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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/compliance-section.d.ts +5 -0
  3. package/src/lib/components/compliance-section.js +302 -180
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enroll-dialog.d.ts +12 -2
  6. package/src/lib/components/enroll-dialog.js +72 -18
  7. package/src/lib/components/enroll-dialog.js.map +1 -1
  8. package/src/lib/components/enrollment-detail.d.ts +5 -1
  9. package/src/lib/components/enrollment-detail.js +14 -8
  10. package/src/lib/components/enrollment-detail.js.map +1 -1
  11. package/src/lib/components/outreach-console-sections.d.ts +1 -1
  12. package/src/lib/components/outreach-console-sections.js +1 -1
  13. package/src/lib/components/outreach-console-sections.js.map +1 -1
  14. package/src/lib/components/sequence-editor.d.ts +13 -0
  15. package/src/lib/components/sequence-editor.js +586 -335
  16. package/src/lib/components/sequence-editor.js.map +1 -1
  17. package/src/lib/components/sequence-preview.js +25 -10
  18. package/src/lib/components/sequence-preview.js.map +1 -1
  19. package/src/lib/components/sequence-step-card.d.ts +10 -0
  20. package/src/lib/components/sequence-step-card.js +320 -202
  21. package/src/lib/components/sequence-step-card.js.map +1 -1
  22. package/src/lib/components/use-outreach-api.d.ts +2 -1
  23. package/src/lib/components/use-outreach-api.js +5 -3
  24. package/src/lib/components/use-outreach-api.js.map +1 -1
  25. package/src/lib/engine/compose.d.ts +1 -1
  26. package/src/lib/engine/compose.js +10 -6
  27. package/src/lib/engine/compose.js.map +1 -1
  28. package/src/lib/engine/delivery-status.js +1 -1
  29. package/src/lib/engine/delivery-status.js.map +1 -1
  30. package/src/lib/engine/enrollment-activity.d.ts +9 -1
  31. package/src/lib/engine/enrollment-activity.js +15 -2
  32. package/src/lib/engine/enrollment-activity.js.map +1 -1
  33. package/src/lib/engine/enrollment-state.d.ts +33 -2
  34. package/src/lib/engine/enrollment-state.js +68 -9
  35. package/src/lib/engine/enrollment-state.js.map +1 -1
  36. package/src/lib/engine/gates.d.ts +2 -2
  37. package/src/lib/engine/gates.js.map +1 -1
  38. package/src/lib/engine/opt-out-intent.js +1 -1
  39. package/src/lib/engine/opt-out-intent.js.map +1 -1
  40. package/src/lib/engine/sending-capacity.d.ts +1 -1
  41. package/src/lib/engine/sending-capacity.js +1 -1
  42. package/src/lib/engine/sending-capacity.js.map +1 -1
  43. package/src/lib/engine/sequence-validation.d.ts +20 -3
  44. package/src/lib/engine/sequence-validation.js +32 -6
  45. package/src/lib/engine/sequence-validation.js.map +1 -1
  46. package/src/lib/engine/thread-classification.js +3 -3
  47. package/src/lib/engine/thread-classification.js.map +1 -1
  48. package/src/lib/enrollment/enroll-people.js +6 -4
  49. package/src/lib/enrollment/enroll-people.js.map +1 -1
  50. package/src/lib/enrollment/gate-lookups.js +5 -4
  51. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  52. package/src/lib/enrollment/step-overrides.d.ts +4 -2
  53. package/src/lib/enrollment/step-overrides.js +11 -4
  54. package/src/lib/enrollment/step-overrides.js.map +1 -1
  55. package/src/lib/mailboxes/mailbox-api.d.ts +5 -1
  56. package/src/lib/mailboxes/mailbox-api.js.map +1 -1
  57. package/src/lib/mailboxes/mailbox-routes.d.ts +5 -2
  58. package/src/lib/mailboxes/mailbox-routes.js +10 -7
  59. package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
  60. package/src/lib/mailboxes/mailbox-settings.d.ts +2 -2
  61. package/src/lib/mailboxes/mailbox-settings.js +1 -1
  62. package/src/lib/mailboxes/mailbox-settings.js.map +1 -1
  63. package/src/lib/model/enrollment-timeline.d.ts +1 -1
  64. package/src/lib/model/enrollment-timeline.js +22 -3
  65. package/src/lib/model/enrollment-timeline.js.map +1 -1
  66. package/src/lib/model/outreach-api.d.ts +19 -1
  67. package/src/lib/model/outreach-api.js.map +1 -1
  68. package/src/lib/model/outreach.types.d.ts +26 -1
  69. package/src/lib/model/outreach.types.js.map +1 -1
  70. package/src/lib/model/sales-topic.d.ts +27 -0
  71. package/src/lib/model/sales-topic.js +27 -0
  72. package/src/lib/model/sales-topic.js.map +1 -0
  73. package/src/lib/model/sequence-draft.d.ts +1 -1
  74. package/src/lib/model/sequence-draft.js +2 -2
  75. package/src/lib/model/sequence-draft.js.map +1 -1
  76. package/src/lib/model/step-preview.d.ts +2 -0
  77. package/src/lib/model/step-preview.js +11 -7
  78. package/src/lib/model/step-preview.js.map +1 -1
  79. package/src/lib/model/stored-records.js +28 -2
  80. package/src/lib/model/stored-records.js.map +1 -1
  81. package/src/lib/plugin.js +1 -1
  82. package/src/lib/plugin.js.map +1 -1
  83. package/src/lib/routes/curate-routes.js +7 -7
  84. package/src/lib/routes/curate-routes.js.map +1 -1
  85. package/src/lib/routes/enroll-routes.d.ts +6 -0
  86. package/src/lib/routes/enroll-routes.js +31 -19
  87. package/src/lib/routes/enroll-routes.js.map +1 -1
  88. package/src/lib/routes/preview-routes.js +14 -5
  89. package/src/lib/routes/preview-routes.js.map +1 -1
  90. package/src/lib/routes/sequence-routes.js +9 -9
  91. package/src/lib/routes/sequence-routes.js.map +1 -1
  92. package/src/lib/routes/step-test-routes.js +3 -3
  93. package/src/lib/routes/step-test-routes.js.map +1 -1
  94. package/src/lib/runtime/campaign-credit.js +2 -2
  95. package/src/lib/runtime/campaign-credit.js.map +1 -1
  96. package/src/lib/runtime/campaign-detach.js +3 -2
  97. package/src/lib/runtime/campaign-detach.js.map +1 -1
  98. package/src/lib/runtime/lead-records.d.ts +6 -1
  99. package/src/lib/runtime/lead-records.js +25 -6
  100. package/src/lib/runtime/lead-records.js.map +1 -1
  101. package/src/lib/runtime/platform-runtime-deps.js +3 -3
  102. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  103. package/src/lib/runtime/send-job.js +20 -4
  104. package/src/lib/runtime/send-job.js.map +1 -1
  105. package/src/lib/runtime/sync-job.js +3 -3
  106. package/src/lib/runtime/sync-job.js.map +1 -1
  107. package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
  108. package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
  109. package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
  110. package/src/lib/transport/gmail-errors.d.ts +27 -0
  111. package/src/lib/transport/gmail-errors.js +56 -0
  112. package/src/lib/transport/gmail-errors.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/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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"}
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/email-text'\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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"}
@@ -18,7 +18,7 @@ import type { OutreachEnrollment, OutreachMailbox, OutreachSendWindow, OutreachS
18
18
  /** The ramp's daily caps: week one, week two, and every week after. */
19
19
  export declare const OUTREACH_RAMP_DAILY_CAPS: readonly number[];
20
20
  /** The most any mailbox sends in one day, whatever is configured. */
21
- export declare const OUTREACH_DAILY_CAP_MAX = 50;
21
+ export declare const OUTREACH_DAILY_CAP_MAX = 100;
22
22
  /** How often the sending runtime runs, in minutes. */
23
23
  export declare const OUTREACH_TICK_MINUTES = 15;
24
24
  /** The most one mailbox sends in one run. */
@@ -47,7 +47,7 @@ import { firstEmailStepIndex } from "./sequence-validation.js";
47
47
  20,
48
48
  30
49
49
  ];
50
- /** The most any mailbox sends in one day, whatever is configured. */ export const OUTREACH_DAILY_CAP_MAX = 50;
50
+ /** The most any mailbox sends in one day, whatever is configured. */ export const OUTREACH_DAILY_CAP_MAX = 100;
51
51
  /** How often the sending runtime runs, in minutes. */ export const OUTREACH_TICK_MINUTES = 15;
52
52
  /** The most one mailbox sends in one run. */ export const OUTREACH_SENDS_PER_TICK_MAX = 5;
53
53
  const MINUTE_MS = 60000;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sending-capacity.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 * HOW MUCH A MAILBOX MAY SEND (AGL-2979).\n *\n * ## The daily cap\n *\n * A mailbox sends at most the least of three numbers in one of its own\n * days: the cap its owner configured; the warm-up ramp — 10 a day in its\n * first week, 20 in its second, 30 from its third — while a ramp is running;\n * and {@link OUTREACH_DAILY_CAP_MAX}, which nothing lifts. Mailbox providers\n * judge a new sender by how its volume grows, and a mailbox that goes from\n * nothing to fifty on its first morning is judged accordingly.\n *\n * ## The tick\n *\n * The runtime runs every {@link OUTREACH_TICK_MINUTES} minutes. What is left\n * of the day's cap is spread over the ticks left in the window, so thirty\n * emails go out across the working day rather than in its first half hour,\n * and no tick sends more than {@link OUTREACH_SENDS_PER_TICK_MAX}.\n *\n * ## Which due enrollments go first\n *\n * Follow-ups before first emails: a thread already started is a\n * conversation a delay would break, and a first email can start tomorrow\n * just as well. Then the longest-waiting, then the id, so two runs over the\n * same enrollments agree. One email per address per tick, whatever the data\n * says.\n *==========================================*/\n\nimport {\n isValidTimeZone,\n nextWeeklyOpening,\n zonedCalendarDaysBetween,\n zonedDayKey,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport type {\n OutreachEnrollment,\n OutreachMailbox,\n OutreachSendWindow,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { effectiveOutreachWindow, isOutreachWindowOpen, outreachWindowSchedule } from './schedule'\nimport { firstEmailStepIndex } from './sequence-validation'\n\n/** The ramp's daily caps: week one, week two, and every week after. */\nexport const OUTREACH_RAMP_DAILY_CAPS: readonly number[] = [10, 20, 30]\n\n/** The most any mailbox sends in one day, whatever is configured. */\nexport const OUTREACH_DAILY_CAP_MAX = 50\n\n/** How often the sending runtime runs, in minutes. */\nexport const OUTREACH_TICK_MINUTES = 15\n\n/** The most one mailbox sends in one run. */\nexport const OUTREACH_SENDS_PER_TICK_MAX = 5\n\nconst MINUTE_MS = 60_000\n\n/**\n * The ramp's cap for the day `nowMs` falls in, or `null` when no ramp is\n * running. Weeks are counted in calendar days of the mailbox's zone from the\n * day the ramp began, which is day one of week one; a start in the future\n * reads as week one.\n */\nexport function outreachRampCap(\n rampStartedAtMs: number | null | undefined,\n nowMs: number,\n timeZone: string,\n): number | null {\n if (typeof rampStartedAtMs !== 'number' || !Number.isFinite(rampStartedAtMs)) return null\n const days = zonedCalendarDaysBetween(rampStartedAtMs, nowMs, timeZone)\n const week = Math.max(0, Math.floor(days / 7))\n return OUTREACH_RAMP_DAILY_CAPS[Math.min(week, OUTREACH_RAMP_DAILY_CAPS.length - 1)]\n}\n\n/** The day's cap: the least of the configured cap, the ramp and the ceiling. */\nexport function outreachDailyCap(input: {\n configuredCap: number\n rampStartedAtMs: number | null | undefined\n nowMs: number\n timeZone: string\n}): number {\n const configured = Number.isFinite(input.configuredCap)\n ? Math.max(0, Math.floor(input.configuredCap))\n : 0\n const ramp = outreachRampCap(input.rampStartedAtMs, input.nowMs, input.timeZone)\n return Math.min(configured, ramp ?? OUTREACH_DAILY_CAP_MAX, OUTREACH_DAILY_CAP_MAX)\n}\n\n/**\n * How many emails the mailbox has sent today: its counter when the counter\n * belongs to today in the mailbox's zone, and `0` when it belongs to an\n * earlier day.\n */\nexport function outreachSentToday(\n health: Pick<OutreachMailbox['health'], 'sentToday' | 'sentOnDay'> | null | undefined,\n nowMs: number,\n timeZone: string,\n): number {\n if (!health || health.sentOnDay !== zonedDayKey(nowMs, timeZone)) return 0\n const sent = Number(health.sentToday)\n return Number.isFinite(sent) ? Math.max(0, Math.floor(sent)) : 0\n}\n\n/** Why a mailbox may send nothing this run. */\nexport type OutreachTickHold = 'mailbox_not_connected' | 'invalid_timezone' | 'daily_cap_reached'\n\nexport interface OutreachTickAllowanceInput {\n mailbox: Pick<\n OutreachMailbox,\n 'status' | 'dailyCap' | 'rampStartedAtMs' | 'timezone' | 'window' | 'health'\n >\n nowMs: number\n /**\n * The window the day's sends are spread across. The mailbox's own by\n * default; when it is closed but a sequence's own window is open, the\n * remainder is not paced and only the per-run maximum holds.\n */\n paceWindow?: OutreachSendWindow | null\n tickMinutes?: number\n perTickMax?: number\n}\n\nexport interface OutreachTickAllowance {\n /** How many emails this run may send from the mailbox. */\n allowance: number\n dailyCap: number\n sentToday: number\n remainingToday: number\n /** Why the allowance is zero, when a rule rather than an empty queue made it so. */\n hold: OutreachTickHold | null\n}\n\n/** How many emails a mailbox may send in this run — see the module note. */\nexport function outreachTickAllowance(input: OutreachTickAllowanceInput): OutreachTickAllowance {\n const { mailbox, nowMs } = input\n const none = (hold: OutreachTickHold, dailyCap = 0, sentToday = 0): OutreachTickAllowance => ({\n allowance: 0,\n dailyCap,\n sentToday,\n remainingToday: Math.max(0, dailyCap - sentToday),\n hold,\n })\n if (mailbox?.status !== 'connected') return none('mailbox_not_connected')\n if (!isValidTimeZone(mailbox.timezone)) return none('invalid_timezone')\n const dailyCap = outreachDailyCap({\n configuredCap: mailbox.dailyCap,\n rampStartedAtMs: mailbox.rampStartedAtMs,\n nowMs,\n timeZone: mailbox.timezone,\n })\n const sentToday = outreachSentToday(mailbox.health, nowMs, mailbox.timezone)\n const remainingToday = Math.max(0, dailyCap - sentToday)\n if (remainingToday === 0) return none('daily_cap_reached', dailyCap, sentToday)\n const tickMinutes = Math.max(1, input.tickMinutes ?? OUTREACH_TICK_MINUTES)\n const perTickMax = Math.max(0, Math.floor(input.perTickMax ?? OUTREACH_SENDS_PER_TICK_MAX))\n const paceWindow = input.paceWindow === undefined ? mailbox.window : input.paceWindow\n const opening = nextWeeklyOpening(nowMs, outreachWindowSchedule(paceWindow), mailbox.timezone)\n let paced = remainingToday\n if (opening && opening.startMs <= nowMs) {\n const ticksLeft = Math.max(1, Math.ceil((opening.endMs - nowMs) / (tickMinutes * MINUTE_MS)))\n paced = Math.ceil(remainingToday / ticksLeft)\n }\n return {\n allowance: Math.min(remainingToday, paced, perTickMax),\n dailyCap,\n sentToday,\n remainingToday,\n hold: null,\n }\n}\n\n/** One due enrollment beside the sequence it runs through. */\nexport interface OutreachDueCandidate {\n enrollment: Pick<\n OutreachEnrollment,\n 'id' | 'status' | 'nextDueAtMs' | 'stepIndex' | 'email' | 'mailboxId' | 'sequenceId'\n >\n sequence: Pick<OutreachSequence, 'id' | 'status' | 'steps' | 'settings'>\n}\n\nexport interface OutreachDueSelection {\n /** Email steps to send this run, in the order to send them. */\n emails: OutreachDueCandidate[]\n /** Task steps due now. Each becomes a CRM task; none spends the allowance. */\n tasks: OutreachDueCandidate[]\n /** Email steps that were due inside their window and did not fit this run. */\n deferred: OutreachDueCandidate[]\n}\n\nexport interface OutreachDueSelectionInput {\n candidates: readonly OutreachDueCandidate[]\n mailbox: Pick<OutreachMailbox, 'id' | 'timezone' | 'window'>\n nowMs: number\n /** {@link outreachTickAllowance}'s `allowance`. */\n allowance: number\n}\n\nconst byDueThenId = (a: OutreachDueCandidate, b: OutreachDueCandidate) =>\n (a.enrollment.nextDueAtMs ?? 0) - (b.enrollment.nextDueAtMs ?? 0) ||\n (a.enrollment.id < b.enrollment.id ? -1 : a.enrollment.id > b.enrollment.id ? 1 : 0)\n\n/**\n * The due enrollments one mailbox runs now, in order — see the module note.\n * An enrollment is due when it and its sequence are active, it belongs to\n * this mailbox, its time has come, and — for an email — its window is open.\n */\nexport function selectDueOutreachEnrollments(input: OutreachDueSelectionInput): OutreachDueSelection {\n const { mailbox, nowMs } = input\n const emails: Array<{ candidate: OutreachDueCandidate; followUp: boolean }> = []\n const tasks: OutreachDueCandidate[] = []\n for (const candidate of input.candidates ?? []) {\n const { enrollment, sequence } = candidate ?? ({} as OutreachDueCandidate)\n if (enrollment?.status !== 'active' || sequence?.status !== 'active') continue\n if (enrollment.sequenceId !== sequence.id || enrollment.mailboxId !== mailbox.id) continue\n const due = enrollment.nextDueAtMs\n if (typeof due !== 'number' || !Number.isFinite(due) || due > nowMs) continue\n const step = sequence.steps?.[enrollment.stepIndex]\n if (step?.kind === 'task') {\n tasks.push(candidate)\n } else if (step?.kind === 'email') {\n const window = effectiveOutreachWindow(sequence.settings?.window, mailbox.window)\n if (!isOutreachWindowOpen(nowMs, window, mailbox.timezone)) continue\n emails.push({\n candidate,\n followUp: enrollment.stepIndex > firstEmailStepIndex(sequence.steps),\n })\n }\n }\n emails.sort(\n (a, b) => Number(b.followUp) - Number(a.followUp) || byDueThenId(a.candidate, b.candidate),\n )\n const allowance = Math.max(0, Math.floor(Number(input.allowance) || 0))\n const selected: OutreachDueCandidate[] = []\n const deferred: OutreachDueCandidate[] = []\n const addresses = new Set<string>()\n for (const { candidate } of emails) {\n const address = String(candidate.enrollment.email ?? '').trim().toLowerCase()\n if (selected.length < allowance && !addresses.has(address)) {\n selected.push(candidate)\n addresses.add(address)\n } else {\n deferred.push(candidate)\n }\n }\n return { emails: selected, tasks: tasks.sort(byDueThenId), deferred }\n}\n"],"names":["isValidTimeZone","nextWeeklyOpening","zonedCalendarDaysBetween","zonedDayKey","effectiveOutreachWindow","isOutreachWindowOpen","outreachWindowSchedule","firstEmailStepIndex","OUTREACH_RAMP_DAILY_CAPS","OUTREACH_DAILY_CAP_MAX","OUTREACH_TICK_MINUTES","OUTREACH_SENDS_PER_TICK_MAX","MINUTE_MS","outreachRampCap","rampStartedAtMs","nowMs","timeZone","Number","isFinite","days","week","Math","max","floor","min","length","outreachDailyCap","input","configured","configuredCap","ramp","outreachSentToday","health","sentOnDay","sent","sentToday","outreachTickAllowance","mailbox","none","hold","dailyCap","allowance","remainingToday","status","timezone","tickMinutes","perTickMax","paceWindow","undefined","window","opening","paced","startMs","ticksLeft","ceil","endMs","byDueThenId","a","b","enrollment","nextDueAtMs","id","selectDueOutreachEnrollments","emails","tasks","candidate","candidates","sequence","sequenceId","mailboxId","due","step","steps","stepIndex","kind","push","settings","followUp","sort","selected","deferred","addresses","Set","address","String","email","trim","toLowerCase","has","add"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sending-capacity.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 * HOW MUCH A MAILBOX MAY SEND (AGL-2979).\n *\n * ## The daily cap\n *\n * A mailbox sends at most the least of three numbers in one of its own\n * days: the cap its owner configured; the warm-up ramp — 10 a day in its\n * first week, 20 in its second, 30 from its third — while a ramp is running;\n * and {@link OUTREACH_DAILY_CAP_MAX}, which nothing lifts. Mailbox providers\n * judge a new sender by how its volume grows, and a mailbox that goes from\n * nothing to fifty on its first morning is judged accordingly.\n *\n * ## The tick\n *\n * The runtime runs every {@link OUTREACH_TICK_MINUTES} minutes. What is left\n * of the day's cap is spread over the ticks left in the window, so thirty\n * emails go out across the working day rather than in its first half hour,\n * and no tick sends more than {@link OUTREACH_SENDS_PER_TICK_MAX}.\n *\n * ## Which due enrollments go first\n *\n * Follow-ups before first emails: a thread already started is a\n * conversation a delay would break, and a first email can start tomorrow\n * just as well. Then the longest-waiting, then the id, so two runs over the\n * same enrollments agree. One email per address per tick, whatever the data\n * says.\n *==========================================*/\n\nimport {\n isValidTimeZone,\n nextWeeklyOpening,\n zonedCalendarDaysBetween,\n zonedDayKey,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport type {\n OutreachEnrollment,\n OutreachMailbox,\n OutreachSendWindow,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { effectiveOutreachWindow, isOutreachWindowOpen, outreachWindowSchedule } from './schedule'\nimport { firstEmailStepIndex } from './sequence-validation'\n\n/** The ramp's daily caps: week one, week two, and every week after. */\nexport const OUTREACH_RAMP_DAILY_CAPS: readonly number[] = [10, 20, 30]\n\n/** The most any mailbox sends in one day, whatever is configured. */\nexport const OUTREACH_DAILY_CAP_MAX = 100\n\n/** How often the sending runtime runs, in minutes. */\nexport const OUTREACH_TICK_MINUTES = 15\n\n/** The most one mailbox sends in one run. */\nexport const OUTREACH_SENDS_PER_TICK_MAX = 5\n\nconst MINUTE_MS = 60_000\n\n/**\n * The ramp's cap for the day `nowMs` falls in, or `null` when no ramp is\n * running. Weeks are counted in calendar days of the mailbox's zone from the\n * day the ramp began, which is day one of week one; a start in the future\n * reads as week one.\n */\nexport function outreachRampCap(\n rampStartedAtMs: number | null | undefined,\n nowMs: number,\n timeZone: string,\n): number | null {\n if (typeof rampStartedAtMs !== 'number' || !Number.isFinite(rampStartedAtMs)) return null\n const days = zonedCalendarDaysBetween(rampStartedAtMs, nowMs, timeZone)\n const week = Math.max(0, Math.floor(days / 7))\n return OUTREACH_RAMP_DAILY_CAPS[Math.min(week, OUTREACH_RAMP_DAILY_CAPS.length - 1)]\n}\n\n/** The day's cap: the least of the configured cap, the ramp and the ceiling. */\nexport function outreachDailyCap(input: {\n configuredCap: number\n rampStartedAtMs: number | null | undefined\n nowMs: number\n timeZone: string\n}): number {\n const configured = Number.isFinite(input.configuredCap)\n ? Math.max(0, Math.floor(input.configuredCap))\n : 0\n const ramp = outreachRampCap(input.rampStartedAtMs, input.nowMs, input.timeZone)\n return Math.min(configured, ramp ?? OUTREACH_DAILY_CAP_MAX, OUTREACH_DAILY_CAP_MAX)\n}\n\n/**\n * How many emails the mailbox has sent today: its counter when the counter\n * belongs to today in the mailbox's zone, and `0` when it belongs to an\n * earlier day.\n */\nexport function outreachSentToday(\n health: Pick<OutreachMailbox['health'], 'sentToday' | 'sentOnDay'> | null | undefined,\n nowMs: number,\n timeZone: string,\n): number {\n if (!health || health.sentOnDay !== zonedDayKey(nowMs, timeZone)) return 0\n const sent = Number(health.sentToday)\n return Number.isFinite(sent) ? Math.max(0, Math.floor(sent)) : 0\n}\n\n/** Why a mailbox may send nothing this run. */\nexport type OutreachTickHold = 'mailbox_not_connected' | 'invalid_timezone' | 'daily_cap_reached'\n\nexport interface OutreachTickAllowanceInput {\n mailbox: Pick<\n OutreachMailbox,\n 'status' | 'dailyCap' | 'rampStartedAtMs' | 'timezone' | 'window' | 'health'\n >\n nowMs: number\n /**\n * The window the day's sends are spread across. The mailbox's own by\n * default; when it is closed but a sequence's own window is open, the\n * remainder is not paced and only the per-run maximum holds.\n */\n paceWindow?: OutreachSendWindow | null\n tickMinutes?: number\n perTickMax?: number\n}\n\nexport interface OutreachTickAllowance {\n /** How many emails this run may send from the mailbox. */\n allowance: number\n dailyCap: number\n sentToday: number\n remainingToday: number\n /** Why the allowance is zero, when a rule rather than an empty queue made it so. */\n hold: OutreachTickHold | null\n}\n\n/** How many emails a mailbox may send in this run — see the module note. */\nexport function outreachTickAllowance(input: OutreachTickAllowanceInput): OutreachTickAllowance {\n const { mailbox, nowMs } = input\n const none = (hold: OutreachTickHold, dailyCap = 0, sentToday = 0): OutreachTickAllowance => ({\n allowance: 0,\n dailyCap,\n sentToday,\n remainingToday: Math.max(0, dailyCap - sentToday),\n hold,\n })\n if (mailbox?.status !== 'connected') return none('mailbox_not_connected')\n if (!isValidTimeZone(mailbox.timezone)) return none('invalid_timezone')\n const dailyCap = outreachDailyCap({\n configuredCap: mailbox.dailyCap,\n rampStartedAtMs: mailbox.rampStartedAtMs,\n nowMs,\n timeZone: mailbox.timezone,\n })\n const sentToday = outreachSentToday(mailbox.health, nowMs, mailbox.timezone)\n const remainingToday = Math.max(0, dailyCap - sentToday)\n if (remainingToday === 0) return none('daily_cap_reached', dailyCap, sentToday)\n const tickMinutes = Math.max(1, input.tickMinutes ?? OUTREACH_TICK_MINUTES)\n const perTickMax = Math.max(0, Math.floor(input.perTickMax ?? OUTREACH_SENDS_PER_TICK_MAX))\n const paceWindow = input.paceWindow === undefined ? mailbox.window : input.paceWindow\n const opening = nextWeeklyOpening(nowMs, outreachWindowSchedule(paceWindow), mailbox.timezone)\n let paced = remainingToday\n if (opening && opening.startMs <= nowMs) {\n const ticksLeft = Math.max(1, Math.ceil((opening.endMs - nowMs) / (tickMinutes * MINUTE_MS)))\n paced = Math.ceil(remainingToday / ticksLeft)\n }\n return {\n allowance: Math.min(remainingToday, paced, perTickMax),\n dailyCap,\n sentToday,\n remainingToday,\n hold: null,\n }\n}\n\n/** One due enrollment beside the sequence it runs through. */\nexport interface OutreachDueCandidate {\n enrollment: Pick<\n OutreachEnrollment,\n 'id' | 'status' | 'nextDueAtMs' | 'stepIndex' | 'email' | 'mailboxId' | 'sequenceId'\n >\n sequence: Pick<OutreachSequence, 'id' | 'status' | 'steps' | 'settings'>\n}\n\nexport interface OutreachDueSelection {\n /** Email steps to send this run, in the order to send them. */\n emails: OutreachDueCandidate[]\n /** Task steps due now. Each becomes a CRM task; none spends the allowance. */\n tasks: OutreachDueCandidate[]\n /** Email steps that were due inside their window and did not fit this run. */\n deferred: OutreachDueCandidate[]\n}\n\nexport interface OutreachDueSelectionInput {\n candidates: readonly OutreachDueCandidate[]\n mailbox: Pick<OutreachMailbox, 'id' | 'timezone' | 'window'>\n nowMs: number\n /** {@link outreachTickAllowance}'s `allowance`. */\n allowance: number\n}\n\nconst byDueThenId = (a: OutreachDueCandidate, b: OutreachDueCandidate) =>\n (a.enrollment.nextDueAtMs ?? 0) - (b.enrollment.nextDueAtMs ?? 0) ||\n (a.enrollment.id < b.enrollment.id ? -1 : a.enrollment.id > b.enrollment.id ? 1 : 0)\n\n/**\n * The due enrollments one mailbox runs now, in order — see the module note.\n * An enrollment is due when it and its sequence are active, it belongs to\n * this mailbox, its time has come, and — for an email — its window is open.\n */\nexport function selectDueOutreachEnrollments(input: OutreachDueSelectionInput): OutreachDueSelection {\n const { mailbox, nowMs } = input\n const emails: Array<{ candidate: OutreachDueCandidate; followUp: boolean }> = []\n const tasks: OutreachDueCandidate[] = []\n for (const candidate of input.candidates ?? []) {\n const { enrollment, sequence } = candidate ?? ({} as OutreachDueCandidate)\n if (enrollment?.status !== 'active' || sequence?.status !== 'active') continue\n if (enrollment.sequenceId !== sequence.id || enrollment.mailboxId !== mailbox.id) continue\n const due = enrollment.nextDueAtMs\n if (typeof due !== 'number' || !Number.isFinite(due) || due > nowMs) continue\n const step = sequence.steps?.[enrollment.stepIndex]\n if (step?.kind === 'task') {\n tasks.push(candidate)\n } else if (step?.kind === 'email') {\n const window = effectiveOutreachWindow(sequence.settings?.window, mailbox.window)\n if (!isOutreachWindowOpen(nowMs, window, mailbox.timezone)) continue\n emails.push({\n candidate,\n followUp: enrollment.stepIndex > firstEmailStepIndex(sequence.steps),\n })\n }\n }\n emails.sort(\n (a, b) => Number(b.followUp) - Number(a.followUp) || byDueThenId(a.candidate, b.candidate),\n )\n const allowance = Math.max(0, Math.floor(Number(input.allowance) || 0))\n const selected: OutreachDueCandidate[] = []\n const deferred: OutreachDueCandidate[] = []\n const addresses = new Set<string>()\n for (const { candidate } of emails) {\n const address = String(candidate.enrollment.email ?? '').trim().toLowerCase()\n if (selected.length < allowance && !addresses.has(address)) {\n selected.push(candidate)\n addresses.add(address)\n } else {\n deferred.push(candidate)\n }\n }\n return { emails: selected, tasks: tasks.sort(byDueThenId), deferred }\n}\n"],"names":["isValidTimeZone","nextWeeklyOpening","zonedCalendarDaysBetween","zonedDayKey","effectiveOutreachWindow","isOutreachWindowOpen","outreachWindowSchedule","firstEmailStepIndex","OUTREACH_RAMP_DAILY_CAPS","OUTREACH_DAILY_CAP_MAX","OUTREACH_TICK_MINUTES","OUTREACH_SENDS_PER_TICK_MAX","MINUTE_MS","outreachRampCap","rampStartedAtMs","nowMs","timeZone","Number","isFinite","days","week","Math","max","floor","min","length","outreachDailyCap","input","configured","configuredCap","ramp","outreachSentToday","health","sentOnDay","sent","sentToday","outreachTickAllowance","mailbox","none","hold","dailyCap","allowance","remainingToday","status","timezone","tickMinutes","perTickMax","paceWindow","undefined","window","opening","paced","startMs","ticksLeft","ceil","endMs","byDueThenId","a","b","enrollment","nextDueAtMs","id","selectDueOutreachEnrollments","emails","tasks","candidate","candidates","sequence","sequenceId","mailboxId","due","step","steps","stepIndex","kind","push","settings","followUp","sort","selected","deferred","addresses","Set","address","String","email","trim","toLowerCase","has","add"],"mappings":"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"}
@@ -44,14 +44,31 @@ export interface OutreachValidationIssue {
44
44
  }
45
45
  /** Whether any issue in the list stops a save or an activation. */
46
46
  export declare function hasOutreachValidationErrors(issues: readonly OutreachValidationIssue[]): boolean;
47
- /** The index of the first email step, or `-1` for a sequence with none. */
48
- export declare function firstEmailStepIndex(steps: readonly OutreachSequenceStep[] | null | undefined): number;
47
+ /**
48
+ * The index of the first email step at or after `fromIndex` — the first
49
+ * email an enrollment that began there sends (AGL-3228) — or `-1` when
50
+ * there is none.
51
+ */
52
+ export declare function firstEmailStepIndex(steps: readonly OutreachSequenceStep[] | null | undefined, fromIndex?: number): number;
49
53
  /**
50
54
  * Whether the email at `index` is sent as a reply in the thread an earlier
51
55
  * email started: every email after the first unless it says otherwise. The
52
56
  * first email starts the thread whatever its `replyInThread` says.
57
+ *
58
+ * `startStepIndex` is where the enrollment began (AGL-3228): the first email
59
+ * it sends from there starts the thread, because the steps before it were
60
+ * sent by hand, if at all, and there is no email of ours to answer.
61
+ */
62
+ export declare function isInThreadEmailStep(steps: readonly OutreachSequenceStep[] | null | undefined, index: number, startStepIndex?: number): boolean;
63
+ /**
64
+ * The subject an email step starts a thread with, before merge fields
65
+ * (AGL-3228). A step written to start a thread carries its own. One written
66
+ * as a reply may start the thread only for an enrollment that began at it,
67
+ * and its subject field is not what its author wrote for it — so it takes
68
+ * the sequence's first email's subject, the one the person already had by
69
+ * hand.
53
70
  */
54
- export declare function isInThreadEmailStep(steps: readonly OutreachSequenceStep[] | null | undefined, index: number): boolean;
71
+ export declare function outreachThreadStartSubject(steps: readonly OutreachSequenceStep[] | null | undefined, index: number): string;
55
72
  /**
56
73
  * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,
57
74
  * `AW:` — which on an email that answers nothing tells the recipient a
@@ -31,7 +31,7 @@
31
31
  *==========================================*/ import { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
32
32
  import { readListUnsubscribeSetting } from "@aglyn/shared-util-email/list-unsubscribe";
33
33
  import { CRM_MERGE_FIELDS, crmMergeFieldsIn } from "@aglyn/aglyn/app-utils/crm-email-templates";
34
- import { crmThreadSubject } from "@aglyn/aglyn/app-utils/crm-inbound";
34
+ import { threadSubject } from "@aglyn/aglyn/app-utils/email-text";
35
35
  import { OUTREACH_DEFAULT_ALLOWED_COUNTRIES, OUTREACH_MAX_EMAIL_STEPS, OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS, OUTREACH_MAX_STEPS, OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS, OUTREACH_TASK_KINDS } from "../model/outreach.types.js";
36
36
  import { outreachBodyLinks } from "./click-tracking.js";
37
37
  /** The merge field the rep's signal sentence is written into a step with. */ export const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine';
@@ -65,19 +65,45 @@ const warning = (path, code, message)=>({
65
65
  });
66
66
  const asText = (value)=>typeof value === 'string' ? value : '';
67
67
  const isEmailStep = (step)=>(step == null ? void 0 : step.kind) === 'email';
68
- /** The index of the first email step, or `-1` for a sequence with none. */ export function firstEmailStepIndex(steps) {
69
- return (steps != null ? steps : []).findIndex(isEmailStep);
68
+ /**
69
+ * The index of the first email step at or after `fromIndex` — the first
70
+ * email an enrollment that began there sends (AGL-3228) — or `-1` when
71
+ * there is none.
72
+ */ export function firstEmailStepIndex(steps, fromIndex = 0) {
73
+ const list = steps != null ? steps : [];
74
+ for(let index = Math.max(0, fromIndex); index < list.length; index += 1){
75
+ if (isEmailStep(list[index])) return index;
76
+ }
77
+ return -1;
70
78
  }
71
79
  /**
72
80
  * Whether the email at `index` is sent as a reply in the thread an earlier
73
81
  * email started: every email after the first unless it says otherwise. The
74
82
  * first email starts the thread whatever its `replyInThread` says.
75
- */ export function isInThreadEmailStep(steps, index) {
83
+ *
84
+ * `startStepIndex` is where the enrollment began (AGL-3228): the first email
85
+ * it sends from there starts the thread, because the steps before it were
86
+ * sent by hand, if at all, and there is no email of ours to answer.
87
+ */ export function isInThreadEmailStep(steps, index, startStepIndex = 0) {
76
88
  const step = steps == null ? void 0 : steps[index];
77
89
  if (!isEmailStep(step)) return false;
78
- const first = firstEmailStepIndex(steps);
90
+ const first = firstEmailStepIndex(steps, startStepIndex);
79
91
  return index > first && step.replyInThread !== false;
80
92
  }
93
+ /**
94
+ * The subject an email step starts a thread with, before merge fields
95
+ * (AGL-3228). A step written to start a thread carries its own. One written
96
+ * as a reply may start the thread only for an enrollment that began at it,
97
+ * and its subject field is not what its author wrote for it — so it takes
98
+ * the sequence's first email's subject, the one the person already had by
99
+ * hand.
100
+ */ export function outreachThreadStartSubject(steps, index) {
101
+ const step = steps == null ? void 0 : steps[index];
102
+ if (!isEmailStep(step)) return '';
103
+ if (!isInThreadEmailStep(steps, index)) return asText(step.subject);
104
+ const first = steps == null ? void 0 : steps[firstEmailStepIndex(steps)];
105
+ return isEmailStep(first) ? asText(first.subject) : '';
106
+ }
81
107
  /**
82
108
  * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,
83
109
  * `AW:` — which on an email that answers nothing tells the recipient a
@@ -85,7 +111,7 @@ const isEmailStep = (step)=>(step == null ? void 0 : step.kind) === 'email';
85
111
  * reader's, so the two agree on what one looks like.
86
112
  */ export function outreachSubjectHasReplyPrefix(subject) {
87
113
  const collapsed = asText(subject).replace(/\s+/g, ' ').trim();
88
- return collapsed !== '' && crmThreadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX);
114
+ return collapsed !== '' && threadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX);
89
115
  }
90
116
  const WEEKDAY_NAMES = [
91
117
  'Sunday',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sequence-validation.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT A SEQUENCE MAY BE (AGL-2979).\n *\n * One validator for the three readers that need it: the editor, which shows\n * every issue beside the field it belongs to; the route that saves, which\n * refuses a sequence with an error; and activation, which also refuses while\n * the organization's footer cannot be written. Each issue names its field by\n * a dotted path (`steps.2.subject`) and says what to do in a sentence.\n *\n * ERRORS AND WARNINGS. An error is a sequence the engine cannot send or the\n * rules forbid: a fifth email, a first email with no subject, a `Re:` on a\n * message that answers nothing. A warning is a sequence that sends and\n * probably is not what its author meant — a merge field nobody fills, a\n * first email that never uses the personal line the rep writes at\n * enrollment.\n *==========================================*/\n\nimport { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport { readListUnsubscribeSetting } from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n CRM_MERGE_FIELDS,\n type CrmMergeFieldGroup,\n crmMergeFieldsIn,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport {\n OUTREACH_DEFAULT_ALLOWED_COUNTRIES,\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n OUTREACH_MAX_STEPS,\n OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS,\n OUTREACH_TASK_KINDS,\n type OutreachEmailStep,\n type OutreachOrgSettings,\n type OutreachSendWindow,\n type OutreachSequence,\n type OutreachSequenceSettings,\n type OutreachSequenceStep,\n} from '../model/outreach.types'\nimport { outreachBodyLinks } from './click-tracking'\n\n/** The merge field the rep's signal sentence is written into a step with. */\nexport const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine'\n\n/** A merge field as the Outreach editor's picker offers it. */\nexport interface OutreachMergeFieldDefinition {\n key: string\n label: string\n group: CrmMergeFieldGroup | 'Enrollment'\n}\n\n/**\n * Every field an Outreach step may use: the CRM's, which the composer fills\n * through the CRM's own resolver, and the enrollment's.\n */\nexport const OUTREACH_MERGE_FIELDS: readonly OutreachMergeFieldDefinition[] = [\n ...CRM_MERGE_FIELDS,\n { key: OUTREACH_PERSONAL_LINE_FIELD, label: 'Personal line', group: 'Enrollment' },\n]\n\n/** The longest name a sequence carries. */\nexport const OUTREACH_SEQUENCE_NAME_MAX = 120\n\n/** The longest title a task step carries. */\nexport const OUTREACH_TASK_TITLE_MAX = 200\n\nexport type OutreachValidationSeverity = 'error' | 'warning'\n\nexport type OutreachValidationCode =\n | 'name_required'\n | 'name_too_long'\n | 'mailbox_required'\n | 'steps_required'\n | 'too_many_steps'\n | 'too_many_email_steps'\n | 'step_id_required'\n | 'duplicate_step_id'\n | 'unknown_step_kind'\n | 'delay_invalid'\n | 'subject_required'\n | 'subject_too_long'\n | 'subject_reply_prefix'\n | 'body_required'\n | 'body_and_template'\n | 'body_too_long'\n | 'unknown_merge_field'\n | 'personal_line_unused'\n | 'task_kind_invalid'\n | 'task_title_required'\n | 'task_title_too_long'\n | 'window_days_invalid'\n | 'window_hours_invalid'\n | 'countries_required'\n | 'country_invalid'\n | 'legal_name_required'\n | 'postal_address_required'\n | 'too_many_links'\n | 'discount_language'\n | 'not_plain_text'\n\nexport interface OutreachValidationIssue {\n /** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */\n path: string\n code: OutreachValidationCode\n message: string\n severity: OutreachValidationSeverity\n}\n\n/** Whether any issue in the list stops a save or an activation. */\nexport function hasOutreachValidationErrors(\n issues: readonly OutreachValidationIssue[],\n): boolean {\n return issues.some((issue) => issue.severity === 'error')\n}\n\nconst error = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'error' })\n\nconst warning = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'warning' })\n\nconst asText = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nconst isEmailStep = (step: OutreachSequenceStep | null | undefined): step is OutreachEmailStep =>\n step?.kind === 'email'\n\n/** The index of the first email step, or `-1` for a sequence with none. */\nexport function firstEmailStepIndex(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n): number {\n return (steps ?? []).findIndex(isEmailStep)\n}\n\n/**\n * Whether the email at `index` is sent as a reply in the thread an earlier\n * email started: every email after the first unless it says otherwise. The\n * first email starts the thread whatever its `replyInThread` says.\n */\nexport function isInThreadEmailStep(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n): boolean {\n const step = steps?.[index]\n if (!isEmailStep(step)) return false\n const first = firstEmailStepIndex(steps)\n return index > first && step.replyInThread !== false\n}\n\n/**\n * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,\n * `AW:` — which on an email that answers nothing tells the recipient a\n * conversation exists that does not. The prefixes are the CRM thread\n * reader's, so the two agree on what one looks like.\n */\nexport function outreachSubjectHasReplyPrefix(subject: unknown): boolean {\n const collapsed = asText(subject).replace(/\\s+/g, ' ').trim()\n return collapsed !== '' && crmThreadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\nconst WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']\n\n/** A sending window's shape: at least one weekday, and an opening before a close. */\nexport function validateOutreachSendWindow(\n window: OutreachSendWindow | null | undefined,\n path = 'window',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const days = Array.isArray(window?.days) ? window.days : []\n const daysValid =\n days.length > 0 &&\n days.every((day) => Number.isInteger(day) && day >= 0 && day < WEEKDAY_NAMES.length) &&\n new Set(days).size === days.length\n if (!daysValid) {\n issues.push(\n error(`${path}.days`, 'window_days_invalid', 'Pick at least one day of the week to send on.'),\n )\n }\n const start = window?.startMinute\n const end = window?.endMinute\n const hoursValid =\n Number.isInteger(start) &&\n Number.isInteger(end) &&\n start >= 0 &&\n end <= 24 * 60 &&\n start < end\n if (!hoursValid) {\n issues.push(\n error(\n path,\n 'window_hours_invalid',\n 'The sending hours need a start time before the end time, within one day.',\n ),\n )\n }\n return issues\n}\n\n/**\n * The allowed countries, as stored or typed, reduced to unique uppercase\n * alpha-2 codes in the order given. Anything that is not two letters is\n * dropped, so a caller that needs to report it checks the raw value.\n */\nexport function normalizeOutreachAllowedCountries(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const codes: string[] = []\n for (const entry of value) {\n const code = asText(entry).trim().toUpperCase()\n if (/^[A-Z]{2}$/.test(code) && !codes.includes(code)) codes.push(code)\n }\n return codes\n}\n\n/**\n * A stored settings map with the defaults applied: the United States alone,\n * no customers, the mailbox's own window. A field a writer never set reads\n * as its default rather than as a permission nobody gave.\n */\nexport function readOutreachSequenceSettings(\n raw: Partial<OutreachSequenceSettings> | Record<string, unknown> | null | undefined,\n): OutreachSequenceSettings {\n const data = (raw ?? {}) as Record<string, unknown>\n const countries = normalizeOutreachAllowedCountries(data['allowedCountries'])\n const window = data['window'] as OutreachSendWindow | null | undefined\n return {\n window: window && typeof window === 'object' ? window : null,\n allowedCountries: Array.isArray(data['allowedCountries'])\n ? countries\n : [...OUTREACH_DEFAULT_ALLOWED_COUNTRIES],\n allowCustomers: data['allowCustomers'] === true,\n // Absent is off (AGL-3239). Every sequence written before the setting\n // existed reads as untracked, which is what it was.\n trackClicks: data['trackClicks'] === true,\n // Absent is off (AGL-3395): a sequence stays plain text unless it asks\n // for the HTML part, and every one written before the setting did not.\n countOpens: data['countOpens'] === true,\n // Absent is off (AGL-3296): a sequence is one-to-one mail, and every\n // sequence written before the setting existed reads as headerless.\n // Read by the shared reader campaigns use too (AGL-3307), with the\n // sequence's own default.\n listUnsubscribe: readListUnsubscribeSetting(data['listUnsubscribe'], false),\n }\n}\n\nexport function validateOutreachSequenceSettings(\n settings: OutreachSequenceSettings | null | undefined,\n path = 'settings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (settings?.window) {\n issues.push(...validateOutreachSendWindow(settings.window, `${path}.window`))\n }\n const raw = Array.isArray(settings?.allowedCountries) ? settings.allowedCountries : []\n if (!raw.length) {\n issues.push(\n error(\n `${path}.allowedCountries`,\n 'countries_required',\n 'Choose at least one country this sequence may send to.',\n ),\n )\n }\n raw.forEach((entry, index) => {\n if (!/^[A-Z]{2}$/.test(asText(entry))) {\n issues.push(\n error(\n `${path}.allowedCountries.${index}`,\n 'country_invalid',\n `\"${asText(entry)}\" isn't a two-letter country code, like US.`,\n ),\n )\n }\n })\n return issues\n}\n\nconst KNOWN_MERGE_FIELDS = new Set(OUTREACH_MERGE_FIELDS.map((field) => field.key))\n\nfunction mergeFieldWarnings(text: string, path: string): OutreachValidationIssue[] {\n return crmMergeFieldsIn(text)\n .filter((key) => !KNOWN_MERGE_FIELDS.has(key))\n .map((key) =>\n warning(\n path,\n 'unknown_merge_field',\n `{{${key}}} isn't a merge field, so it will be sent empty.`,\n ),\n )\n}\n\nfunction delayIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachSequenceStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const delay = step.delayBusinessDays\n const followsAnEmail = isEmailStep(step) && index > firstEmailStepIndex(steps)\n const min = followsAnEmail ? OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS : 0\n if (Number.isInteger(delay) && delay >= min && delay <= OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS) {\n return []\n }\n const message =\n min > 0\n ? `Wait ${min}–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this email.`\n : `Wait 0–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this step.`\n return [error(`${path}.delayBusinessDays`, 'delay_invalid', message)]\n}\n\nfunction emailStepIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachEmailStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const first = firstEmailStepIndex(steps) === index\n const startsThread = !isInThreadEmailStep(steps, index)\n const subject = asText(step.subject)\n if (startsThread) {\n if (!subject.trim()) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_required',\n first\n ? 'The first email needs a subject.'\n : 'An email that starts a new thread needs a subject.',\n ),\n )\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_too_long',\n `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`,\n ),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(step.body)\n const templateId = asText(step.templateId).trim()\n if (body.trim() && templateId) {\n issues.push(\n error(\n `${path}.body`,\n 'body_and_template',\n 'Write the email here or pick a template, not both.',\n ),\n )\n } else if (!body.trim() && !templateId) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email, or pick a template.'))\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n if (body.trim()) {\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n if (first && !crmMergeFieldsIn(body).includes(OUTREACH_PERSONAL_LINE_FIELD)) {\n issues.push(\n warning(\n `${path}.body`,\n 'personal_line_unused',\n `The first email doesn't use {{${OUTREACH_PERSONAL_LINE_FIELD}}}, so the line written for each person won't appear.`,\n ),\n )\n }\n }\n return issues\n}\n\n/** The steps of a sequence: their count, their ids, and each one's own fields. */\nexport function validateOutreachSteps(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n path = 'steps',\n): OutreachValidationIssue[] {\n const list = Array.isArray(steps) ? steps : []\n const issues: OutreachValidationIssue[] = []\n if (!list.length) {\n issues.push(error(path, 'steps_required', 'Add at least one step.'))\n return issues\n }\n if (list.length > OUTREACH_MAX_STEPS) {\n issues.push(\n error(path, 'too_many_steps', `A sequence holds at most ${OUTREACH_MAX_STEPS} steps.`),\n )\n }\n const emailCount = list.filter(isEmailStep).length\n if (emailCount > OUTREACH_MAX_EMAIL_STEPS) {\n issues.push(\n error(\n path,\n 'too_many_email_steps',\n `A sequence sends at most ${OUTREACH_MAX_EMAIL_STEPS} emails to one person.`,\n ),\n )\n }\n const seen = new Set<string>()\n list.forEach((step, index) => {\n const stepPath = `${path}.${index}`\n const id = asText(step?.id).trim()\n if (!id) {\n issues.push(error(`${stepPath}.id`, 'step_id_required', 'Every step needs an id.'))\n } else if (seen.has(id)) {\n issues.push(error(`${stepPath}.id`, 'duplicate_step_id', 'Two steps share an id.'))\n }\n seen.add(id)\n if (isEmailStep(step)) {\n issues.push(...delayIssues(list, step, index, stepPath))\n issues.push(...emailStepIssues(list, step, index, stepPath))\n } else if (step?.kind === 'task') {\n issues.push(...delayIssues(list, step, index, stepPath))\n if (!(OUTREACH_TASK_KINDS as readonly string[]).includes(step.taskKind)) {\n issues.push(\n error(`${stepPath}.taskKind`, 'task_kind_invalid', 'Pick LinkedIn, Call or To-do.'),\n )\n }\n const title = asText(step.title)\n if (!title.trim()) {\n issues.push(error(`${stepPath}.title`, 'task_title_required', 'Give the task a title.'))\n } else if (title.length > OUTREACH_TASK_TITLE_MAX) {\n issues.push(\n error(\n `${stepPath}.title`,\n 'task_title_too_long',\n `Keep the title under ${OUTREACH_TASK_TITLE_MAX} characters.`,\n ),\n )\n }\n } else {\n issues.push(\n error(stepPath, 'unknown_step_kind', 'A step is an email or a task.'),\n )\n }\n })\n return issues\n}\n\n/** Everything a stored sequence has to be before it may be saved. */\nexport function validateOutreachSequence(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const name = asText(sequence?.name)\n if (!name.trim()) {\n issues.push(error('name', 'name_required', 'Name the sequence.'))\n } else if (name.length > OUTREACH_SEQUENCE_NAME_MAX) {\n issues.push(\n error('name', 'name_too_long', `Keep the name under ${OUTREACH_SEQUENCE_NAME_MAX} characters.`),\n )\n }\n const steps = sequence?.steps ?? []\n if (steps.some(isEmailStep) && !asText(sequence?.mailboxId).trim()) {\n issues.push(\n error('mailboxId', 'mailbox_required', 'Choose the mailbox this sequence sends from.'),\n )\n }\n issues.push(...validateOutreachSteps(steps))\n issues.push(...validateOutreachSequenceSettings(sequence?.settings))\n return issues\n}\n\n/*==========================================\n * A CURATED STEP (AGL-3324).\n *\n * One person's own copy of a step is held to everything a step is held to —\n * a subject where the email starts a thread, a body, the lengths, the merge\n * fields — and to the outbound playbook's rules the sequence editor trusts\n * its author with but an AI draft cannot be trusted with: one link at most,\n * list price only, plain text. Each is an ERROR: a draft that breaks one is\n * refused rather than stored, whoever wrote it.\n *==========================================*/\n\n/**\n * Words the playbook keeps out of every message: a discount, a coupon, a\n * cohort, a slot count, a deadline. Matched on word boundaries, so \"count\"\n * and \"expired domain\" pass and \"coupon\" and \"20% off\" do not.\n */\nconst DISCOUNT_LANGUAGE =\n /\\b(discounts?|discounted|coupons?|promo(?:tional)?\\s+codes?|promo\\b|vouchers?|\\d+\\s*%\\s*off|percent\\s+off|money\\s+off|special\\s+offer|limited[-\\s]time|expires?\\s+(?:on|at|in|soon|today|tomorrow)|deadline|(?:only|last|first)\\s+\\d+\\s+(?:spots?|slots?|seats?|places?)|cohorts?)\\b/i\n\n/** Markup a plain-text email would show as typed: bold stars, a markdown link, a heading line. */\nconst MARKDOWN = /\\*\\*[^*\\n]+\\*\\*|\\[[^\\]\\n]+\\]\\([^)\\s]+\\)|^#{1,6}\\s|<\\/?[a-z][^>]*>/im\n\nexport interface OutreachCuratedStepInput {\n /** The subject, when the copy carries one. Read only where the email starts a thread. */\n subject?: string | null\n body?: string | null\n /** Whether the email this copy is for starts a thread — `!isInThreadEmailStep`. */\n startsThread: boolean\n}\n\n/** Everything one person's copy of a step has to be before it may be stored — see the section note. */\nexport function validateOutreachCuratedStep(\n input: OutreachCuratedStepInput,\n path = 'override',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const subject = asText(input.subject)\n if (input.startsThread && input.subject !== undefined && input.subject !== null) {\n if (!subject.trim()) {\n issues.push(error(`${path}.subject`, 'subject_required', 'This email starts a thread, so it needs a subject.'))\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(`${path}.subject`, 'subject_too_long', `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`),\n )\n }\n if (DISCOUNT_LANGUAGE.test(subject)) {\n issues.push(\n error(`${path}.subject`, 'discount_language', 'The subject offers a discount or a deadline. List price only.'),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(input.body)\n if (!body.trim()) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email.'))\n return issues\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n const links = outreachBodyLinks(body)\n if (links.length > 1) {\n issues.push(\n error(\n `${path}.body`,\n 'too_many_links',\n `This email carries ${links.length} links. A sequence email carries one at most.`,\n ),\n )\n }\n if (DISCOUNT_LANGUAGE.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'discount_language',\n 'This email offers a discount, a coupon, a cohort, a slot count or a deadline. List price only.',\n ),\n )\n }\n if (MARKDOWN.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'not_plain_text',\n 'This email carries markup — bold stars, a markdown link or a heading — which a plain-text email shows as typed.',\n ),\n )\n }\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n return issues\n}\n\n/**\n * The organization's footer fields: a legal name and a postal address, the\n * two things the law requires every commercial email to carry.\n */\nexport function validateOutreachOrgSettings(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n path = 'orgSettings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (!asText(settings?.legalName).trim()) {\n issues.push(\n error(\n `${path}.legalName`,\n 'legal_name_required',\n \"Add your organization's legal name in Sequences settings. It goes in every email's footer.\",\n ),\n )\n }\n if (!asText(settings?.postalAddress).trim()) {\n issues.push(\n error(\n `${path}.postalAddress`,\n 'postal_address_required',\n \"Add your organization's postal address in Sequences settings. The law requires one in every sales email.\",\n ),\n )\n }\n return issues\n}\n\n/**\n * What activating a sequence refuses on: every issue saving refuses on, and\n * an organization whose footer cannot be written yet.\n */\nexport function validateOutreachSequenceActivation(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n): OutreachValidationIssue[] {\n return [...validateOutreachSequence(sequence), ...validateOutreachOrgSettings(orgSettings)]\n}\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","readListUnsubscribeSetting","CRM_MERGE_FIELDS","crmMergeFieldsIn","crmThreadSubject","OUTREACH_DEFAULT_ALLOWED_COUNTRIES","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS","OUTREACH_MAX_STEPS","OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS","OUTREACH_TASK_KINDS","outreachBodyLinks","OUTREACH_PERSONAL_LINE_FIELD","OUTREACH_MERGE_FIELDS","key","label","group","OUTREACH_SEQUENCE_NAME_MAX","OUTREACH_TASK_TITLE_MAX","hasOutreachValidationErrors","issues","some","issue","severity","error","path","code","message","warning","asText","value","isEmailStep","step","kind","firstEmailStepIndex","steps","findIndex","isInThreadEmailStep","index","first","replyInThread","outreachSubjectHasReplyPrefix","subject","collapsed","replace","trim","slice","WEEKDAY_NAMES","validateOutreachSendWindow","window","days","Array","isArray","daysValid","length","every","day","Number","isInteger","Set","size","push","start","startMinute","end","endMinute","hoursValid","normalizeOutreachAllowedCountries","codes","entry","toUpperCase","test","includes","readOutreachSequenceSettings","raw","data","countries","allowedCountries","allowCustomers","trackClicks","countOpens","listUnsubscribe","validateOutreachSequenceSettings","settings","forEach","KNOWN_MERGE_FIELDS","map","field","mergeFieldWarnings","text","filter","has","delayIssues","delay","delayBusinessDays","followsAnEmail","min","emailStepIssues","startsThread","body","templateId","toLocaleString","validateOutreachSteps","list","emailCount","seen","stepPath","id","add","taskKind","title","validateOutreachSequence","sequence","name","mailboxId","DISCOUNT_LANGUAGE","MARKDOWN","validateOutreachCuratedStep","input","undefined","links","validateOutreachOrgSettings","legalName","postalAddress","validateOutreachSequenceActivation","orgSettings"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sequence-validation.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT A SEQUENCE MAY BE (AGL-2979).\n *\n * One validator for the three readers that need it: the editor, which shows\n * every issue beside the field it belongs to; the route that saves, which\n * refuses a sequence with an error; and activation, which also refuses while\n * the organization's footer cannot be written. Each issue names its field by\n * a dotted path (`steps.2.subject`) and says what to do in a sentence.\n *\n * ERRORS AND WARNINGS. An error is a sequence the engine cannot send or the\n * rules forbid: a fifth email, a first email with no subject, a `Re:` on a\n * message that answers nothing. A warning is a sequence that sends and\n * probably is not what its author meant — a merge field nobody fills, a\n * first email that never uses the personal line the rep writes at\n * enrollment.\n *==========================================*/\n\nimport { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport { readListUnsubscribeSetting } from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n CRM_MERGE_FIELDS,\n type CrmMergeFieldGroup,\n crmMergeFieldsIn,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { threadSubject } from '@aglyn/aglyn/app-utils/email-text'\nimport {\n OUTREACH_DEFAULT_ALLOWED_COUNTRIES,\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n OUTREACH_MAX_STEPS,\n OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS,\n OUTREACH_TASK_KINDS,\n type OutreachEmailStep,\n type OutreachOrgSettings,\n type OutreachSendWindow,\n type OutreachSequence,\n type OutreachSequenceSettings,\n type OutreachSequenceStep,\n} from '../model/outreach.types'\nimport { outreachBodyLinks } from './click-tracking'\n\n/** The merge field the rep's signal sentence is written into a step with. */\nexport const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine'\n\n/** A merge field as the Outreach editor's picker offers it. */\nexport interface OutreachMergeFieldDefinition {\n key: string\n label: string\n group: CrmMergeFieldGroup | 'Enrollment'\n}\n\n/**\n * Every field an Outreach step may use: the CRM's, which the composer fills\n * through the CRM's own resolver, and the enrollment's.\n */\nexport const OUTREACH_MERGE_FIELDS: readonly OutreachMergeFieldDefinition[] = [\n ...CRM_MERGE_FIELDS,\n { key: OUTREACH_PERSONAL_LINE_FIELD, label: 'Personal line', group: 'Enrollment' },\n]\n\n/** The longest name a sequence carries. */\nexport const OUTREACH_SEQUENCE_NAME_MAX = 120\n\n/** The longest title a task step carries. */\nexport const OUTREACH_TASK_TITLE_MAX = 200\n\nexport type OutreachValidationSeverity = 'error' | 'warning'\n\nexport type OutreachValidationCode =\n | 'name_required'\n | 'name_too_long'\n | 'mailbox_required'\n | 'steps_required'\n | 'too_many_steps'\n | 'too_many_email_steps'\n | 'step_id_required'\n | 'duplicate_step_id'\n | 'unknown_step_kind'\n | 'delay_invalid'\n | 'subject_required'\n | 'subject_too_long'\n | 'subject_reply_prefix'\n | 'body_required'\n | 'body_and_template'\n | 'body_too_long'\n | 'unknown_merge_field'\n | 'personal_line_unused'\n | 'task_kind_invalid'\n | 'task_title_required'\n | 'task_title_too_long'\n | 'window_days_invalid'\n | 'window_hours_invalid'\n | 'countries_required'\n | 'country_invalid'\n | 'legal_name_required'\n | 'postal_address_required'\n | 'too_many_links'\n | 'discount_language'\n | 'not_plain_text'\n\nexport interface OutreachValidationIssue {\n /** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */\n path: string\n code: OutreachValidationCode\n message: string\n severity: OutreachValidationSeverity\n}\n\n/** Whether any issue in the list stops a save or an activation. */\nexport function hasOutreachValidationErrors(\n issues: readonly OutreachValidationIssue[],\n): boolean {\n return issues.some((issue) => issue.severity === 'error')\n}\n\nconst error = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'error' })\n\nconst warning = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'warning' })\n\nconst asText = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nconst isEmailStep = (step: OutreachSequenceStep | null | undefined): step is OutreachEmailStep =>\n step?.kind === 'email'\n\n/**\n * The index of the first email step at or after `fromIndex` — the first\n * email an enrollment that began there sends (AGL-3228) — or `-1` when\n * there is none.\n */\nexport function firstEmailStepIndex(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n fromIndex = 0,\n): number {\n const list = steps ?? []\n for (let index = Math.max(0, fromIndex); index < list.length; index += 1) {\n if (isEmailStep(list[index])) return index\n }\n return -1\n}\n\n/**\n * Whether the email at `index` is sent as a reply in the thread an earlier\n * email started: every email after the first unless it says otherwise. The\n * first email starts the thread whatever its `replyInThread` says.\n *\n * `startStepIndex` is where the enrollment began (AGL-3228): the first email\n * it sends from there starts the thread, because the steps before it were\n * sent by hand, if at all, and there is no email of ours to answer.\n */\nexport function isInThreadEmailStep(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n startStepIndex = 0,\n): boolean {\n const step = steps?.[index]\n if (!isEmailStep(step)) return false\n const first = firstEmailStepIndex(steps, startStepIndex)\n return index > first && step.replyInThread !== false\n}\n\n/**\n * The subject an email step starts a thread with, before merge fields\n * (AGL-3228). A step written to start a thread carries its own. One written\n * as a reply may start the thread only for an enrollment that began at it,\n * and its subject field is not what its author wrote for it — so it takes\n * the sequence's first email's subject, the one the person already had by\n * hand.\n */\nexport function outreachThreadStartSubject(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n): string {\n const step = steps?.[index]\n if (!isEmailStep(step)) return ''\n if (!isInThreadEmailStep(steps, index)) return asText(step.subject)\n const first = steps?.[firstEmailStepIndex(steps)]\n return isEmailStep(first) ? asText(first.subject) : ''\n}\n\n/**\n * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,\n * `AW:` — which on an email that answers nothing tells the recipient a\n * conversation exists that does not. The prefixes are the CRM thread\n * reader's, so the two agree on what one looks like.\n */\nexport function outreachSubjectHasReplyPrefix(subject: unknown): boolean {\n const collapsed = asText(subject).replace(/\\s+/g, ' ').trim()\n return collapsed !== '' && threadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\nconst WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']\n\n/** A sending window's shape: at least one weekday, and an opening before a close. */\nexport function validateOutreachSendWindow(\n window: OutreachSendWindow | null | undefined,\n path = 'window',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const days = Array.isArray(window?.days) ? window.days : []\n const daysValid =\n days.length > 0 &&\n days.every((day) => Number.isInteger(day) && day >= 0 && day < WEEKDAY_NAMES.length) &&\n new Set(days).size === days.length\n if (!daysValid) {\n issues.push(\n error(`${path}.days`, 'window_days_invalid', 'Pick at least one day of the week to send on.'),\n )\n }\n const start = window?.startMinute\n const end = window?.endMinute\n const hoursValid =\n Number.isInteger(start) &&\n Number.isInteger(end) &&\n start >= 0 &&\n end <= 24 * 60 &&\n start < end\n if (!hoursValid) {\n issues.push(\n error(\n path,\n 'window_hours_invalid',\n 'The sending hours need a start time before the end time, within one day.',\n ),\n )\n }\n return issues\n}\n\n/**\n * The allowed countries, as stored or typed, reduced to unique uppercase\n * alpha-2 codes in the order given. Anything that is not two letters is\n * dropped, so a caller that needs to report it checks the raw value.\n */\nexport function normalizeOutreachAllowedCountries(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const codes: string[] = []\n for (const entry of value) {\n const code = asText(entry).trim().toUpperCase()\n if (/^[A-Z]{2}$/.test(code) && !codes.includes(code)) codes.push(code)\n }\n return codes\n}\n\n/**\n * A stored settings map with the defaults applied: the United States alone,\n * no customers, the mailbox's own window. A field a writer never set reads\n * as its default rather than as a permission nobody gave.\n */\nexport function readOutreachSequenceSettings(\n raw: Partial<OutreachSequenceSettings> | Record<string, unknown> | null | undefined,\n): OutreachSequenceSettings {\n const data = (raw ?? {}) as Record<string, unknown>\n const countries = normalizeOutreachAllowedCountries(data['allowedCountries'])\n const window = data['window'] as OutreachSendWindow | null | undefined\n return {\n window: window && typeof window === 'object' ? window : null,\n allowedCountries: Array.isArray(data['allowedCountries'])\n ? countries\n : [...OUTREACH_DEFAULT_ALLOWED_COUNTRIES],\n allowCustomers: data['allowCustomers'] === true,\n // Absent is off (AGL-3239). Every sequence written before the setting\n // existed reads as untracked, which is what it was.\n trackClicks: data['trackClicks'] === true,\n // Absent is off (AGL-3395): a sequence stays plain text unless it asks\n // for the HTML part, and every one written before the setting did not.\n countOpens: data['countOpens'] === true,\n // Absent is off (AGL-3296): a sequence is one-to-one mail, and every\n // sequence written before the setting existed reads as headerless.\n // Read by the shared reader campaigns use too (AGL-3307), with the\n // sequence's own default.\n listUnsubscribe: readListUnsubscribeSetting(data['listUnsubscribe'], false),\n }\n}\n\nexport function validateOutreachSequenceSettings(\n settings: OutreachSequenceSettings | null | undefined,\n path = 'settings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (settings?.window) {\n issues.push(...validateOutreachSendWindow(settings.window, `${path}.window`))\n }\n const raw = Array.isArray(settings?.allowedCountries) ? settings.allowedCountries : []\n if (!raw.length) {\n issues.push(\n error(\n `${path}.allowedCountries`,\n 'countries_required',\n 'Choose at least one country this sequence may send to.',\n ),\n )\n }\n raw.forEach((entry, index) => {\n if (!/^[A-Z]{2}$/.test(asText(entry))) {\n issues.push(\n error(\n `${path}.allowedCountries.${index}`,\n 'country_invalid',\n `\"${asText(entry)}\" isn't a two-letter country code, like US.`,\n ),\n )\n }\n })\n return issues\n}\n\nconst KNOWN_MERGE_FIELDS = new Set(OUTREACH_MERGE_FIELDS.map((field) => field.key))\n\nfunction mergeFieldWarnings(text: string, path: string): OutreachValidationIssue[] {\n return crmMergeFieldsIn(text)\n .filter((key) => !KNOWN_MERGE_FIELDS.has(key))\n .map((key) =>\n warning(\n path,\n 'unknown_merge_field',\n `{{${key}}} isn't a merge field, so it will be sent empty.`,\n ),\n )\n}\n\nfunction delayIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachSequenceStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const delay = step.delayBusinessDays\n const followsAnEmail = isEmailStep(step) && index > firstEmailStepIndex(steps)\n const min = followsAnEmail ? OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS : 0\n if (Number.isInteger(delay) && delay >= min && delay <= OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS) {\n return []\n }\n const message =\n min > 0\n ? `Wait ${min}–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this email.`\n : `Wait 0–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this step.`\n return [error(`${path}.delayBusinessDays`, 'delay_invalid', message)]\n}\n\nfunction emailStepIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachEmailStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const first = firstEmailStepIndex(steps) === index\n const startsThread = !isInThreadEmailStep(steps, index)\n const subject = asText(step.subject)\n if (startsThread) {\n if (!subject.trim()) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_required',\n first\n ? 'The first email needs a subject.'\n : 'An email that starts a new thread needs a subject.',\n ),\n )\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_too_long',\n `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`,\n ),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(step.body)\n const templateId = asText(step.templateId).trim()\n if (body.trim() && templateId) {\n issues.push(\n error(\n `${path}.body`,\n 'body_and_template',\n 'Write the email here or pick a template, not both.',\n ),\n )\n } else if (!body.trim() && !templateId) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email, or pick a template.'))\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n if (body.trim()) {\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n if (first && !crmMergeFieldsIn(body).includes(OUTREACH_PERSONAL_LINE_FIELD)) {\n issues.push(\n warning(\n `${path}.body`,\n 'personal_line_unused',\n `The first email doesn't use {{${OUTREACH_PERSONAL_LINE_FIELD}}}, so the line written for each person won't appear.`,\n ),\n )\n }\n }\n return issues\n}\n\n/** The steps of a sequence: their count, their ids, and each one's own fields. */\nexport function validateOutreachSteps(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n path = 'steps',\n): OutreachValidationIssue[] {\n const list = Array.isArray(steps) ? steps : []\n const issues: OutreachValidationIssue[] = []\n if (!list.length) {\n issues.push(error(path, 'steps_required', 'Add at least one step.'))\n return issues\n }\n if (list.length > OUTREACH_MAX_STEPS) {\n issues.push(\n error(path, 'too_many_steps', `A sequence holds at most ${OUTREACH_MAX_STEPS} steps.`),\n )\n }\n const emailCount = list.filter(isEmailStep).length\n if (emailCount > OUTREACH_MAX_EMAIL_STEPS) {\n issues.push(\n error(\n path,\n 'too_many_email_steps',\n `A sequence sends at most ${OUTREACH_MAX_EMAIL_STEPS} emails to one person.`,\n ),\n )\n }\n const seen = new Set<string>()\n list.forEach((step, index) => {\n const stepPath = `${path}.${index}`\n const id = asText(step?.id).trim()\n if (!id) {\n issues.push(error(`${stepPath}.id`, 'step_id_required', 'Every step needs an id.'))\n } else if (seen.has(id)) {\n issues.push(error(`${stepPath}.id`, 'duplicate_step_id', 'Two steps share an id.'))\n }\n seen.add(id)\n if (isEmailStep(step)) {\n issues.push(...delayIssues(list, step, index, stepPath))\n issues.push(...emailStepIssues(list, step, index, stepPath))\n } else if (step?.kind === 'task') {\n issues.push(...delayIssues(list, step, index, stepPath))\n if (!(OUTREACH_TASK_KINDS as readonly string[]).includes(step.taskKind)) {\n issues.push(\n error(`${stepPath}.taskKind`, 'task_kind_invalid', 'Pick LinkedIn, Call or To-do.'),\n )\n }\n const title = asText(step.title)\n if (!title.trim()) {\n issues.push(error(`${stepPath}.title`, 'task_title_required', 'Give the task a title.'))\n } else if (title.length > OUTREACH_TASK_TITLE_MAX) {\n issues.push(\n error(\n `${stepPath}.title`,\n 'task_title_too_long',\n `Keep the title under ${OUTREACH_TASK_TITLE_MAX} characters.`,\n ),\n )\n }\n } else {\n issues.push(\n error(stepPath, 'unknown_step_kind', 'A step is an email or a task.'),\n )\n }\n })\n return issues\n}\n\n/** Everything a stored sequence has to be before it may be saved. */\nexport function validateOutreachSequence(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const name = asText(sequence?.name)\n if (!name.trim()) {\n issues.push(error('name', 'name_required', 'Name the sequence.'))\n } else if (name.length > OUTREACH_SEQUENCE_NAME_MAX) {\n issues.push(\n error('name', 'name_too_long', `Keep the name under ${OUTREACH_SEQUENCE_NAME_MAX} characters.`),\n )\n }\n const steps = sequence?.steps ?? []\n if (steps.some(isEmailStep) && !asText(sequence?.mailboxId).trim()) {\n issues.push(\n error('mailboxId', 'mailbox_required', 'Choose the mailbox this sequence sends from.'),\n )\n }\n issues.push(...validateOutreachSteps(steps))\n issues.push(...validateOutreachSequenceSettings(sequence?.settings))\n return issues\n}\n\n/*==========================================\n * A CURATED STEP (AGL-3324).\n *\n * One person's own copy of a step is held to everything a step is held to —\n * a subject where the email starts a thread, a body, the lengths, the merge\n * fields — and to the outbound playbook's rules the sequence editor trusts\n * its author with but an AI draft cannot be trusted with: one link at most,\n * list price only, plain text. Each is an ERROR: a draft that breaks one is\n * refused rather than stored, whoever wrote it.\n *==========================================*/\n\n/**\n * Words the playbook keeps out of every message: a discount, a coupon, a\n * cohort, a slot count, a deadline. Matched on word boundaries, so \"count\"\n * and \"expired domain\" pass and \"coupon\" and \"20% off\" do not.\n */\nconst DISCOUNT_LANGUAGE =\n /\\b(discounts?|discounted|coupons?|promo(?:tional)?\\s+codes?|promo\\b|vouchers?|\\d+\\s*%\\s*off|percent\\s+off|money\\s+off|special\\s+offer|limited[-\\s]time|expires?\\s+(?:on|at|in|soon|today|tomorrow)|deadline|(?:only|last|first)\\s+\\d+\\s+(?:spots?|slots?|seats?|places?)|cohorts?)\\b/i\n\n/** Markup a plain-text email would show as typed: bold stars, a markdown link, a heading line. */\nconst MARKDOWN = /\\*\\*[^*\\n]+\\*\\*|\\[[^\\]\\n]+\\]\\([^)\\s]+\\)|^#{1,6}\\s|<\\/?[a-z][^>]*>/im\n\nexport interface OutreachCuratedStepInput {\n /** The subject, when the copy carries one. Read only where the email starts a thread. */\n subject?: string | null\n body?: string | null\n /** Whether the email this copy is for starts a thread — `!isInThreadEmailStep`. */\n startsThread: boolean\n}\n\n/** Everything one person's copy of a step has to be before it may be stored — see the section note. */\nexport function validateOutreachCuratedStep(\n input: OutreachCuratedStepInput,\n path = 'override',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const subject = asText(input.subject)\n if (input.startsThread && input.subject !== undefined && input.subject !== null) {\n if (!subject.trim()) {\n issues.push(error(`${path}.subject`, 'subject_required', 'This email starts a thread, so it needs a subject.'))\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(`${path}.subject`, 'subject_too_long', `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`),\n )\n }\n if (DISCOUNT_LANGUAGE.test(subject)) {\n issues.push(\n error(`${path}.subject`, 'discount_language', 'The subject offers a discount or a deadline. List price only.'),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(input.body)\n if (!body.trim()) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email.'))\n return issues\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n const links = outreachBodyLinks(body)\n if (links.length > 1) {\n issues.push(\n error(\n `${path}.body`,\n 'too_many_links',\n `This email carries ${links.length} links. A sequence email carries one at most.`,\n ),\n )\n }\n if (DISCOUNT_LANGUAGE.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'discount_language',\n 'This email offers a discount, a coupon, a cohort, a slot count or a deadline. List price only.',\n ),\n )\n }\n if (MARKDOWN.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'not_plain_text',\n 'This email carries markup — bold stars, a markdown link or a heading — which a plain-text email shows as typed.',\n ),\n )\n }\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n return issues\n}\n\n/**\n * The organization's footer fields: a legal name and a postal address, the\n * two things the law requires every commercial email to carry.\n */\nexport function validateOutreachOrgSettings(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n path = 'orgSettings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (!asText(settings?.legalName).trim()) {\n issues.push(\n error(\n `${path}.legalName`,\n 'legal_name_required',\n \"Add your organization's legal name in Sequences settings. It goes in every email's footer.\",\n ),\n )\n }\n if (!asText(settings?.postalAddress).trim()) {\n issues.push(\n error(\n `${path}.postalAddress`,\n 'postal_address_required',\n \"Add your organization's postal address in Sequences settings. The law requires one in every sales email.\",\n ),\n )\n }\n return issues\n}\n\n/**\n * What activating a sequence refuses on: every issue saving refuses on, and\n * an organization whose footer cannot be written yet.\n */\nexport function validateOutreachSequenceActivation(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n): OutreachValidationIssue[] {\n return [...validateOutreachSequence(sequence), ...validateOutreachOrgSettings(orgSettings)]\n}\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","readListUnsubscribeSetting","CRM_MERGE_FIELDS","crmMergeFieldsIn","threadSubject","OUTREACH_DEFAULT_ALLOWED_COUNTRIES","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS","OUTREACH_MAX_STEPS","OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS","OUTREACH_TASK_KINDS","outreachBodyLinks","OUTREACH_PERSONAL_LINE_FIELD","OUTREACH_MERGE_FIELDS","key","label","group","OUTREACH_SEQUENCE_NAME_MAX","OUTREACH_TASK_TITLE_MAX","hasOutreachValidationErrors","issues","some","issue","severity","error","path","code","message","warning","asText","value","isEmailStep","step","kind","firstEmailStepIndex","steps","fromIndex","list","index","Math","max","length","isInThreadEmailStep","startStepIndex","first","replyInThread","outreachThreadStartSubject","subject","outreachSubjectHasReplyPrefix","collapsed","replace","trim","slice","WEEKDAY_NAMES","validateOutreachSendWindow","window","days","Array","isArray","daysValid","every","day","Number","isInteger","Set","size","push","start","startMinute","end","endMinute","hoursValid","normalizeOutreachAllowedCountries","codes","entry","toUpperCase","test","includes","readOutreachSequenceSettings","raw","data","countries","allowedCountries","allowCustomers","trackClicks","countOpens","listUnsubscribe","validateOutreachSequenceSettings","settings","forEach","KNOWN_MERGE_FIELDS","map","field","mergeFieldWarnings","text","filter","has","delayIssues","delay","delayBusinessDays","followsAnEmail","min","emailStepIssues","startsThread","body","templateId","toLocaleString","validateOutreachSteps","emailCount","seen","stepPath","id","add","taskKind","title","validateOutreachSequence","sequence","name","mailboxId","DISCOUNT_LANGUAGE","MARKDOWN","validateOutreachCuratedStep","input","undefined","links","validateOutreachOrgSettings","legalName","postalAddress","validateOutreachSequenceActivation","orgSettings"],"mappings":"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"}
@@ -46,7 +46,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
46
46
  * bounces that report this recipient are counted either way, because mailbox
47
47
  * health counts every one.
48
48
  *==========================================*/ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
49
- import { crmThreadSubject, emailAddressOf, htmlToPlainText } from "@aglyn/aglyn/app-utils/crm-inbound";
49
+ import { emailAddressOf, htmlToPlainText, threadSubject } from "@aglyn/aglyn/app-utils/email-text";
50
50
  import { readOutreachDeliveryReport, isMailerDaemonMessage } from "./delivery-status.js";
51
51
  import { detectOutreachOptOutIntent, detectOutreachOptOutSubject } from "./opt-out-intent.js";
52
52
  import { outreachHeader } from "./thread-message.js";
@@ -161,8 +161,8 @@ function readableText(message) {
161
161
  // A subject is read only on a message that starts fresh — no `Re:`, and
162
162
  // not the thread's own — and only when it says nothing but stop.
163
163
  const subject = String((_message_subject = message.subject) != null ? _message_subject : '').replace(/\s+/g, ' ').trim();
164
- const fresh = crmThreadSubject(subject) === subject;
165
- if (subject && fresh && subject !== crmThreadSubject(context.threadSubject)) {
164
+ const fresh = threadSubject(subject) === subject;
165
+ if (subject && fresh && subject !== threadSubject(context.threadSubject)) {
166
166
  const subjectIntent = detectOutreachOptOutSubject(subject);
167
167
  if (subjectIntent.optOut) {
168
168
  return _extends({}, base, {