@aglyn/plugins-outreach 1.0.0-beta.196 → 1.0.0-beta.198

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 (105) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/compliance-section.d.ts +3 -1
  3. package/src/lib/components/compliance-section.js +110 -45
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enroll-dialog.d.ts +7 -1
  6. package/src/lib/components/enroll-dialog.js +106 -3
  7. package/src/lib/components/enroll-dialog.js.map +1 -1
  8. package/src/lib/components/mailbox-card.js +11 -2
  9. package/src/lib/components/mailbox-card.js.map +1 -1
  10. package/src/lib/components/outreach-ui.js +1 -0
  11. package/src/lib/components/outreach-ui.js.map +1 -1
  12. package/src/lib/components/sequence-detail.js +1 -0
  13. package/src/lib/components/sequence-detail.js.map +1 -1
  14. package/src/lib/components/sequence-editor.d.ts +2 -0
  15. package/src/lib/components/sequence-editor.js +51 -26
  16. package/src/lib/components/sequence-editor.js.map +1 -1
  17. package/src/lib/components/sequence-step-card.d.ts +7 -0
  18. package/src/lib/components/sequence-step-card.js +37 -14
  19. package/src/lib/components/sequence-step-card.js.map +1 -1
  20. package/src/lib/components/sequences-section.js +12 -3
  21. package/src/lib/components/sequences-section.js.map +1 -1
  22. package/src/lib/components/step-test-dialog.d.ts +31 -0
  23. package/src/lib/components/step-test-dialog.js +247 -0
  24. package/src/lib/components/step-test-dialog.js.map +1 -0
  25. package/src/lib/components/use-outreach-api.d.ts +8 -1
  26. package/src/lib/components/use-outreach-api.js +4 -0
  27. package/src/lib/components/use-outreach-api.js.map +1 -1
  28. package/src/lib/components/use-outreach-data.d.ts +6 -1
  29. package/src/lib/components/use-outreach-data.js +6 -3
  30. package/src/lib/components/use-outreach-data.js.map +1 -1
  31. package/src/lib/constants/api-routes.d.ts +7 -0
  32. package/src/lib/constants/api-routes.js +6 -0
  33. package/src/lib/constants/api-routes.js.map +1 -1
  34. package/src/lib/engine/delivery-status.d.ts +8 -0
  35. package/src/lib/engine/delivery-status.js +10 -5
  36. package/src/lib/engine/delivery-status.js.map +1 -1
  37. package/src/lib/engine/enrollment-state.d.ts +2 -0
  38. package/src/lib/engine/enrollment-state.js +2 -2
  39. package/src/lib/engine/enrollment-state.js.map +1 -1
  40. package/src/lib/engine/gates.d.ts +28 -1
  41. package/src/lib/engine/gates.js +28 -1
  42. package/src/lib/engine/gates.js.map +1 -1
  43. package/src/lib/engine/mail-gateway.d.ts +97 -0
  44. package/src/lib/engine/mail-gateway.js +226 -0
  45. package/src/lib/engine/mail-gateway.js.map +1 -0
  46. package/src/lib/engine/mailbox-health.d.ts +3 -3
  47. package/src/lib/engine/mailbox-health.js +17 -11
  48. package/src/lib/engine/mailbox-health.js.map +1 -1
  49. package/src/lib/engine/thread-classification.d.ts +2 -0
  50. package/src/lib/engine/thread-classification.js +2 -1
  51. package/src/lib/engine/thread-classification.js.map +1 -1
  52. package/src/lib/enrollment/enroll-people.d.ts +6 -1
  53. package/src/lib/enrollment/enroll-people.js +18 -4
  54. package/src/lib/enrollment/enroll-people.js.map +1 -1
  55. package/src/lib/enrollment/gate-lookups.d.ts +7 -0
  56. package/src/lib/enrollment/gate-lookups.js +11 -2
  57. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  58. package/src/lib/model/outreach-api.d.ts +35 -1
  59. package/src/lib/model/outreach-api.js.map +1 -1
  60. package/src/lib/model/outreach.types.d.ts +85 -3
  61. package/src/lib/model/outreach.types.js +21 -2
  62. package/src/lib/model/outreach.types.js.map +1 -1
  63. package/src/lib/model/step-preview.d.ts +7 -1
  64. package/src/lib/model/step-preview.js +3 -2
  65. package/src/lib/model/step-preview.js.map +1 -1
  66. package/src/lib/routes/enroll-routes.js +23 -4
  67. package/src/lib/routes/enroll-routes.js.map +1 -1
  68. package/src/lib/routes/enrollment-routes.js +15 -1
  69. package/src/lib/routes/enrollment-routes.js.map +1 -1
  70. package/src/lib/routes/platform-deps.d.ts +2 -2
  71. package/src/lib/routes/platform-deps.js +2 -2
  72. package/src/lib/routes/platform-deps.js.map +1 -1
  73. package/src/lib/routes/preview-routes.d.ts +24 -0
  74. package/src/lib/routes/preview-routes.js +99 -95
  75. package/src/lib/routes/preview-routes.js.map +1 -1
  76. package/src/lib/routes/register-routes.d.ts +7 -0
  77. package/src/lib/routes/register-routes.js +27 -0
  78. package/src/lib/routes/register-routes.js.map +1 -1
  79. package/src/lib/routes/route-deps.d.ts +6 -0
  80. package/src/lib/routes/route-deps.js.map +1 -1
  81. package/src/lib/routes/step-test-routes.d.ts +88 -0
  82. package/src/lib/routes/step-test-routes.js +275 -0
  83. package/src/lib/routes/step-test-routes.js.map +1 -0
  84. package/src/lib/runtime/click-events.js +10 -0
  85. package/src/lib/runtime/click-events.js.map +1 -1
  86. package/src/lib/runtime/click-link.d.ts +11 -0
  87. package/src/lib/runtime/click-link.js +9 -4
  88. package/src/lib/runtime/click-link.js.map +1 -1
  89. package/src/lib/runtime/click-route.js +3 -0
  90. package/src/lib/runtime/click-route.js.map +1 -1
  91. package/src/lib/runtime/gateway-ledger.d.ts +46 -0
  92. package/src/lib/runtime/gateway-ledger.js +113 -0
  93. package/src/lib/runtime/gateway-ledger.js.map +1 -0
  94. package/src/lib/runtime/platform-runtime-deps.js +3 -0
  95. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  96. package/src/lib/runtime/runtime-deps.d.ts +6 -0
  97. package/src/lib/runtime/runtime-deps.js.map +1 -1
  98. package/src/lib/runtime/send-job.js +48 -0
  99. package/src/lib/runtime/send-job.js.map +1 -1
  100. package/src/lib/runtime/sync-job.d.ts +2 -0
  101. package/src/lib/runtime/sync-job.js +40 -3
  102. package/src/lib/runtime/sync-job.js.map +1 -1
  103. package/src/lib/storage/domain-intel-store.d.ts +107 -0
  104. package/src/lib/storage/domain-intel-store.js +320 -0
  105. package/src/lib/storage/domain-intel-store.js.map +1 -0
@@ -26,6 +26,12 @@ export interface OutreachDeliveryRecipient {
26
26
  status: string | null;
27
27
  /** `Diagnostic-Code`, without its type prefix. */
28
28
  diagnostic: string | null;
29
+ /**
30
+ * `Remote-MTA`, without its type prefix and lowercased: the receiving
31
+ * server that answered — which names the gateway in front of the domain
32
+ * when it is one (AGL-3326).
33
+ */
34
+ remoteMta: string | null;
29
35
  /** `hard`, `soft`, or `null` for a block that reports no failure. */
30
36
  kind: OutreachBounceKind | null;
31
37
  }
@@ -43,6 +49,8 @@ export interface OutreachDeliveryReport {
43
49
  status: string | null;
44
50
  /** The diagnostic of the worst failure, when one was given. */
45
51
  diagnostic: string | null;
52
+ /** The `Remote-MTA` of the worst failure, when the report named one. */
53
+ remoteMta: string | null;
46
54
  }
47
55
  /** Whether a message comes from a mail system rather than from a person. */
48
56
  export declare function isMailerDaemonMessage(message: Pick<OutreachThreadMessage, 'from'>): boolean;
@@ -121,7 +121,7 @@ function prose(message) {
121
121
  * What a delivery report says, or `null` when the message is not one — see
122
122
  * the module note for how a standard report and a prose bounce are read.
123
123
  */ export function readOutreachDeliveryReport(message) {
124
- var _message_parts, _ref, _ref1, _ref2;
124
+ var _message_parts, _ref, _ref1, _ref2, _ref3;
125
125
  const standard = isDeliveryStatusReport(message);
126
126
  const daemon = isMailerDaemonMessage(message);
127
127
  if (!standard && !(daemon && BOUNCE_WORDS.test(prose(message)))) return null;
@@ -137,11 +137,14 @@ function prose(message) {
137
137
  const statusMatch = STATUS_CODE.exec((_block_status = block['status']) != null ? _block_status : '');
138
138
  const status = statusMatch ? statusMatch[0] : null;
139
139
  const diagnostic = block['diagnostic-code'] ? block['diagnostic-code'].replace(/^[a-z0-9-]+\s*;\s*/i, '') : null;
140
+ // `dns; mx.example.com. (192.0.2.10, ...)` → the host alone.
141
+ const remoteMta = block['remote-mta'] ? block['remote-mta'].replace(/^[a-z0-9-]+\s*;\s*/i, '').trim().split(/\s+/)[0].toLowerCase().replace(/\.$/, '') || null : null;
140
142
  return {
141
143
  address: (_typedAddress = typedAddress(block['final-recipient'])) != null ? _typedAddress : typedAddress(block['original-recipient']),
142
144
  action,
143
145
  status,
144
146
  diagnostic,
147
+ remoteMta,
145
148
  kind: kindFor(action, status)
146
149
  };
147
150
  });
@@ -152,13 +155,13 @@ function prose(message) {
152
155
  }
153
156
  }
154
157
  if (!worst && !recipients.length) {
155
- var _ref3, _ref4;
158
+ var _ref4, _ref5;
156
159
  // A report without readable blocks, or a bounce written as prose: the
157
160
  // code the text quotes, else the words.
158
161
  const text = prose(message);
159
162
  const code = FAILURE_STATUS_CODE.exec(text);
160
163
  const reply = FAILURE_REPLY_CODE.exec(text);
161
- const statusClass = (_ref3 = (_ref4 = code == null ? void 0 : code[1]) != null ? _ref4 : reply == null ? void 0 : reply[1]) != null ? _ref3 : null;
164
+ const statusClass = (_ref4 = (_ref5 = code == null ? void 0 : code[1]) != null ? _ref5 : reply == null ? void 0 : reply[1]) != null ? _ref4 : null;
162
165
  const retrying = /\b(delay(ed)?|will (keep )?retry|not (been )?delivered yet|temporar(y|ily))\b/i.test(text);
163
166
  const kind = statusClass === '5' ? 'hard' : statusClass === '4' || retrying ? 'soft' : 'hard';
164
167
  return {
@@ -166,7 +169,8 @@ function prose(message) {
166
169
  recipients: [],
167
170
  failedAddresses: failedRecipientsHeader(message),
168
171
  status: code ? code[0] : null,
169
- diagnostic: null
172
+ diagnostic: null,
173
+ remoteMta: null
170
174
  };
171
175
  }
172
176
  // Only the addresses the report's own kind is about: a report that
@@ -177,7 +181,8 @@ function prose(message) {
177
181
  recipients,
178
182
  failedAddresses: failedAddresses.length || !worst ? failedAddresses : failedRecipientsHeader(message),
179
183
  status: (_ref1 = worst == null ? void 0 : worst.status) != null ? _ref1 : null,
180
- diagnostic: (_ref2 = worst == null ? void 0 : worst.diagnostic) != null ? _ref2 : null
184
+ diagnostic: (_ref2 = worst == null ? void 0 : worst.diagnostic) != null ? _ref2 : null,
185
+ remoteMta: (_ref3 = worst == null ? void 0 : worst.remoteMta) != null ? _ref3 : null
181
186
  };
182
187
  }
183
188
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/delivery-status.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 * BOUNCES (AGL-2979).\n *\n * A mail server that cannot deliver says so in a DELIVERY STATUS\n * NOTIFICATION (RFC 3464): a `multipart/report; report-type=delivery-status`\n * message, usually from `mailer-daemon@` or `postmaster@`, whose\n * `message/delivery-status` part carries one block per recipient —\n * `Final-Recipient`, `Action` and `Status`, an enhanced code like `5.1.1`.\n * Gmail writes one into the sender's own thread when a message it relayed is\n * refused, and a receiving server that accepted a message and then gave up\n * sends one of its own.\n *\n * ## Hard and soft\n *\n * A `5.x.x` status on a `failed` action is HARD: the address does not\n * exist, the domain refuses the sender, the mailbox is closed — trying again\n * will not help, and the enrollment stops. A `4.x.x` status, or a `delayed`\n * action, is SOFT: the server is busy or the mailbox is full, the sending\n * server is still retrying, and nothing changes. A report whose action is\n * `delivered`, `relayed` or `expanded` is not a bounce at all.\n *\n * ## Reports that are not standard\n *\n * Some servers send a bounce as plain prose. A message from a mailer daemon\n * whose subject or text reads like one is read for the status code the prose\n * quotes (`550 5.1.1`, `421 4.7.0`), and failing that for the words that say\n * permanent or temporary — soft when it says it will retry, hard otherwise.\n *==========================================*/\n\nimport { emailAddressOf } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\nexport type OutreachBounceKind = 'hard' | 'soft'\n\n/** What a delivery report says about one recipient. */\nexport interface OutreachDeliveryRecipient {\n /** `Final-Recipient`, or `Original-Recipient` when that is all there is; normalized. */\n address: string | null\n /** `Action`, lowercased: `failed`, `delayed`, `delivered`, `relayed`, `expanded`. */\n action: string | null\n /** `Status`: the enhanced code, `5.1.1`. */\n status: string | null\n /** `Diagnostic-Code`, without its type prefix. */\n diagnostic: string | null\n /** `hard`, `soft`, or `null` for a block that reports no failure. */\n kind: OutreachBounceKind | null\n}\n\nexport interface OutreachDeliveryReport {\n /** The worst failure the report carries, or `null` when it reports none. */\n kind: OutreachBounceKind | null\n /** Every recipient the report names, in order. */\n recipients: OutreachDeliveryRecipient[]\n /**\n * The addresses `kind` is about, normalized — `X-Failed-Recipients` when no\n * block names one. Empty when nothing says which recipient failed.\n */\n failedAddresses: string[]\n /** The status code of the worst failure, when one was found. */\n status: string | null\n /** The diagnostic of the worst failure, when one was given. */\n diagnostic: string | null\n}\n\nconst DAEMON_LOCAL_PART = /^(mailer-daemon|postmaster|mail-daemon|maildaemon)$/i\nconst DAEMON_NAME = /\\bmail delivery (subsystem|system|service)\\b/i\nconst BOUNCE_WORDS =\n /undeliver|delivery status notification|delivery (has )?failed|delivery failure|failure notice|returned mail|mail delivery failed|could not be delivered|wasn't delivered|was not delivered|not delivered|address not found|message blocked/i\n\n/** Whether a message comes from a mail system rather than from a person. */\nexport function isMailerDaemonMessage(message: Pick<OutreachThreadMessage, 'from'>): boolean {\n const raw = String(message?.from ?? '')\n const address = emailAddressOf(raw)\n const local = address ? address.slice(0, address.lastIndexOf('@')) : ''\n return DAEMON_LOCAL_PART.test(local) || DAEMON_NAME.test(raw)\n}\n\nfunction isDeliveryStatusReport(message: OutreachThreadMessage): boolean {\n const contentType = outreachHeader(message, 'Content-Type').toLowerCase()\n if (\n contentType.includes('multipart/report') &&\n /report-type\\s*=\\s*\"?delivery-status\"?/.test(contentType)\n ) {\n return true\n }\n return (message.parts ?? []).some(\n (part) =>\n part.mimeType === 'message/delivery-status' ||\n part.mimeType === 'message/global-delivery-status',\n )\n}\n\n/** `Name: value` fields, continuation lines folded in, one list per blank-line block. */\nfunction fieldBlocks(text: string): Array<Record<string, string>> {\n const blocks: Array<Record<string, string>> = []\n let current: Record<string, string> = {}\n let last: string | null = null\n const close = () => {\n if (Object.keys(current).length) blocks.push(current)\n current = {}\n last = null\n }\n for (const line of text.replace(/\\r\\n?/g, '\\n').split('\\n')) {\n if (!line.trim()) {\n close()\n continue\n }\n if (/^[ \\t]/.test(line) && last) {\n current[last] = `${current[last]} ${line.trim()}`\n continue\n }\n const field = /^([A-Za-z][A-Za-z0-9-]*)\\s*:\\s*(.*)$/.exec(line)\n if (field) {\n last = field[1].toLowerCase()\n if (!(last in current)) current[last] = field[2].trim()\n }\n }\n close()\n return blocks\n}\n\n/** `rfc822; someone@example.com` → the address. */\nfunction typedAddress(value: string | undefined): string | null {\n if (!value) return null\n const typed = /^[a-z0-9-]+\\s*;\\s*(.*)$/i.exec(value)\n return emailAddressOf(typed ? typed[1] : value)\n}\n\n/** An enhanced status code, any class: what a `Status` field holds. */\nconst STATUS_CODE = /\\b([245])\\.(\\d{1,3})\\.(\\d{1,3})\\b/\n/** A failure's enhanced status code, quoted somewhere in prose. */\nconst FAILURE_STATUS_CODE = /\\b([45])\\.(\\d{1,3})\\.(\\d{1,3})\\b/\n/** A failure's basic SMTP reply code, as a transcript quotes it: `550-` or `421 `. */\nconst FAILURE_REPLY_CODE = /\\b([45])[0-5]\\d(?=[ -])/\n\nfunction kindFor(action: string | null, status: string | null): OutreachBounceKind | null {\n if (action === 'delivered' || action === 'relayed' || action === 'expanded') return null\n const statusClass = status ? status.charAt(0) : null\n if (statusClass === '4' || action === 'delayed') return 'soft'\n if (statusClass === '5') return action === null || action === 'failed' ? 'hard' : 'soft'\n if (action === 'failed') return 'hard'\n return null\n}\n\nconst SEVERITY: Record<OutreachBounceKind, number> = { soft: 1, hard: 2 }\n\nfunction failedRecipientsHeader(message: OutreachThreadMessage): string[] {\n return outreachHeader(message, 'X-Failed-Recipients')\n .split(/[,\\s]+/)\n .map((value) => emailAddressOf(value))\n .filter((value): value is string => value !== null)\n}\n\nfunction prose(message: OutreachThreadMessage): string {\n return [message.subject, message.textBody, message.snippet]\n .filter((value) => typeof value === 'string' && value)\n .join('\\n')\n}\n\n/**\n * What a delivery report says, or `null` when the message is not one — see\n * the module note for how a standard report and a prose bounce are read.\n */\nexport function readOutreachDeliveryReport(\n message: OutreachThreadMessage,\n): OutreachDeliveryReport | null {\n const standard = isDeliveryStatusReport(message)\n const daemon = isMailerDaemonMessage(message)\n if (!standard && !(daemon && BOUNCE_WORDS.test(prose(message)))) return null\n\n const statusText = (message.parts ?? [])\n .filter(\n (part) =>\n part.mimeType === 'message/delivery-status' ||\n part.mimeType === 'message/global-delivery-status',\n )\n .map((part) => part.text ?? '')\n .join('\\n\\n')\n // The first block describes the report itself (`Reporting-MTA`,\n // `Arrival-Date`); each later one describes a recipient.\n const recipients: OutreachDeliveryRecipient[] = fieldBlocks(statusText)\n .filter(\n (block) =>\n block['final-recipient'] || block['original-recipient'] || block['action'] || block['status'],\n )\n .map((block) => {\n const action = block['action'] ? block['action'].toLowerCase().split(/\\s/)[0] : null\n const statusMatch = STATUS_CODE.exec(block['status'] ?? '')\n const status = statusMatch ? statusMatch[0] : null\n const diagnostic = block['diagnostic-code']\n ? block['diagnostic-code'].replace(/^[a-z0-9-]+\\s*;\\s*/i, '')\n : null\n return {\n address: typedAddress(block['final-recipient']) ?? typedAddress(block['original-recipient']),\n action,\n status,\n diagnostic,\n kind: kindFor(action, status),\n }\n })\n\n let worst: OutreachDeliveryRecipient | null = null\n for (const recipient of recipients) {\n if (recipient.kind && (!worst || SEVERITY[recipient.kind] > SEVERITY[worst.kind])) {\n worst = recipient\n }\n }\n\n if (!worst && !recipients.length) {\n // A report without readable blocks, or a bounce written as prose: the\n // code the text quotes, else the words.\n const text = prose(message)\n const code = FAILURE_STATUS_CODE.exec(text)\n const reply = FAILURE_REPLY_CODE.exec(text)\n const statusClass = code?.[1] ?? reply?.[1] ?? null\n const retrying =\n /\\b(delay(ed)?|will (keep )?retry|not (been )?delivered yet|temporar(y|ily))\\b/i.test(text)\n const kind: OutreachBounceKind =\n statusClass === '5' ? 'hard' : statusClass === '4' || retrying ? 'soft' : 'hard'\n return {\n kind,\n recipients: [],\n failedAddresses: failedRecipientsHeader(message),\n status: code ? code[0] : null,\n diagnostic: null,\n }\n }\n\n // Only the addresses the report's own kind is about: a report that\n // hard-bounced one recipient and delayed another does not bounce both.\n const failedAddresses = worst\n ? recipients\n .filter((recipient) => recipient.kind === worst.kind && recipient.address)\n .map((recipient) => recipient.address as string)\n : []\n return {\n kind: worst?.kind ?? null,\n recipients,\n failedAddresses:\n failedAddresses.length || !worst ? failedAddresses : failedRecipientsHeader(message),\n status: worst?.status ?? null,\n diagnostic: worst?.diagnostic ?? null,\n }\n}\n"],"names":["emailAddressOf","outreachHeader","DAEMON_LOCAL_PART","DAEMON_NAME","BOUNCE_WORDS","isMailerDaemonMessage","message","raw","String","from","address","local","slice","lastIndexOf","test","isDeliveryStatusReport","contentType","toLowerCase","includes","parts","some","part","mimeType","fieldBlocks","text","blocks","current","last","close","Object","keys","length","push","line","replace","split","trim","field","exec","typedAddress","value","typed","STATUS_CODE","FAILURE_STATUS_CODE","FAILURE_REPLY_CODE","kindFor","action","status","statusClass","charAt","SEVERITY","soft","hard","failedRecipientsHeader","map","filter","prose","subject","textBody","snippet","join","readOutreachDeliveryReport","standard","daemon","statusText","recipients","block","statusMatch","diagnostic","kind","worst","recipient","code","reply","retrying","failedAddresses"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA2B4C,GAE5C,SAASA,cAAc,QAAQ,qCAAoC;AACnE,SAASC,cAAc,QAAoC,sBAAkB;AAkC7E,MAAMC,oBAAoB;AAC1B,MAAMC,cAAc;AACpB,MAAMC,eACJ;AAEF,0EAA0E,GAC1E,OAAO,SAASC,sBAAsBC,OAA4C;;IAChF,MAAMC,MAAMC,eAAOF,2BAAAA,QAASG,IAAI,mBAAI;IACpC,MAAMC,UAAUV,eAAeO;IAC/B,MAAMI,QAAQD,UAAUA,QAAQE,KAAK,CAAC,GAAGF,QAAQG,WAAW,CAAC,QAAQ;IACrE,OAAOX,kBAAkBY,IAAI,CAACH,UAAUR,YAAYW,IAAI,CAACP;AAC3D;AAEA,SAASQ,uBAAuBT,OAA8B;QAQpDA;IAPR,MAAMU,cAAcf,eAAeK,SAAS,gBAAgBW,WAAW;IACvE,IACED,YAAYE,QAAQ,CAAC,uBACrB,wCAAwCJ,IAAI,CAACE,cAC7C;QACA,OAAO;IACT;IACA,OAAO,EAACV,iBAAAA,QAAQa,KAAK,YAAbb,iBAAiB,EAAE,EAAEc,IAAI,CAC/B,CAACC,OACCA,KAAKC,QAAQ,KAAK,6BAClBD,KAAKC,QAAQ,KAAK;AAExB;AAEA,uFAAuF,GACvF,SAASC,YAAYC,IAAY;IAC/B,MAAMC,SAAwC,EAAE;IAChD,IAAIC,UAAkC,CAAC;IACvC,IAAIC,OAAsB;IAC1B,MAAMC,QAAQ;QACZ,IAAIC,OAAOC,IAAI,CAACJ,SAASK,MAAM,EAAEN,OAAOO,IAAI,CAACN;QAC7CA,UAAU,CAAC;QACXC,OAAO;IACT;IACA,KAAK,MAAMM,QAAQT,KAAKU,OAAO,CAAC,UAAU,MAAMC,KAAK,CAAC,MAAO;QAC3D,IAAI,CAACF,KAAKG,IAAI,IAAI;YAChBR;YACA;QACF;QACA,IAAI,SAASd,IAAI,CAACmB,SAASN,MAAM;YAC/BD,OAAO,CAACC,KAAK,GAAG,GAAGD,OAAO,CAACC,KAAK,CAAC,CAAC,EAAEM,KAAKG,IAAI,IAAI;YACjD;QACF;QACA,MAAMC,QAAQ,uCAAuCC,IAAI,CAACL;QAC1D,IAAII,OAAO;YACTV,OAAOU,KAAK,CAAC,EAAE,CAACpB,WAAW;YAC3B,IAAI,CAAEU,CAAAA,QAAQD,OAAM,GAAIA,OAAO,CAACC,KAAK,GAAGU,KAAK,CAAC,EAAE,CAACD,IAAI;QACvD;IACF;IACAR;IACA,OAAOH;AACT;AAEA,iDAAiD,GACjD,SAASc,aAAaC,KAAyB;IAC7C,IAAI,CAACA,OAAO,OAAO;IACnB,MAAMC,QAAQ,2BAA2BH,IAAI,CAACE;IAC9C,OAAOxC,eAAeyC,QAAQA,KAAK,CAAC,EAAE,GAAGD;AAC3C;AAEA,qEAAqE,GACrE,MAAME,cAAc;AACpB,iEAAiE,GACjE,MAAMC,sBAAsB;AAC5B,oFAAoF,GACpF,MAAMC,qBAAqB;AAE3B,SAASC,QAAQC,MAAqB,EAAEC,MAAqB;IAC3D,IAAID,WAAW,eAAeA,WAAW,aAAaA,WAAW,YAAY,OAAO;IACpF,MAAME,cAAcD,SAASA,OAAOE,MAAM,CAAC,KAAK;IAChD,IAAID,gBAAgB,OAAOF,WAAW,WAAW,OAAO;IACxD,IAAIE,gBAAgB,KAAK,OAAOF,WAAW,QAAQA,WAAW,WAAW,SAAS;IAClF,IAAIA,WAAW,UAAU,OAAO;IAChC,OAAO;AACT;AAEA,MAAMI,WAA+C;IAAEC,MAAM;IAAGC,MAAM;AAAE;AAExE,SAASC,uBAAuB/C,OAA8B;IAC5D,OAAOL,eAAeK,SAAS,uBAC5B6B,KAAK,CAAC,UACNmB,GAAG,CAAC,CAACd,QAAUxC,eAAewC,QAC9Be,MAAM,CAAC,CAACf,QAA2BA,UAAU;AAClD;AAEA,SAASgB,MAAMlD,OAA8B;IAC3C,OAAO;QAACA,QAAQmD,OAAO;QAAEnD,QAAQoD,QAAQ;QAAEpD,QAAQqD,OAAO;KAAC,CACxDJ,MAAM,CAAC,CAACf,QAAU,OAAOA,UAAU,YAAYA,OAC/CoB,IAAI,CAAC;AACV;AAEA;;;CAGC,GACD,OAAO,SAASC,2BACdvD,OAA8B;QAMVA;IAJpB,MAAMwD,WAAW/C,uBAAuBT;IACxC,MAAMyD,SAAS1D,sBAAsBC;IACrC,IAAI,CAACwD,YAAY,CAAEC,CAAAA,UAAU3D,aAAaU,IAAI,CAAC0C,MAAMlD,SAAQ,GAAI,OAAO;IAExE,MAAM0D,aAAa,EAAC1D,iBAAAA,QAAQa,KAAK,YAAbb,iBAAiB,EAAE,EACpCiD,MAAM,CACL,CAAClC,OACCA,KAAKC,QAAQ,KAAK,6BAClBD,KAAKC,QAAQ,KAAK,kCAErBgC,GAAG,CAAC,CAACjC;YAASA;gBAAAA,aAAAA,KAAKG,IAAI,YAATH,aAAa;OAC3BuC,IAAI,CAAC;IACR,gEAAgE;IAChE,yDAAyD;IACzD,MAAMK,aAA0C1C,YAAYyC,YACzDT,MAAM,CACL,CAACW,QACCA,KAAK,CAAC,kBAAkB,IAAIA,KAAK,CAAC,qBAAqB,IAAIA,KAAK,CAAC,SAAS,IAAIA,KAAK,CAAC,SAAS,EAEhGZ,GAAG,CAAC,CAACY;YAEiCA,eAM1B3B;QAPX,MAAMO,SAASoB,KAAK,CAAC,SAAS,GAAGA,KAAK,CAAC,SAAS,CAACjD,WAAW,GAAGkB,KAAK,CAAC,KAAK,CAAC,EAAE,GAAG;QAChF,MAAMgC,cAAczB,YAAYJ,IAAI,EAAC4B,gBAAAA,KAAK,CAAC,SAAS,YAAfA,gBAAmB;QACxD,MAAMnB,SAASoB,cAAcA,WAAW,CAAC,EAAE,GAAG;QAC9C,MAAMC,aAAaF,KAAK,CAAC,kBAAkB,GACvCA,KAAK,CAAC,kBAAkB,CAAChC,OAAO,CAAC,uBAAuB,MACxD;QACJ,OAAO;YACLxB,OAAO,GAAE6B,gBAAAA,aAAa2B,KAAK,CAAC,kBAAkB,aAArC3B,gBAA0CA,aAAa2B,KAAK,CAAC,qBAAqB;YAC3FpB;YACAC;YACAqB;YACAC,MAAMxB,QAAQC,QAAQC;QACxB;IACF;IAEF,IAAIuB,QAA0C;IAC9C,KAAK,MAAMC,aAAaN,WAAY;QAClC,IAAIM,UAAUF,IAAI,IAAK,CAAA,CAACC,SAASpB,QAAQ,CAACqB,UAAUF,IAAI,CAAC,GAAGnB,QAAQ,CAACoB,MAAMD,IAAI,CAAC,AAAD,GAAI;YACjFC,QAAQC;QACV;IACF;IAEA,IAAI,CAACD,SAAS,CAACL,WAAWlC,MAAM,EAAE;YAMZyC;QALpB,sEAAsE;QACtE,wCAAwC;QACxC,MAAMhD,OAAOgC,MAAMlD;QACnB,MAAMkE,OAAO7B,oBAAoBL,IAAI,CAACd;QACtC,MAAMiD,QAAQ7B,mBAAmBN,IAAI,CAACd;QACtC,MAAMwB,eAAcwB,iBAAAA,wBAAAA,IAAM,CAAC,EAAE,oBAAIC,yBAAAA,KAAO,CAAC,EAAE,YAAvBD,QAA2B;QAC/C,MAAME,WACJ,iFAAiF5D,IAAI,CAACU;QACxF,MAAM6C,OACJrB,gBAAgB,MAAM,SAASA,gBAAgB,OAAO0B,WAAW,SAAS;QAC5E,OAAO;YACLL;YACAJ,YAAY,EAAE;YACdU,iBAAiBtB,uBAAuB/C;YACxCyC,QAAQyB,OAAOA,IAAI,CAAC,EAAE,GAAG;YACzBJ,YAAY;QACd;IACF;IAEA,mEAAmE;IACnE,uEAAuE;IACvE,MAAMO,kBAAkBL,QACpBL,WACGV,MAAM,CAAC,CAACgB,YAAcA,UAAUF,IAAI,KAAKC,MAAMD,IAAI,IAAIE,UAAU7D,OAAO,EACxE4C,GAAG,CAAC,CAACiB,YAAcA,UAAU7D,OAAO,IACvC,EAAE;IACN,OAAO;QACL2D,IAAI,UAAEC,yBAAAA,MAAOD,IAAI,mBAAI;QACrBJ;QACAU,iBACEA,gBAAgB5C,MAAM,IAAI,CAACuC,QAAQK,kBAAkBtB,uBAAuB/C;QAC9EyC,MAAM,WAAEuB,yBAAAA,MAAOvB,MAAM,oBAAI;QACzBqB,UAAU,WAAEE,yBAAAA,MAAOF,UAAU,oBAAI;IACnC;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/delivery-status.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 * BOUNCES (AGL-2979).\n *\n * A mail server that cannot deliver says so in a DELIVERY STATUS\n * NOTIFICATION (RFC 3464): a `multipart/report; report-type=delivery-status`\n * message, usually from `mailer-daemon@` or `postmaster@`, whose\n * `message/delivery-status` part carries one block per recipient —\n * `Final-Recipient`, `Action` and `Status`, an enhanced code like `5.1.1`.\n * Gmail writes one into the sender's own thread when a message it relayed is\n * refused, and a receiving server that accepted a message and then gave up\n * sends one of its own.\n *\n * ## Hard and soft\n *\n * A `5.x.x` status on a `failed` action is HARD: the address does not\n * exist, the domain refuses the sender, the mailbox is closed — trying again\n * will not help, and the enrollment stops. A `4.x.x` status, or a `delayed`\n * action, is SOFT: the server is busy or the mailbox is full, the sending\n * server is still retrying, and nothing changes. A report whose action is\n * `delivered`, `relayed` or `expanded` is not a bounce at all.\n *\n * ## Reports that are not standard\n *\n * Some servers send a bounce as plain prose. A message from a mailer daemon\n * whose subject or text reads like one is read for the status code the prose\n * quotes (`550 5.1.1`, `421 4.7.0`), and failing that for the words that say\n * permanent or temporary — soft when it says it will retry, hard otherwise.\n *==========================================*/\n\nimport { emailAddressOf } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\nexport type OutreachBounceKind = 'hard' | 'soft'\n\n/** What a delivery report says about one recipient. */\nexport interface OutreachDeliveryRecipient {\n /** `Final-Recipient`, or `Original-Recipient` when that is all there is; normalized. */\n address: string | null\n /** `Action`, lowercased: `failed`, `delayed`, `delivered`, `relayed`, `expanded`. */\n action: string | null\n /** `Status`: the enhanced code, `5.1.1`. */\n status: string | null\n /** `Diagnostic-Code`, without its type prefix. */\n diagnostic: string | null\n /**\n * `Remote-MTA`, without its type prefix and lowercased: the receiving\n * server that answered — which names the gateway in front of the domain\n * when it is one (AGL-3326).\n */\n remoteMta: string | null\n /** `hard`, `soft`, or `null` for a block that reports no failure. */\n kind: OutreachBounceKind | null\n}\n\nexport interface OutreachDeliveryReport {\n /** The worst failure the report carries, or `null` when it reports none. */\n kind: OutreachBounceKind | null\n /** Every recipient the report names, in order. */\n recipients: OutreachDeliveryRecipient[]\n /**\n * The addresses `kind` is about, normalized — `X-Failed-Recipients` when no\n * block names one. Empty when nothing says which recipient failed.\n */\n failedAddresses: string[]\n /** The status code of the worst failure, when one was found. */\n status: string | null\n /** The diagnostic of the worst failure, when one was given. */\n diagnostic: string | null\n /** The `Remote-MTA` of the worst failure, when the report named one. */\n remoteMta: string | null\n}\n\nconst DAEMON_LOCAL_PART = /^(mailer-daemon|postmaster|mail-daemon|maildaemon)$/i\nconst DAEMON_NAME = /\\bmail delivery (subsystem|system|service)\\b/i\nconst BOUNCE_WORDS =\n /undeliver|delivery status notification|delivery (has )?failed|delivery failure|failure notice|returned mail|mail delivery failed|could not be delivered|wasn't delivered|was not delivered|not delivered|address not found|message blocked/i\n\n/** Whether a message comes from a mail system rather than from a person. */\nexport function isMailerDaemonMessage(message: Pick<OutreachThreadMessage, 'from'>): boolean {\n const raw = String(message?.from ?? '')\n const address = emailAddressOf(raw)\n const local = address ? address.slice(0, address.lastIndexOf('@')) : ''\n return DAEMON_LOCAL_PART.test(local) || DAEMON_NAME.test(raw)\n}\n\nfunction isDeliveryStatusReport(message: OutreachThreadMessage): boolean {\n const contentType = outreachHeader(message, 'Content-Type').toLowerCase()\n if (\n contentType.includes('multipart/report') &&\n /report-type\\s*=\\s*\"?delivery-status\"?/.test(contentType)\n ) {\n return true\n }\n return (message.parts ?? []).some(\n (part) =>\n part.mimeType === 'message/delivery-status' ||\n part.mimeType === 'message/global-delivery-status',\n )\n}\n\n/** `Name: value` fields, continuation lines folded in, one list per blank-line block. */\nfunction fieldBlocks(text: string): Array<Record<string, string>> {\n const blocks: Array<Record<string, string>> = []\n let current: Record<string, string> = {}\n let last: string | null = null\n const close = () => {\n if (Object.keys(current).length) blocks.push(current)\n current = {}\n last = null\n }\n for (const line of text.replace(/\\r\\n?/g, '\\n').split('\\n')) {\n if (!line.trim()) {\n close()\n continue\n }\n if (/^[ \\t]/.test(line) && last) {\n current[last] = `${current[last]} ${line.trim()}`\n continue\n }\n const field = /^([A-Za-z][A-Za-z0-9-]*)\\s*:\\s*(.*)$/.exec(line)\n if (field) {\n last = field[1].toLowerCase()\n if (!(last in current)) current[last] = field[2].trim()\n }\n }\n close()\n return blocks\n}\n\n/** `rfc822; someone@example.com` → the address. */\nfunction typedAddress(value: string | undefined): string | null {\n if (!value) return null\n const typed = /^[a-z0-9-]+\\s*;\\s*(.*)$/i.exec(value)\n return emailAddressOf(typed ? typed[1] : value)\n}\n\n/** An enhanced status code, any class: what a `Status` field holds. */\nconst STATUS_CODE = /\\b([245])\\.(\\d{1,3})\\.(\\d{1,3})\\b/\n/** A failure's enhanced status code, quoted somewhere in prose. */\nconst FAILURE_STATUS_CODE = /\\b([45])\\.(\\d{1,3})\\.(\\d{1,3})\\b/\n/** A failure's basic SMTP reply code, as a transcript quotes it: `550-` or `421 `. */\nconst FAILURE_REPLY_CODE = /\\b([45])[0-5]\\d(?=[ -])/\n\nfunction kindFor(action: string | null, status: string | null): OutreachBounceKind | null {\n if (action === 'delivered' || action === 'relayed' || action === 'expanded') return null\n const statusClass = status ? status.charAt(0) : null\n if (statusClass === '4' || action === 'delayed') return 'soft'\n if (statusClass === '5') return action === null || action === 'failed' ? 'hard' : 'soft'\n if (action === 'failed') return 'hard'\n return null\n}\n\nconst SEVERITY: Record<OutreachBounceKind, number> = { soft: 1, hard: 2 }\n\nfunction failedRecipientsHeader(message: OutreachThreadMessage): string[] {\n return outreachHeader(message, 'X-Failed-Recipients')\n .split(/[,\\s]+/)\n .map((value) => emailAddressOf(value))\n .filter((value): value is string => value !== null)\n}\n\nfunction prose(message: OutreachThreadMessage): string {\n return [message.subject, message.textBody, message.snippet]\n .filter((value) => typeof value === 'string' && value)\n .join('\\n')\n}\n\n/**\n * What a delivery report says, or `null` when the message is not one — see\n * the module note for how a standard report and a prose bounce are read.\n */\nexport function readOutreachDeliveryReport(\n message: OutreachThreadMessage,\n): OutreachDeliveryReport | null {\n const standard = isDeliveryStatusReport(message)\n const daemon = isMailerDaemonMessage(message)\n if (!standard && !(daemon && BOUNCE_WORDS.test(prose(message)))) return null\n\n const statusText = (message.parts ?? [])\n .filter(\n (part) =>\n part.mimeType === 'message/delivery-status' ||\n part.mimeType === 'message/global-delivery-status',\n )\n .map((part) => part.text ?? '')\n .join('\\n\\n')\n // The first block describes the report itself (`Reporting-MTA`,\n // `Arrival-Date`); each later one describes a recipient.\n const recipients: OutreachDeliveryRecipient[] = fieldBlocks(statusText)\n .filter(\n (block) =>\n block['final-recipient'] || block['original-recipient'] || block['action'] || block['status'],\n )\n .map((block) => {\n const action = block['action'] ? block['action'].toLowerCase().split(/\\s/)[0] : null\n const statusMatch = STATUS_CODE.exec(block['status'] ?? '')\n const status = statusMatch ? statusMatch[0] : null\n const diagnostic = block['diagnostic-code']\n ? block['diagnostic-code'].replace(/^[a-z0-9-]+\\s*;\\s*/i, '')\n : null\n // `dns; mx.example.com. (192.0.2.10, ...)` → the host alone.\n const remoteMta = block['remote-mta']\n ? block['remote-mta'].replace(/^[a-z0-9-]+\\s*;\\s*/i, '').trim().split(/\\s+/)[0].toLowerCase().replace(/\\.$/, '') || null\n : null\n return {\n address: typedAddress(block['final-recipient']) ?? typedAddress(block['original-recipient']),\n action,\n status,\n diagnostic,\n remoteMta,\n kind: kindFor(action, status),\n }\n })\n\n let worst: OutreachDeliveryRecipient | null = null\n for (const recipient of recipients) {\n if (recipient.kind && (!worst || SEVERITY[recipient.kind] > SEVERITY[worst.kind])) {\n worst = recipient\n }\n }\n\n if (!worst && !recipients.length) {\n // A report without readable blocks, or a bounce written as prose: the\n // code the text quotes, else the words.\n const text = prose(message)\n const code = FAILURE_STATUS_CODE.exec(text)\n const reply = FAILURE_REPLY_CODE.exec(text)\n const statusClass = code?.[1] ?? reply?.[1] ?? null\n const retrying =\n /\\b(delay(ed)?|will (keep )?retry|not (been )?delivered yet|temporar(y|ily))\\b/i.test(text)\n const kind: OutreachBounceKind =\n statusClass === '5' ? 'hard' : statusClass === '4' || retrying ? 'soft' : 'hard'\n return {\n kind,\n recipients: [],\n failedAddresses: failedRecipientsHeader(message),\n status: code ? code[0] : null,\n diagnostic: null,\n remoteMta: null,\n }\n }\n\n // Only the addresses the report's own kind is about: a report that\n // hard-bounced one recipient and delayed another does not bounce both.\n const failedAddresses = worst\n ? recipients\n .filter((recipient) => recipient.kind === worst.kind && recipient.address)\n .map((recipient) => recipient.address as string)\n : []\n return {\n kind: worst?.kind ?? null,\n recipients,\n failedAddresses:\n failedAddresses.length || !worst ? failedAddresses : failedRecipientsHeader(message),\n status: worst?.status ?? null,\n diagnostic: worst?.diagnostic ?? null,\n remoteMta: worst?.remoteMta ?? null,\n }\n}\n"],"names":["emailAddressOf","outreachHeader","DAEMON_LOCAL_PART","DAEMON_NAME","BOUNCE_WORDS","isMailerDaemonMessage","message","raw","String","from","address","local","slice","lastIndexOf","test","isDeliveryStatusReport","contentType","toLowerCase","includes","parts","some","part","mimeType","fieldBlocks","text","blocks","current","last","close","Object","keys","length","push","line","replace","split","trim","field","exec","typedAddress","value","typed","STATUS_CODE","FAILURE_STATUS_CODE","FAILURE_REPLY_CODE","kindFor","action","status","statusClass","charAt","SEVERITY","soft","hard","failedRecipientsHeader","map","filter","prose","subject","textBody","snippet","join","readOutreachDeliveryReport","standard","daemon","statusText","recipients","block","statusMatch","diagnostic","remoteMta","kind","worst","recipient","code","reply","retrying","failedAddresses"],"mappings":"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"}
@@ -24,6 +24,8 @@ export type OutreachEnrollmentEvent = {
24
24
  type: 'pause';
25
25
  atMs: number;
26
26
  byUid: string | null;
27
+ /** `manual` unless the engine held the send on its gateway (AGL-3326). */
28
+ reason?: Extract<OutreachStopReason, 'manual' | 'gateway_blocked_here'>;
27
29
  detail?: string | null;
28
30
  } | {
29
31
  type: 'resume';
@@ -107,7 +107,7 @@ const detailOf = (value)=>{
107
107
  return text ? text.slice(0, 500) : null;
108
108
  };
109
109
  /** What an event does to an enrollment's status — see the module note. */ export function applyOutreachEnrollmentEvent(enrollment, event) {
110
- var _event_reason;
110
+ var _event_reason, _event_reason1;
111
111
  const from = enrollment.status;
112
112
  const to = EVENT_TARGET[event.type];
113
113
  if (!to) return {
@@ -137,7 +137,7 @@ const detailOf = (value)=>{
137
137
  error: null
138
138
  };
139
139
  }
140
- const reason = event.type === 'pause' ? 'manual' : event.type === 'stop' ? (_event_reason = event.reason) != null ? _event_reason : 'manual' : event.type === 'reply' ? 'reply' : event.type === 'opt_out' ? event.reason : event.type === 'bounce' ? 'hard_bounce' : 'send_failed';
140
+ const reason = event.type === 'pause' ? (_event_reason = event.reason) != null ? _event_reason : 'manual' : event.type === 'stop' ? (_event_reason1 = event.reason) != null ? _event_reason1 : 'manual' : event.type === 'reply' ? 'reply' : event.type === 'opt_out' ? event.reason : event.type === 'bounce' ? 'hard_bounce' : 'send_failed';
141
141
  const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to];
142
142
  if (!(allowed == null ? void 0 : allowed.includes(reason))) {
143
143
  return {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-state.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * ONE PERSON'S RUN THROUGH A SEQUENCE (AGL-2979).\n *\n * ## The state machine\n *\n * active ──► paused ──► active\n * │ │\n * ├──────────┴──► replied · bounced · opted_out · stopped\n * └─► finished · failed\n *\n * An enrollment sends only while `active`. Everything that ends the sending\n * records why (`stopReason`, `stopDetail`, when, and who). Ending the\n * sending does not end what can still be learned from the thread: a reply,\n * a bounce or an opt-out that arrives after the last step, after a member\n * stopped it or after a send failed is still recorded, and a person who\n * replied and then asked to be left alone moves on to `opted_out` — an\n * opt-out is the one fact every later sequence has to be able to find.\n * `bounced` and `opted_out` are final.\n *\n * Every function returns a PATCH — the fields to write — and never the\n * whole document, so a writer applies it with its own SDK and its own\n * timestamps.\n *==========================================*/\n\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_STOP_REASONS_BY_STATUS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachEnrollmentTarget,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachStopReason,\n} from '../model/outreach.types'\nimport { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport { effectiveOutreachWindow, type OutreachRandom, scheduleOutreachDue } from './schedule'\nimport { isInThreadEmailStep } from './sequence-validation'\n\n/** Where each status may go next. */\nexport const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<\n Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>\n> = {\n active: ['paused', 'finished', 'replied', 'bounced', 'opted_out', 'stopped', 'failed'],\n paused: ['active', 'replied', 'bounced', 'opted_out', 'stopped'],\n finished: ['replied', 'bounced', 'opted_out'],\n stopped: ['replied', 'bounced', 'opted_out'],\n failed: ['replied', 'bounced', 'opted_out'],\n replied: ['opted_out'],\n bounced: [],\n opted_out: [],\n}\n\nexport function canTransitionOutreachEnrollment(\n from: OutreachEnrollmentStatus,\n to: OutreachEnrollmentStatus,\n): boolean {\n return OUTREACH_ENROLLMENT_TRANSITIONS[from]?.includes(to) === true\n}\n\n/** Something that happened to an enrollment. `atMs` is when it happened. */\nexport type OutreachEnrollmentEvent =\n | { type: 'pause'; atMs: number; byUid: string | null; detail?: string | null }\n | { type: 'resume'; atMs: number; byUid: string | null }\n | {\n type: 'stop'\n atMs: number\n byUid: string | null\n reason?: Extract<OutreachStopReason, 'manual' | 'gate' | 'sequence_archived'>\n detail?: string | null\n }\n | { type: 'reply'; atMs: number; detail?: string | null }\n | {\n type: 'opt_out'\n atMs: number\n reason: Extract<OutreachStopReason, 'opt_out_reply' | 'unsubscribe' | 'do_not_contact'>\n detail?: string | null\n }\n | { type: 'bounce'; atMs: number; detail?: string | null }\n | { type: 'send_failed'; atMs: number; detail?: string | null }\n\n/** The status fields an event writes. */\nexport interface OutreachEnrollmentStatusPatch {\n status: OutreachEnrollmentStatus\n stopReason: OutreachStopReason | null\n stopDetail: string | null\n stoppedAtMs: number | null\n stoppedByUid: string | null\n nextDueAtMs: number | null\n}\n\n/**\n * `patch` is what to write, or `null` when there is nothing to write — the\n * enrollment is already there, or the event is not allowed, in which case\n * `error` says why.\n */\nexport interface OutreachEnrollmentEventResult {\n patch: OutreachEnrollmentStatusPatch | null\n error: string | null\n}\n\nconst STATUS_WORDS: Record<OutreachEnrollmentStatus, string> = {\n active: 'is active',\n paused: 'is paused',\n finished: 'finished',\n replied: 'got a reply',\n bounced: 'bounced',\n opted_out: 'opted out',\n stopped: 'was stopped',\n failed: 'failed',\n}\n\nconst EVENT_TARGET: Record<OutreachEnrollmentEvent['type'], OutreachEnrollmentStatus> = {\n pause: 'paused',\n resume: 'active',\n stop: 'stopped',\n reply: 'replied',\n opt_out: 'opted_out',\n bounce: 'bounced',\n send_failed: 'failed',\n}\n\nconst detailOf = (value: unknown): string | null => {\n const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n return text ? text.slice(0, 500) : null\n}\n\n/** What an event does to an enrollment's status — see the module note. */\nexport function applyOutreachEnrollmentEvent(\n enrollment: Pick<OutreachEnrollment, 'status' | 'nextDueAtMs'>,\n event: OutreachEnrollmentEvent,\n): OutreachEnrollmentEventResult {\n const from = enrollment.status\n const to = EVENT_TARGET[event.type]\n if (!to) return { patch: null, error: 'That is not something that happens to an enrollment.' }\n if (from === to) return { patch: null, error: null }\n if (!canTransitionOutreachEnrollment(from, to)) {\n return {\n patch: null,\n error: `This enrollment ${STATUS_WORDS[from]}, so it can't be marked as one that ${STATUS_WORDS[to]}.`,\n }\n }\n if (event.type === 'resume') {\n return {\n patch: {\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n nextDueAtMs: enrollment.nextDueAtMs,\n },\n error: null,\n }\n }\n const reason: OutreachStopReason =\n event.type === 'pause'\n ? 'manual'\n : event.type === 'stop'\n ? (event.reason ?? 'manual')\n : event.type === 'reply'\n ? 'reply'\n : event.type === 'opt_out'\n ? event.reason\n : event.type === 'bounce'\n ? 'hard_bounce'\n : 'send_failed'\n const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to as keyof typeof OUTREACH_STOP_REASONS_BY_STATUS]\n if (!allowed?.includes(reason)) {\n return { patch: null, error: `\"${reason}\" isn't a reason an enrollment ${STATUS_WORDS[to]}.` }\n }\n const byUid = 'byUid' in event && typeof event.byUid === 'string' && event.byUid ? event.byUid : null\n return {\n patch: {\n status: to,\n stopReason: reason,\n stopDetail: detailOf(event.detail),\n stoppedAtMs: event.atMs,\n stoppedByUid: byUid,\n // A paused enrollment keeps its place, so resuming it picks up where it\n // stopped; every other status has nothing left waiting.\n nextDueAtMs: to === 'paused' ? enrollment.nextDueAtMs : null,\n },\n error: null,\n }\n}\n\ntype ScheduleMailbox = Pick<OutreachMailbox, 'timezone' | 'window'>\ntype ScheduleSequence = Pick<OutreachSequence, 'steps' | 'settings'>\n\n/** When a new enrollment's first step comes due, or `null` when it cannot be placed. */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n}): number | null {\n const first = input.sequence.steps?.[0]\n if (!first) return null\n return scheduleOutreachDue({\n fromMs: input.enrolledAtMs,\n delayBusinessDays: first.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(input.sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n}\n\n/** Stamps the attestations a rep ticked with who ticked them and when; unknown kinds are dropped. */\nexport function outreachAttestationsFrom(\n kinds: readonly unknown[],\n uid: string,\n atMs: number,\n): OutreachAttestations {\n const attestations: OutreachAttestations = {}\n for (const kind of kinds ?? []) {\n if ((OUTREACH_ATTESTATION_KINDS as readonly unknown[]).includes(kind)) {\n attestations[kind as OutreachAttestationKind] = { uid, atMs }\n }\n }\n return attestations\n}\n\nexport interface OutreachEnrollmentDraft {\n id: string\n sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings' | 'campaignIds'>\n mailbox: ScheduleMailbox\n /** The record the person is (AGL-3234); a contact when absent. */\n target?: OutreachEnrollmentTarget\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key, for an enrollment made on a lead. */\n leadId?: string | null\n /** The contact's name as the sending site knows it; `''` for none. */\n contactName?: string | null\n /** The address, as the gates normalized it. */\n email: string\n /** The gates' `cold`. */\n cold: boolean\n personalLine: string\n attestations: OutreachAttestations\n enrolledByUid: string\n nowMs: number\n random: OutreachRandom\n}\n\n/**\n * A new enrollment document, first step scheduled — one builder for every\n * door that enrolls, so an enrollment made from a contact and one made from\n * a list are the same shape. Call it after the gates allowed the person.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n return {\n id: draft.id,\n sequenceId: draft.sequence.id,\n target: draft.target ?? 'contact',\n contactId: draft.contactId,\n leadId: draft.leadId ?? null,\n contactName: String(draft.contactName ?? '').replace(/\\s+/g, ' ').trim().slice(0, 200),\n email: String(draft.email ?? '').trim().toLowerCase(),\n hostId: draft.sequence.hostId,\n mailboxId: draft.sequence.mailboxId,\n stepIndex: 0,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n }),\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n personalLine: normalizeOutreachPersonalLine(draft.personalLine),\n cold: draft.cold === true,\n attestations: { ...(draft.attestations ?? {}) },\n enrolledByUid: draft.enrolledByUid,\n gmailThreadId: null,\n gmailThreadIds: [],\n threadSubject: null,\n messageIds: [],\n lastSentAtMs: null,\n // The sequence's campaigns as they stand now (AGL-3254): what this\n // enrollment's outcomes are credited to, whatever the sequence joins later.\n campaignIds: normalizeCampaignIds(draft.sequence.campaignIds),\n createdAtMs: draft.nowMs,\n updatedAtMs: draft.nowMs,\n }\n}\n\n/** What the provider reported for an email step that was sent. */\nexport interface OutreachSentEmail {\n /** The `Message-ID` header the message went out with, angle brackets included. */\n messageId: string\n /** The provider's thread id. */\n threadId: string\n /** The subject as sent. */\n subject: string\n}\n\nexport interface OutreachStepCompletionInput {\n enrollment: Pick<\n OutreachEnrollment,\n 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'\n >\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n /** When the step ran. */\n completedAtMs: number\n /** For an email step, what was sent; not read for a task. */\n sent?: OutreachSentEmail | null\n random: OutreachRandom\n}\n\n/** The fields a completed step writes. */\nexport interface OutreachStepCompletionPatch {\n status: 'active' | 'finished'\n stepIndex: number\n nextDueAtMs: number | null\n lastSentAtMs: number\n gmailThreadId?: string\n gmailThreadIds?: string[]\n threadSubject?: string\n messageIds?: string[]\n}\n\nexport interface OutreachStepCompletionResult {\n patch: OutreachStepCompletionPatch | null\n /**\n * Why there is no patch, or — beside one — that the next step could not\n * be placed on the calendar (a window that never opens, a zone `Intl` does\n * not know), which leaves the enrollment with nothing due until a member\n * fixes the window and resumes it.\n */\n error: string | null\n}\n\n/**\n * What running the current step writes: the send recorded on the thread\n * fields, and the next step scheduled — or the enrollment `finished` when\n * that was the last.\n */\nexport function planOutreachStepCompletion(\n input: OutreachStepCompletionInput,\n): OutreachStepCompletionResult {\n const { enrollment, sequence } = input\n if (enrollment.status !== 'active') {\n return { patch: null, error: 'Only an active enrollment runs its steps.' }\n }\n const steps = sequence.steps ?? []\n const step = steps[enrollment.stepIndex]\n if (!step) return { patch: null, error: 'This enrollment has no step at its position.' }\n const patch: OutreachStepCompletionPatch = {\n status: 'active',\n stepIndex: enrollment.stepIndex + 1,\n nextDueAtMs: null,\n lastSentAtMs: input.completedAtMs,\n }\n if (step.kind === 'email') {\n const sent = input.sent\n const messageId = String(sent?.messageId ?? '').trim()\n const threadId = String(sent?.threadId ?? '').trim()\n if (!messageId || !threadId) {\n return { patch: null, error: 'An email step needs the sent message and thread ids.' }\n }\n const threadIds = [...(enrollment.gmailThreadIds ?? [])]\n if (!threadIds.includes(threadId)) threadIds.push(threadId)\n patch.gmailThreadId = threadId\n patch.gmailThreadIds = threadIds\n if (isInThreadEmailStep(steps, enrollment.stepIndex) && enrollment.threadSubject) {\n patch.messageIds = [...(enrollment.messageIds ?? []), messageId]\n } else {\n patch.threadSubject = String(sent.subject ?? '').trim()\n patch.messageIds = [messageId]\n }\n }\n const next = steps[patch.stepIndex]\n if (!next) {\n patch.status = 'finished'\n return { patch, error: null }\n }\n patch.nextDueAtMs = scheduleOutreachDue({\n fromMs: input.completedAtMs,\n delayBusinessDays: next.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n return {\n patch,\n error:\n patch.nextDueAtMs === null\n ? \"The next step couldn't be scheduled: check the sending window and the mailbox's timezone.\"\n : null,\n }\n}\n"],"names":["OUTREACH_ATTESTATION_KINDS","OUTREACH_STOP_REASONS_BY_STATUS","normalizeCampaignIds","normalizeOutreachPersonalLine","effectiveOutreachWindow","scheduleOutreachDue","isInThreadEmailStep","OUTREACH_ENROLLMENT_TRANSITIONS","active","paused","finished","stopped","failed","replied","bounced","opted_out","canTransitionOutreachEnrollment","from","to","includes","STATUS_WORDS","EVENT_TARGET","pause","resume","stop","reply","opt_out","bounce","send_failed","detailOf","value","text","replace","trim","slice","applyOutreachEnrollmentEvent","enrollment","event","status","type","patch","error","stopReason","stopDetail","stoppedAtMs","stoppedByUid","nextDueAtMs","reason","allowed","byUid","detail","atMs","planOutreachFirstDue","input","first","sequence","steps","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","id","sequenceId","target","contactId","leadId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","campaignIds","createdAtMs","updatedAtMs","planOutreachStepCompletion","step","completedAtMs","sent","messageId","threadId","threadIds","push","subject","next"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-state.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * ONE PERSON'S RUN THROUGH A SEQUENCE (AGL-2979).\n *\n * ## The state machine\n *\n * active ──► paused ──► active\n * │ │\n * ├──────────┴──► replied · bounced · opted_out · stopped\n * └─► finished · failed\n *\n * An enrollment sends only while `active`. Everything that ends the sending\n * records why (`stopReason`, `stopDetail`, when, and who). Ending the\n * sending does not end what can still be learned from the thread: a reply,\n * a bounce or an opt-out that arrives after the last step, after a member\n * stopped it or after a send failed is still recorded, and a person who\n * replied and then asked to be left alone moves on to `opted_out` — an\n * opt-out is the one fact every later sequence has to be able to find.\n * `bounced` and `opted_out` are final.\n *\n * Every function returns a PATCH — the fields to write — and never the\n * whole document, so a writer applies it with its own SDK and its own\n * timestamps.\n *==========================================*/\n\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_STOP_REASONS_BY_STATUS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachEnrollmentTarget,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachStopReason,\n} from '../model/outreach.types'\nimport { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport { effectiveOutreachWindow, type OutreachRandom, scheduleOutreachDue } from './schedule'\nimport { isInThreadEmailStep } from './sequence-validation'\n\n/** Where each status may go next. */\nexport const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<\n Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>\n> = {\n active: ['paused', 'finished', 'replied', 'bounced', 'opted_out', 'stopped', 'failed'],\n paused: ['active', 'replied', 'bounced', 'opted_out', 'stopped'],\n finished: ['replied', 'bounced', 'opted_out'],\n stopped: ['replied', 'bounced', 'opted_out'],\n failed: ['replied', 'bounced', 'opted_out'],\n replied: ['opted_out'],\n bounced: [],\n opted_out: [],\n}\n\nexport function canTransitionOutreachEnrollment(\n from: OutreachEnrollmentStatus,\n to: OutreachEnrollmentStatus,\n): boolean {\n return OUTREACH_ENROLLMENT_TRANSITIONS[from]?.includes(to) === true\n}\n\n/** Something that happened to an enrollment. `atMs` is when it happened. */\nexport type OutreachEnrollmentEvent =\n | {\n type: 'pause'\n atMs: number\n byUid: string | null\n /** `manual` unless the engine held the send on its gateway (AGL-3326). */\n reason?: Extract<OutreachStopReason, 'manual' | 'gateway_blocked_here'>\n detail?: string | null\n }\n | { type: 'resume'; atMs: number; byUid: string | null }\n | {\n type: 'stop'\n atMs: number\n byUid: string | null\n reason?: Extract<OutreachStopReason, 'manual' | 'gate' | 'sequence_archived'>\n detail?: string | null\n }\n | { type: 'reply'; atMs: number; detail?: string | null }\n | {\n type: 'opt_out'\n atMs: number\n reason: Extract<OutreachStopReason, 'opt_out_reply' | 'unsubscribe' | 'do_not_contact'>\n detail?: string | null\n }\n | { type: 'bounce'; atMs: number; detail?: string | null }\n | { type: 'send_failed'; atMs: number; detail?: string | null }\n\n/** The status fields an event writes. */\nexport interface OutreachEnrollmentStatusPatch {\n status: OutreachEnrollmentStatus\n stopReason: OutreachStopReason | null\n stopDetail: string | null\n stoppedAtMs: number | null\n stoppedByUid: string | null\n nextDueAtMs: number | null\n}\n\n/**\n * `patch` is what to write, or `null` when there is nothing to write — the\n * enrollment is already there, or the event is not allowed, in which case\n * `error` says why.\n */\nexport interface OutreachEnrollmentEventResult {\n patch: OutreachEnrollmentStatusPatch | null\n error: string | null\n}\n\nconst STATUS_WORDS: Record<OutreachEnrollmentStatus, string> = {\n active: 'is active',\n paused: 'is paused',\n finished: 'finished',\n replied: 'got a reply',\n bounced: 'bounced',\n opted_out: 'opted out',\n stopped: 'was stopped',\n failed: 'failed',\n}\n\nconst EVENT_TARGET: Record<OutreachEnrollmentEvent['type'], OutreachEnrollmentStatus> = {\n pause: 'paused',\n resume: 'active',\n stop: 'stopped',\n reply: 'replied',\n opt_out: 'opted_out',\n bounce: 'bounced',\n send_failed: 'failed',\n}\n\nconst detailOf = (value: unknown): string | null => {\n const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n return text ? text.slice(0, 500) : null\n}\n\n/** What an event does to an enrollment's status — see the module note. */\nexport function applyOutreachEnrollmentEvent(\n enrollment: Pick<OutreachEnrollment, 'status' | 'nextDueAtMs'>,\n event: OutreachEnrollmentEvent,\n): OutreachEnrollmentEventResult {\n const from = enrollment.status\n const to = EVENT_TARGET[event.type]\n if (!to) return { patch: null, error: 'That is not something that happens to an enrollment.' }\n if (from === to) return { patch: null, error: null }\n if (!canTransitionOutreachEnrollment(from, to)) {\n return {\n patch: null,\n error: `This enrollment ${STATUS_WORDS[from]}, so it can't be marked as one that ${STATUS_WORDS[to]}.`,\n }\n }\n if (event.type === 'resume') {\n return {\n patch: {\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n nextDueAtMs: enrollment.nextDueAtMs,\n },\n error: null,\n }\n }\n const reason: OutreachStopReason =\n event.type === 'pause'\n ? (event.reason ?? 'manual')\n : event.type === 'stop'\n ? (event.reason ?? 'manual')\n : event.type === 'reply'\n ? 'reply'\n : event.type === 'opt_out'\n ? event.reason\n : event.type === 'bounce'\n ? 'hard_bounce'\n : 'send_failed'\n const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to as keyof typeof OUTREACH_STOP_REASONS_BY_STATUS]\n if (!allowed?.includes(reason)) {\n return { patch: null, error: `\"${reason}\" isn't a reason an enrollment ${STATUS_WORDS[to]}.` }\n }\n const byUid = 'byUid' in event && typeof event.byUid === 'string' && event.byUid ? event.byUid : null\n return {\n patch: {\n status: to,\n stopReason: reason,\n stopDetail: detailOf(event.detail),\n stoppedAtMs: event.atMs,\n stoppedByUid: byUid,\n // A paused enrollment keeps its place, so resuming it picks up where it\n // stopped; every other status has nothing left waiting.\n nextDueAtMs: to === 'paused' ? enrollment.nextDueAtMs : null,\n },\n error: null,\n }\n}\n\ntype ScheduleMailbox = Pick<OutreachMailbox, 'timezone' | 'window'>\ntype ScheduleSequence = Pick<OutreachSequence, 'steps' | 'settings'>\n\n/** When a new enrollment's first step comes due, or `null` when it cannot be placed. */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n}): number | null {\n const first = input.sequence.steps?.[0]\n if (!first) return null\n return scheduleOutreachDue({\n fromMs: input.enrolledAtMs,\n delayBusinessDays: first.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(input.sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n}\n\n/** Stamps the attestations a rep ticked with who ticked them and when; unknown kinds are dropped. */\nexport function outreachAttestationsFrom(\n kinds: readonly unknown[],\n uid: string,\n atMs: number,\n): OutreachAttestations {\n const attestations: OutreachAttestations = {}\n for (const kind of kinds ?? []) {\n if ((OUTREACH_ATTESTATION_KINDS as readonly unknown[]).includes(kind)) {\n attestations[kind as OutreachAttestationKind] = { uid, atMs }\n }\n }\n return attestations\n}\n\nexport interface OutreachEnrollmentDraft {\n id: string\n sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings' | 'campaignIds'>\n mailbox: ScheduleMailbox\n /** The record the person is (AGL-3234); a contact when absent. */\n target?: OutreachEnrollmentTarget\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key, for an enrollment made on a lead. */\n leadId?: string | null\n /** The contact's name as the sending site knows it; `''` for none. */\n contactName?: string | null\n /** The address, as the gates normalized it. */\n email: string\n /** The gates' `cold`. */\n cold: boolean\n personalLine: string\n attestations: OutreachAttestations\n enrolledByUid: string\n nowMs: number\n random: OutreachRandom\n}\n\n/**\n * A new enrollment document, first step scheduled — one builder for every\n * door that enrolls, so an enrollment made from a contact and one made from\n * a list are the same shape. Call it after the gates allowed the person.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n return {\n id: draft.id,\n sequenceId: draft.sequence.id,\n target: draft.target ?? 'contact',\n contactId: draft.contactId,\n leadId: draft.leadId ?? null,\n contactName: String(draft.contactName ?? '').replace(/\\s+/g, ' ').trim().slice(0, 200),\n email: String(draft.email ?? '').trim().toLowerCase(),\n hostId: draft.sequence.hostId,\n mailboxId: draft.sequence.mailboxId,\n stepIndex: 0,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n }),\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n personalLine: normalizeOutreachPersonalLine(draft.personalLine),\n cold: draft.cold === true,\n attestations: { ...(draft.attestations ?? {}) },\n enrolledByUid: draft.enrolledByUid,\n gmailThreadId: null,\n gmailThreadIds: [],\n threadSubject: null,\n messageIds: [],\n lastSentAtMs: null,\n // The sequence's campaigns as they stand now (AGL-3254): what this\n // enrollment's outcomes are credited to, whatever the sequence joins later.\n campaignIds: normalizeCampaignIds(draft.sequence.campaignIds),\n createdAtMs: draft.nowMs,\n updatedAtMs: draft.nowMs,\n }\n}\n\n/** What the provider reported for an email step that was sent. */\nexport interface OutreachSentEmail {\n /** The `Message-ID` header the message went out with, angle brackets included. */\n messageId: string\n /** The provider's thread id. */\n threadId: string\n /** The subject as sent. */\n subject: string\n}\n\nexport interface OutreachStepCompletionInput {\n enrollment: Pick<\n OutreachEnrollment,\n 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'\n >\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n /** When the step ran. */\n completedAtMs: number\n /** For an email step, what was sent; not read for a task. */\n sent?: OutreachSentEmail | null\n random: OutreachRandom\n}\n\n/** The fields a completed step writes. */\nexport interface OutreachStepCompletionPatch {\n status: 'active' | 'finished'\n stepIndex: number\n nextDueAtMs: number | null\n lastSentAtMs: number\n gmailThreadId?: string\n gmailThreadIds?: string[]\n threadSubject?: string\n messageIds?: string[]\n}\n\nexport interface OutreachStepCompletionResult {\n patch: OutreachStepCompletionPatch | null\n /**\n * Why there is no patch, or — beside one — that the next step could not\n * be placed on the calendar (a window that never opens, a zone `Intl` does\n * not know), which leaves the enrollment with nothing due until a member\n * fixes the window and resumes it.\n */\n error: string | null\n}\n\n/**\n * What running the current step writes: the send recorded on the thread\n * fields, and the next step scheduled — or the enrollment `finished` when\n * that was the last.\n */\nexport function planOutreachStepCompletion(\n input: OutreachStepCompletionInput,\n): OutreachStepCompletionResult {\n const { enrollment, sequence } = input\n if (enrollment.status !== 'active') {\n return { patch: null, error: 'Only an active enrollment runs its steps.' }\n }\n const steps = sequence.steps ?? []\n const step = steps[enrollment.stepIndex]\n if (!step) return { patch: null, error: 'This enrollment has no step at its position.' }\n const patch: OutreachStepCompletionPatch = {\n status: 'active',\n stepIndex: enrollment.stepIndex + 1,\n nextDueAtMs: null,\n lastSentAtMs: input.completedAtMs,\n }\n if (step.kind === 'email') {\n const sent = input.sent\n const messageId = String(sent?.messageId ?? '').trim()\n const threadId = String(sent?.threadId ?? '').trim()\n if (!messageId || !threadId) {\n return { patch: null, error: 'An email step needs the sent message and thread ids.' }\n }\n const threadIds = [...(enrollment.gmailThreadIds ?? [])]\n if (!threadIds.includes(threadId)) threadIds.push(threadId)\n patch.gmailThreadId = threadId\n patch.gmailThreadIds = threadIds\n if (isInThreadEmailStep(steps, enrollment.stepIndex) && enrollment.threadSubject) {\n patch.messageIds = [...(enrollment.messageIds ?? []), messageId]\n } else {\n patch.threadSubject = String(sent.subject ?? '').trim()\n patch.messageIds = [messageId]\n }\n }\n const next = steps[patch.stepIndex]\n if (!next) {\n patch.status = 'finished'\n return { patch, error: null }\n }\n patch.nextDueAtMs = scheduleOutreachDue({\n fromMs: input.completedAtMs,\n delayBusinessDays: next.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n return {\n patch,\n error:\n patch.nextDueAtMs === null\n ? \"The next step couldn't be scheduled: check the sending window and the mailbox's timezone.\"\n : null,\n }\n}\n"],"names":["OUTREACH_ATTESTATION_KINDS","OUTREACH_STOP_REASONS_BY_STATUS","normalizeCampaignIds","normalizeOutreachPersonalLine","effectiveOutreachWindow","scheduleOutreachDue","isInThreadEmailStep","OUTREACH_ENROLLMENT_TRANSITIONS","active","paused","finished","stopped","failed","replied","bounced","opted_out","canTransitionOutreachEnrollment","from","to","includes","STATUS_WORDS","EVENT_TARGET","pause","resume","stop","reply","opt_out","bounce","send_failed","detailOf","value","text","replace","trim","slice","applyOutreachEnrollmentEvent","enrollment","event","status","type","patch","error","stopReason","stopDetail","stoppedAtMs","stoppedByUid","nextDueAtMs","reason","allowed","byUid","detail","atMs","planOutreachFirstDue","input","first","sequence","steps","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","id","sequenceId","target","contactId","leadId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","campaignIds","createdAtMs","updatedAtMs","planOutreachStepCompletion","step","completedAtMs","sent","messageId","threadId","threadIds","push","subject","next"],"mappings":"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"}
@@ -18,6 +18,7 @@ import { type ContactSource } from '@aglyn/aglyn/app-utils/contacts';
18
18
  import type { TopicSubscriptionState } from '@aglyn/aglyn/app-utils/email-topics';
19
19
  import { type MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases';
20
20
  import { type OutreachAttestationKind, type OutreachAttestations, type OutreachEnrollmentStatus, type OutreachSequenceSettings } from '../model/outreach.types';
21
+ import { type OutreachGatewayStanding } from './mail-gateway';
21
22
  import { type OutreachRecipientCountry } from './recipient-country';
22
23
  /**
23
24
  * The capture doors that mean a person came to the business: a form, a
@@ -77,6 +78,13 @@ export interface OutreachGateLookups {
77
78
  * the rules stricter.
78
79
  */
79
80
  hasInboundEmail: boolean | null;
81
+ /**
82
+ * The mail gateway in front of the address's domain (AGL-3326), as its MX
83
+ * names it — `outreachDomainIntel/{domain}` — and what that gateway did
84
+ * with this organization's mail lately, from `outreachGatewayStats`.
85
+ * `none` blocks; a gateway that holds is answered in `hold`.
86
+ */
87
+ gateway: OutreachGatewayStanding | null;
80
88
  }
81
89
  export interface OutreachGateInput {
82
90
  /** The address the steps go to. */
@@ -94,14 +102,27 @@ export interface OutreachGateInput {
94
102
  attestations: OutreachAttestations | null | undefined;
95
103
  /** The enrollment being re-checked before a send, so it does not count as another. */
96
104
  enrollmentId?: string | null;
105
+ /**
106
+ * A member already released the gateway hold on this enrollment
107
+ * (AGL-3326) — resumed it, or ticked the person past the red chip — so
108
+ * the gateway's history does not hold it again.
109
+ */
110
+ gatewayHoldReleased?: boolean;
97
111
  lookups: OutreachGateLookups;
98
112
  }
99
- export type OutreachGateCode = 'contact_missing' | 'invalid_email' | 'email_not_on_contact' | 'free_mail' | 'country_not_allowed' | 'country_unknown' | 'platform_suppressed' | 'host_suppressed' | 'email_state' | 'sales_opted_out' | 'do_not_contact' | 'do_not_contact_domain' | 'workspace_member' | 'customer' | 'already_enrolled' | 'attestations_missing' | 'personal_line_missing' | 'personal_line_too_long';
113
+ export type OutreachGateCode = 'contact_missing' | 'invalid_email' | 'email_not_on_contact' | 'free_mail' | 'country_not_allowed' | 'country_unknown' | 'platform_suppressed' | 'host_suppressed' | 'email_state' | 'sales_opted_out' | 'do_not_contact' | 'do_not_contact_domain' | 'no_mx' | 'workspace_member' | 'customer' | 'already_enrolled' | 'attestations_missing' | 'personal_line_missing' | 'personal_line_too_long';
100
114
  export interface OutreachGateBlock {
101
115
  code: OutreachGateCode;
102
116
  /** A sentence a rep can act on. */
103
117
  reason: string;
104
118
  }
119
+ /** The one verdict that holds a send rather than refusing it — see the module note. */
120
+ export interface OutreachGateHold {
121
+ code: 'gateway_blocked_here';
122
+ gateway: OutreachGatewayStanding['gateway'];
123
+ /** A sentence the enrollment row shows. */
124
+ reason: string;
125
+ }
105
126
  export interface OutreachGateResult {
106
127
  allowed: boolean;
107
128
  /** The address, normalized, or `null` when it is not one. */
@@ -110,6 +131,12 @@ export interface OutreachGateResult {
110
131
  country: OutreachRecipientCountry;
111
132
  /** Every gate that refuses, in gate order. */
112
133
  blocks: OutreachGateBlock[];
134
+ /**
135
+ * The gateway hold (AGL-3326), when the address's gateway refused this
136
+ * organization twice lately and nobody released it; `null` otherwise. An
137
+ * allowed result can carry one: the send waits for a member.
138
+ */
139
+ hold: OutreachGateHold | null;
113
140
  /** The attestations a cold contact still needs, in the order they are asked. */
114
141
  missingAttestations: OutreachAttestationKind[];
115
142
  }
@@ -45,11 +45,23 @@
45
45
  * refuses — with a sentence that says the check could not be made rather
46
46
  * than one that accuses the address of something. A person who asked not to
47
47
  * be emailed must not be emailed because a read timed out.
48
+ *
49
+ * ## A block and a hold
50
+ *
51
+ * Every gate above REFUSES: the person is not enrolled, or the enrollment
52
+ * is stopped. The mail gateway (AGL-3326) adds one verdict of a different
53
+ * weight. A domain with no MX cannot receive mail and blocks like the rest;
54
+ * a gateway that refused this organization's sender twice in the last
55
+ * thirty days without delivering once HOLDS the send — the enrollment is
56
+ * allowed, and pauses at the send with `gateway_blocked_here` until a
57
+ * member resumes it — because the founder decides, and the decision is
58
+ * shown to them in Check people before they tick the person.
48
59
  *==========================================*/ import { contactEmails, normalizeContactEmail, readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
49
60
  import { isPublicMailboxDomain } from "@aglyn/aglyn/app-utils/crm";
50
61
  import { emailStateRefusal, readEmailState } from "@aglyn/aglyn/app-utils/email-state";
51
62
  import { findMemberByEmailAddress } from "@aglyn/aglyn/app-utils/member-email-aliases";
52
63
  import { OUTREACH_ATTESTATION_KINDS, OUTREACH_PERSONAL_LINE_MAX } from "../model/outreach.types.js";
64
+ import { outreachGatewayHoldReason, outreachGatewayHolds } from "./mail-gateway.js";
53
65
  import { outreachCountryName, resolveOutreachRecipientCountry } from "./recipient-country.js";
54
66
  import { readOutreachSequenceSettings } from "./sequence-validation.js";
55
67
  /**
@@ -103,7 +115,7 @@ function listInWords(items) {
103
115
  }
104
116
  const COULD_NOT_CHECK = 'Try again in a moment.';
105
117
  /** Every gate an address and a contact must pass — see the module note. */ export function evaluateOutreachGates(input) {
106
- var _input_contact;
118
+ var _input_contact, _ref;
107
119
  const lookups = input.lookups;
108
120
  const settings = readOutreachSequenceSettings(input.settings);
109
121
  const email = normalizeContactEmail(input.email);
@@ -124,6 +136,7 @@ const COULD_NOT_CHECK = 'Try again in a moment.';
124
136
  source: null
125
137
  },
126
138
  blocks,
139
+ hold: null,
127
140
  missingAttestations: []
128
141
  });
129
142
  if (!contact) {
@@ -199,6 +212,19 @@ const COULD_NOT_CHECK = 'Try again in a moment.';
199
212
  } else if ((lookups == null ? void 0 : lookups.doNotContactDomain) !== false) {
200
213
  block('do_not_contact_domain', `We couldn't check your do-not-contact list for ${domain}. ${COULD_NOT_CHECK}`);
201
214
  }
215
+ // 4b. The mail gateway in front of the domain (AGL-3326): no MX means no
216
+ // mail; a gateway that refused us twice lately holds, below.
217
+ const gateway = (_ref = lookups == null ? void 0 : lookups.gateway) != null ? _ref : null;
218
+ if (!gateway) {
219
+ block('no_mx', `We couldn't look up the mail server for ${domain}. ${COULD_NOT_CHECK}`);
220
+ } else if (gateway.gateway === 'none') {
221
+ block('no_mx', `${domain} has no MX record, so ${email} cannot receive mail.`);
222
+ }
223
+ const hold = gateway && input.gatewayHoldReleased !== true && outreachGatewayHolds(gateway) ? {
224
+ code: 'gateway_blocked_here',
225
+ gateway: gateway.gateway,
226
+ reason: outreachGatewayHoldReason(gateway.gateway, gateway.blocked30)
227
+ } : null;
202
228
  // 5. Not one of the workspace's own people.
203
229
  const members = lookups == null ? void 0 : lookups.workspaceMembers;
204
230
  if (!Array.isArray(members)) {
@@ -236,6 +262,7 @@ const COULD_NOT_CHECK = 'Try again in a moment.';
236
262
  cold,
237
263
  country,
238
264
  blocks,
265
+ hold,
239
266
  missingAttestations
240
267
  };
241
268
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/gates.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHO MAY BE EMAILED (AGL-2979).\n *\n * One pure decision, run twice: when a rep enrolls a person, so the dialog\n * can say everything that stands in the way at once, and again before every\n * send, because a person enrolled on Monday can unsubscribe, become a\n * customer or join the team by Thursday. It reads a contact as stored and\n * the RESULTS of the lookups the runtime makes; it makes none itself.\n *\n * ## Every block, not the first\n *\n * The result lists each gate that refuses, in the order below, with a\n * sentence a rep can act on. A dialog that revealed one problem per attempt\n * would be a dialog people learn to fight.\n *\n * ## Cold\n *\n * A contact is COLD when no inbound door ever brought them to the sending\n * site's business — no form, sign-up, order or booking recorded on that\n * site's facet of the contact, and no email they wrote to the workspace.\n * Cold contacts carry the heavier rules: a business address rather than a\n * personal mailbox, a United States address, a personal line from the rep,\n * and the three attestations only the rep can make. The facet is read, never\n * the whole document, because a form filled in on another client's site is\n * that client's relationship and not this one's.\n *\n * ## Lookups fail closed\n *\n * A lookup the runtime could not complete is passed as `null`, and `null`\n * refuses — with a sentence that says the check could not be made rather\n * than one that accuses the address of something. A person who asked not to\n * be emailed must not be emailed because a read timed out.\n *==========================================*/\n\nimport {\n type ContactSource,\n contactEmails,\n normalizeContactEmail,\n readContactFacet,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport { emailStateRefusal, readEmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { TopicSubscriptionState } from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n findMemberByEmailAddress,\n type MemberAddresses,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_PERSONAL_LINE_MAX,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollmentStatus,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport {\n type OutreachRecipientCountry,\n outreachCountryName,\n resolveOutreachRecipientCountry,\n} from './recipient-country'\nimport { readOutreachSequenceSettings } from './sequence-validation'\n\n/**\n * The capture doors that mean a person came to the business: a form, a\n * sign-up to the site or its newsletter, an order, a booking. `api`,\n * `import` and `manual` are how a business adds people it found, which is\n * exactly what cold means.\n */\nexport const OUTREACH_INBOUND_CONTACT_SOURCES: readonly ContactSource[] = [\n 'form',\n 'member',\n 'newsletter',\n 'order',\n 'booking',\n]\n\n/** The enrollment statuses that hold a person: one sequence at a time. */\nexport const OUTREACH_OPEN_ENROLLMENT_STATUSES: readonly OutreachEnrollmentStatus[] = [\n 'active',\n 'paused',\n]\n\n/**\n * What the runtime looked up about the address before asking. Every field is\n * `null` when its read failed, and a `null` refuses.\n */\nexport interface OutreachGateLookups {\n /**\n * `emailSuppressions/{key}` holds a record in force — a hard bounce or a\n * spam complaint anywhere on the platform (`isEmailSuppressed`).\n */\n platformSuppressed: boolean | null\n /** `hosts/{hostId}/suppressions/{key}` exists for the sequence's site. */\n hostSuppressed: boolean | null\n /**\n * The address's standing on the site's `sales` topic —\n * `readTopicSubscriptionState` of the `sales` entry in\n * `hosts/{hostId}/topicOptOuts/{key}`, folded across the site's consent\n * group. Only `opted-out` refuses: a pending confirmation — the site's own,\n * or a sibling's where the org said its group waits — is about a\n * subscription, and a sales email does not rest on one.\n */\n salesTopicState: TopicSubscriptionState | null\n /** The address is on the organization's Outreach do-not-contact list. */\n doNotContact: boolean | null\n /**\n * The address's DOMAIN is on that list (AGL-3244): a member put it there,\n * or the domain's mail gateway blocked the sender outright.\n */\n doNotContactDomain: boolean | null\n /**\n * The organization's roster: each member's sign-in address and confirmed\n * aliases, as `crmInboundRoster` builds it.\n */\n workspaceMembers: readonly MemberAddresses[] | null\n /**\n * The person's enrollments that are `active` or `paused`, found by contact\n * id or by address. Other statuses are ignored, so passing every\n * enrollment is safe too.\n */\n openEnrollments: ReadonlyArray<{\n id: string\n sequenceId: string\n status: OutreachEnrollmentStatus\n }> | null\n /**\n * An email the person wrote (`direction: 'inbound'`) is filed on the\n * contact. `null` reads as `false`, which only makes the contact cold and\n * the rules stricter.\n */\n hasInboundEmail: boolean | null\n}\n\nexport interface OutreachGateInput {\n /** The address the steps go to. */\n email: string\n /** The contact document as stored (`orgs/{orgId}/contacts/{id}`), or `null` when it is gone. */\n contact: Record<string, unknown> | null | undefined\n /** The consent group of the sequence's site: whose facet holds stage, sources and address. */\n contactGroupId: string\n /** The company document the contact is filed under, when there is one. */\n company?: Record<string, unknown> | null\n /** The sequence's settings as stored; a missing field reads as its default. */\n settings: Partial<OutreachSequenceSettings> | null | undefined\n /** The rep's signal sentence. */\n personalLine: string | null | undefined\n attestations: OutreachAttestations | null | undefined\n /** The enrollment being re-checked before a send, so it does not count as another. */\n enrollmentId?: string | null\n lookups: OutreachGateLookups\n}\n\nexport type OutreachGateCode =\n | 'contact_missing'\n | 'invalid_email'\n | 'email_not_on_contact'\n | 'free_mail'\n | 'country_not_allowed'\n | 'country_unknown'\n | 'platform_suppressed'\n | 'host_suppressed'\n | 'email_state'\n | 'sales_opted_out'\n | 'do_not_contact'\n | 'do_not_contact_domain'\n | 'workspace_member'\n | 'customer'\n | 'already_enrolled'\n | 'attestations_missing'\n | 'personal_line_missing'\n | 'personal_line_too_long'\n\nexport interface OutreachGateBlock {\n code: OutreachGateCode\n /** A sentence a rep can act on. */\n reason: string\n}\n\nexport interface OutreachGateResult {\n allowed: boolean\n /** The address, normalized, or `null` when it is not one. */\n email: string | null\n cold: boolean\n country: OutreachRecipientCountry\n /** Every gate that refuses, in gate order. */\n blocks: OutreachGateBlock[]\n /** The attestations a cold contact still needs, in the order they are asked. */\n missingAttestations: OutreachAttestationKind[]\n}\n\n/** Local parts that answer no person: nobody reads a reply sent there. */\nconst MACHINE_LOCAL_PART = /^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|bounces?)(\\+.*)?$/\n\n/**\n * Whether a contact came to the sending site's business through no inbound\n * door — see the module note.\n */\nexport function isColdOutreachContact(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n hasInboundEmail: boolean,\n): boolean {\n if (hasInboundEmail) return false\n const { sources } = readContactFacet(contact, contactGroupId)\n return !OUTREACH_INBOUND_CONTACT_SOURCES.some((source) => sources?.[source] === true)\n}\n\n/**\n * Whether the sending site's business counts the contact as a customer: a\n * `customer` stage, the `evangelist` stage past it, or an order on record.\n */\nexport function isOutreachCustomer(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n): boolean {\n const facet = readContactFacet(contact, contactGroupId)\n return (\n facet.lifecycleStage === 'customer' ||\n facet.lifecycleStage === 'evangelist' ||\n facet.sources?.order === true ||\n Number(facet.ordersCount) > 0\n )\n}\n\n/** A confirmation counts only when it says who confirmed it and when. */\nexport function outreachAttestationHolds(\n attestations: OutreachAttestations | null | undefined,\n kind: OutreachAttestationKind,\n): boolean {\n const entry = attestations?.[kind]\n return (\n typeof entry?.uid === 'string' &&\n entry.uid.trim() !== '' &&\n typeof entry.atMs === 'number' &&\n Number.isFinite(entry.atMs) &&\n entry.atMs > 0\n )\n}\n\n/** The personal line as stored: one line, whitespace settled. */\nexport function normalizeOutreachPersonalLine(value: unknown): string {\n return typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n}\n\n/** Each attestation as the sentence that asks for it reads it. */\nconst ATTESTATION_PHRASES: Record<OutreachAttestationKind, string> = {\n us_business_address: \"it's a US business address\",\n published_or_given: 'they or their company published it, or they gave it to you',\n verified_deliverable: 'it was verified as deliverable',\n}\n\nfunction listInWords(items: readonly string[]): string {\n if (items.length <= 1) return items.join('')\n return `${items.slice(0, -1).join('; ')}; and ${items[items.length - 1]}`\n}\n\nconst COULD_NOT_CHECK = 'Try again in a moment.'\n\n/** Every gate an address and a contact must pass — see the module note. */\nexport function evaluateOutreachGates(input: OutreachGateInput): OutreachGateResult {\n const lookups = input.lookups\n const settings = readOutreachSequenceSettings(input.settings)\n const email = normalizeContactEmail(input.email)\n const contact = input.contact ?? null\n const cold = isColdOutreachContact(\n contact,\n input.contactGroupId,\n lookups?.hasInboundEmail === true,\n )\n const blocks: OutreachGateBlock[] = []\n const block = (code: OutreachGateCode, reason: string) => blocks.push({ code, reason })\n // The gates below need both a record and an address to read.\n const refusedOutright = (): OutreachGateResult => ({\n allowed: false,\n email,\n cold,\n country: { country: null, source: null },\n blocks,\n missingAttestations: [],\n })\n\n if (!contact) {\n block('contact_missing', 'This contact no longer exists in the CRM.')\n return refusedOutright()\n }\n\n // 1. A valid address, one that belongs to this contact and to a person.\n if (!email) {\n block('invalid_email', `\"${String(input.email ?? '').trim()}\" isn't a valid email address.`)\n return refusedOutright()\n }\n if (MACHINE_LOCAL_PART.test(email.slice(0, email.lastIndexOf('@')))) {\n block('invalid_email', `${email} is an automated address that no person reads.`)\n }\n if (!contactEmails(contact).includes(email)) {\n block('email_not_on_contact', `${email} isn't an address on this contact's record.`)\n }\n\n // 2. A business address for a cold contact.\n if (cold && isPublicMailboxDomain(email)) {\n block(\n 'free_mail',\n `${email} is a personal mailbox. Cold outreach goes only to business addresses.`,\n )\n }\n\n // 3. The country.\n const facet = readContactFacet(contact, input.contactGroupId)\n const companyAddress = input.company ? input.company['address'] : null\n const country = resolveOutreachRecipientCountry({\n email,\n contactAddress: facet.address,\n companyAddress,\n })\n const attestedUs = outreachAttestationHolds(input.attestations, 'us_business_address')\n if (country.country) {\n const name = outreachCountryName(country.country)\n const evidence =\n country.source === 'domain' ? `its domain places it in ${name}` : `it's in ${name}`\n if (!settings.allowedCountries.includes(country.country)) {\n block(\n 'country_not_allowed',\n `${email} can't be emailed from this sequence: ${evidence}, which isn't one of its countries.`,\n )\n } else if (cold && country.country !== 'US') {\n block(\n 'country_not_allowed',\n `Cold outreach goes only to US business addresses, and ${evidence}.`,\n )\n }\n } else if (!settings.allowedCountries.includes('US')) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Add the country to the contact's address.`,\n )\n } else if (!attestedUs) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Confirm it's a US business address, or add the country to the contact.`,\n )\n }\n\n // 4. Every list that says not to — the record's own verdict first\n // (AGL-3245), in the words the record page shows: what the lists below\n // hold, said by the reason it is held.\n const recordRefusal = emailStateRefusal(readEmailState(contact))\n if (recordRefusal) block('email_state', recordRefusal)\n if (lookups?.platformSuppressed === true) {\n block(\n 'platform_suppressed',\n `${email} is on the platform's suppression list after a hard bounce or a spam complaint.`,\n )\n } else if (lookups?.platformSuppressed !== false) {\n block(\n 'platform_suppressed',\n `We couldn't check the platform's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.hostSuppressed === true) {\n block('host_suppressed', `${email} unsubscribed from this site's email, or mail to it bounced.`)\n } else if (lookups?.hostSuppressed !== false) {\n block(\n 'host_suppressed',\n `We couldn't check this site's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.salesTopicState === 'opted-out') {\n block('sales_opted_out', `${email} opted out of sales outreach from this site.`)\n } else if (lookups?.salesTopicState !== 'subscribed' && lookups?.salesTopicState !== 'pending') {\n block(\n 'sales_opted_out',\n `We couldn't check whether ${email} opted out of sales outreach. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.doNotContact === true) {\n block('do_not_contact', `${email} is on your organization's do-not-contact list.`)\n } else if (lookups?.doNotContact !== false) {\n block(\n 'do_not_contact',\n `We couldn't check your do-not-contact list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n const domain = email.slice(email.lastIndexOf('@') + 1)\n if (lookups?.doNotContactDomain === true) {\n block(\n 'do_not_contact_domain',\n `${email} is at ${domain}, which is on your organization's do-not-contact list: no address there is emailed.`,\n )\n } else if (lookups?.doNotContactDomain !== false) {\n block(\n 'do_not_contact_domain',\n `We couldn't check your do-not-contact list for ${domain}. ${COULD_NOT_CHECK}`,\n )\n }\n\n // 5. Not one of the workspace's own people.\n const members = lookups?.workspaceMembers\n if (!Array.isArray(members)) {\n block(\n 'workspace_member',\n `We couldn't check whether ${email} belongs to a member of this workspace. ${COULD_NOT_CHECK}`,\n )\n } else if (findMemberByEmailAddress(members, email)) {\n block('workspace_member', `${email} belongs to a member of this workspace.`)\n }\n\n // 6. Not a customer, unless the sequence is written for them.\n if (!settings.allowCustomers && isOutreachCustomer(contact, input.contactGroupId)) {\n block('customer', \"This contact is a customer, and this sequence doesn't include customers.\")\n }\n\n // 7. One sequence at a time.\n const enrollments = lookups?.openEnrollments\n if (!Array.isArray(enrollments)) {\n block(\n 'already_enrolled',\n `We couldn't check this contact's other sequences. ${COULD_NOT_CHECK}`,\n )\n } else if (\n enrollments.some(\n (enrollment) =>\n enrollment?.id !== input.enrollmentId &&\n OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment?.status),\n )\n ) {\n block(\n 'already_enrolled',\n 'This contact is already in another sequence. Finish or stop that one first.',\n )\n }\n\n // 8. What only the rep can vouch for, on a cold contact.\n const personalLine = normalizeOutreachPersonalLine(input.personalLine)\n const missingAttestations = cold\n ? OUTREACH_ATTESTATION_KINDS.filter(\n (kind) => !outreachAttestationHolds(input.attestations, kind),\n )\n : []\n if (missingAttestations.length) {\n const phrases = missingAttestations.map((kind) => `that ${ATTESTATION_PHRASES[kind]}`)\n block(\n 'attestations_missing',\n `Before emailing a cold contact, confirm ${listInWords(phrases)}.`,\n )\n }\n if (cold && !personalLine) {\n block(\n 'personal_line_missing',\n 'Write a personal line: one sentence on why you are writing to this person now.',\n )\n }\n if (personalLine.length > OUTREACH_PERSONAL_LINE_MAX) {\n block(\n 'personal_line_too_long',\n `Keep the personal line to one sentence, under ${OUTREACH_PERSONAL_LINE_MAX} characters.`,\n )\n }\n\n return {\n allowed: blocks.length === 0,\n email,\n cold,\n country,\n blocks,\n missingAttestations,\n }\n}\n"],"names":["contactEmails","normalizeContactEmail","readContactFacet","isPublicMailboxDomain","emailStateRefusal","readEmailState","findMemberByEmailAddress","OUTREACH_ATTESTATION_KINDS","OUTREACH_PERSONAL_LINE_MAX","outreachCountryName","resolveOutreachRecipientCountry","readOutreachSequenceSettings","OUTREACH_INBOUND_CONTACT_SOURCES","OUTREACH_OPEN_ENROLLMENT_STATUSES","MACHINE_LOCAL_PART","isColdOutreachContact","contact","contactGroupId","hasInboundEmail","sources","some","source","isOutreachCustomer","facet","lifecycleStage","order","Number","ordersCount","outreachAttestationHolds","attestations","kind","entry","uid","trim","atMs","isFinite","normalizeOutreachPersonalLine","value","replace","ATTESTATION_PHRASES","us_business_address","published_or_given","verified_deliverable","listInWords","items","length","join","slice","COULD_NOT_CHECK","evaluateOutreachGates","input","lookups","settings","email","cold","blocks","block","code","reason","push","refusedOutright","allowed","country","missingAttestations","String","test","lastIndexOf","includes","companyAddress","company","contactAddress","address","attestedUs","name","evidence","allowedCountries","recordRefusal","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","domain","doNotContactDomain","members","workspaceMembers","Array","isArray","allowCustomers","enrollments","openEnrollments","enrollment","id","enrollmentId","status","personalLine","filter","phrases","map"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/gates.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHO MAY BE EMAILED (AGL-2979).\n *\n * One pure decision, run twice: when a rep enrolls a person, so the dialog\n * can say everything that stands in the way at once, and again before every\n * send, because a person enrolled on Monday can unsubscribe, become a\n * customer or join the team by Thursday. It reads a contact as stored and\n * the RESULTS of the lookups the runtime makes; it makes none itself.\n *\n * ## Every block, not the first\n *\n * The result lists each gate that refuses, in the order below, with a\n * sentence a rep can act on. A dialog that revealed one problem per attempt\n * would be a dialog people learn to fight.\n *\n * ## Cold\n *\n * A contact is COLD when no inbound door ever brought them to the sending\n * site's business — no form, sign-up, order or booking recorded on that\n * site's facet of the contact, and no email they wrote to the workspace.\n * Cold contacts carry the heavier rules: a business address rather than a\n * personal mailbox, a United States address, a personal line from the rep,\n * and the three attestations only the rep can make. The facet is read, never\n * the whole document, because a form filled in on another client's site is\n * that client's relationship and not this one's.\n *\n * ## Lookups fail closed\n *\n * A lookup the runtime could not complete is passed as `null`, and `null`\n * refuses — with a sentence that says the check could not be made rather\n * than one that accuses the address of something. A person who asked not to\n * be emailed must not be emailed because a read timed out.\n *\n * ## A block and a hold\n *\n * Every gate above REFUSES: the person is not enrolled, or the enrollment\n * is stopped. The mail gateway (AGL-3326) adds one verdict of a different\n * weight. A domain with no MX cannot receive mail and blocks like the rest;\n * a gateway that refused this organization's sender twice in the last\n * thirty days without delivering once HOLDS the send — the enrollment is\n * allowed, and pauses at the send with `gateway_blocked_here` until a\n * member resumes it — because the founder decides, and the decision is\n * shown to them in Check people before they tick the person.\n *==========================================*/\n\nimport {\n type ContactSource,\n contactEmails,\n normalizeContactEmail,\n readContactFacet,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport { emailStateRefusal, readEmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { TopicSubscriptionState } from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n findMemberByEmailAddress,\n type MemberAddresses,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_PERSONAL_LINE_MAX,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollmentStatus,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport {\n type OutreachGatewayStanding,\n outreachGatewayHoldReason,\n outreachGatewayHolds,\n} from './mail-gateway'\nimport {\n type OutreachRecipientCountry,\n outreachCountryName,\n resolveOutreachRecipientCountry,\n} from './recipient-country'\nimport { readOutreachSequenceSettings } from './sequence-validation'\n\n/**\n * The capture doors that mean a person came to the business: a form, a\n * sign-up to the site or its newsletter, an order, a booking. `api`,\n * `import` and `manual` are how a business adds people it found, which is\n * exactly what cold means.\n */\nexport const OUTREACH_INBOUND_CONTACT_SOURCES: readonly ContactSource[] = [\n 'form',\n 'member',\n 'newsletter',\n 'order',\n 'booking',\n]\n\n/** The enrollment statuses that hold a person: one sequence at a time. */\nexport const OUTREACH_OPEN_ENROLLMENT_STATUSES: readonly OutreachEnrollmentStatus[] = [\n 'active',\n 'paused',\n]\n\n/**\n * What the runtime looked up about the address before asking. Every field is\n * `null` when its read failed, and a `null` refuses.\n */\nexport interface OutreachGateLookups {\n /**\n * `emailSuppressions/{key}` holds a record in force — a hard bounce or a\n * spam complaint anywhere on the platform (`isEmailSuppressed`).\n */\n platformSuppressed: boolean | null\n /** `hosts/{hostId}/suppressions/{key}` exists for the sequence's site. */\n hostSuppressed: boolean | null\n /**\n * The address's standing on the site's `sales` topic —\n * `readTopicSubscriptionState` of the `sales` entry in\n * `hosts/{hostId}/topicOptOuts/{key}`, folded across the site's consent\n * group. Only `opted-out` refuses: a pending confirmation — the site's own,\n * or a sibling's where the org said its group waits — is about a\n * subscription, and a sales email does not rest on one.\n */\n salesTopicState: TopicSubscriptionState | null\n /** The address is on the organization's Outreach do-not-contact list. */\n doNotContact: boolean | null\n /**\n * The address's DOMAIN is on that list (AGL-3244): a member put it there,\n * or the domain's mail gateway blocked the sender outright.\n */\n doNotContactDomain: boolean | null\n /**\n * The organization's roster: each member's sign-in address and confirmed\n * aliases, as `crmInboundRoster` builds it.\n */\n workspaceMembers: readonly MemberAddresses[] | null\n /**\n * The person's enrollments that are `active` or `paused`, found by contact\n * id or by address. Other statuses are ignored, so passing every\n * enrollment is safe too.\n */\n openEnrollments: ReadonlyArray<{\n id: string\n sequenceId: string\n status: OutreachEnrollmentStatus\n }> | null\n /**\n * An email the person wrote (`direction: 'inbound'`) is filed on the\n * contact. `null` reads as `false`, which only makes the contact cold and\n * the rules stricter.\n */\n hasInboundEmail: boolean | null\n /**\n * The mail gateway in front of the address's domain (AGL-3326), as its MX\n * names it — `outreachDomainIntel/{domain}` — and what that gateway did\n * with this organization's mail lately, from `outreachGatewayStats`.\n * `none` blocks; a gateway that holds is answered in `hold`.\n */\n gateway: OutreachGatewayStanding | null\n}\n\nexport interface OutreachGateInput {\n /** The address the steps go to. */\n email: string\n /** The contact document as stored (`orgs/{orgId}/contacts/{id}`), or `null` when it is gone. */\n contact: Record<string, unknown> | null | undefined\n /** The consent group of the sequence's site: whose facet holds stage, sources and address. */\n contactGroupId: string\n /** The company document the contact is filed under, when there is one. */\n company?: Record<string, unknown> | null\n /** The sequence's settings as stored; a missing field reads as its default. */\n settings: Partial<OutreachSequenceSettings> | null | undefined\n /** The rep's signal sentence. */\n personalLine: string | null | undefined\n attestations: OutreachAttestations | null | undefined\n /** The enrollment being re-checked before a send, so it does not count as another. */\n enrollmentId?: string | null\n /**\n * A member already released the gateway hold on this enrollment\n * (AGL-3326) — resumed it, or ticked the person past the red chip — so\n * the gateway's history does not hold it again.\n */\n gatewayHoldReleased?: boolean\n lookups: OutreachGateLookups\n}\n\nexport type OutreachGateCode =\n | 'contact_missing'\n | 'invalid_email'\n | 'email_not_on_contact'\n | 'free_mail'\n | 'country_not_allowed'\n | 'country_unknown'\n | 'platform_suppressed'\n | 'host_suppressed'\n | 'email_state'\n | 'sales_opted_out'\n | 'do_not_contact'\n | 'do_not_contact_domain'\n | 'no_mx'\n | 'workspace_member'\n | 'customer'\n | 'already_enrolled'\n | 'attestations_missing'\n | 'personal_line_missing'\n | 'personal_line_too_long'\n\nexport interface OutreachGateBlock {\n code: OutreachGateCode\n /** A sentence a rep can act on. */\n reason: string\n}\n\n/** The one verdict that holds a send rather than refusing it — see the module note. */\nexport interface OutreachGateHold {\n code: 'gateway_blocked_here'\n gateway: OutreachGatewayStanding['gateway']\n /** A sentence the enrollment row shows. */\n reason: string\n}\n\nexport interface OutreachGateResult {\n allowed: boolean\n /** The address, normalized, or `null` when it is not one. */\n email: string | null\n cold: boolean\n country: OutreachRecipientCountry\n /** Every gate that refuses, in gate order. */\n blocks: OutreachGateBlock[]\n /**\n * The gateway hold (AGL-3326), when the address's gateway refused this\n * organization twice lately and nobody released it; `null` otherwise. An\n * allowed result can carry one: the send waits for a member.\n */\n hold: OutreachGateHold | null\n /** The attestations a cold contact still needs, in the order they are asked. */\n missingAttestations: OutreachAttestationKind[]\n}\n\n/** Local parts that answer no person: nobody reads a reply sent there. */\nconst MACHINE_LOCAL_PART = /^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|bounces?)(\\+.*)?$/\n\n/**\n * Whether a contact came to the sending site's business through no inbound\n * door — see the module note.\n */\nexport function isColdOutreachContact(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n hasInboundEmail: boolean,\n): boolean {\n if (hasInboundEmail) return false\n const { sources } = readContactFacet(contact, contactGroupId)\n return !OUTREACH_INBOUND_CONTACT_SOURCES.some((source) => sources?.[source] === true)\n}\n\n/**\n * Whether the sending site's business counts the contact as a customer: a\n * `customer` stage, the `evangelist` stage past it, or an order on record.\n */\nexport function isOutreachCustomer(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n): boolean {\n const facet = readContactFacet(contact, contactGroupId)\n return (\n facet.lifecycleStage === 'customer' ||\n facet.lifecycleStage === 'evangelist' ||\n facet.sources?.order === true ||\n Number(facet.ordersCount) > 0\n )\n}\n\n/** A confirmation counts only when it says who confirmed it and when. */\nexport function outreachAttestationHolds(\n attestations: OutreachAttestations | null | undefined,\n kind: OutreachAttestationKind,\n): boolean {\n const entry = attestations?.[kind]\n return (\n typeof entry?.uid === 'string' &&\n entry.uid.trim() !== '' &&\n typeof entry.atMs === 'number' &&\n Number.isFinite(entry.atMs) &&\n entry.atMs > 0\n )\n}\n\n/** The personal line as stored: one line, whitespace settled. */\nexport function normalizeOutreachPersonalLine(value: unknown): string {\n return typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n}\n\n/** Each attestation as the sentence that asks for it reads it. */\nconst ATTESTATION_PHRASES: Record<OutreachAttestationKind, string> = {\n us_business_address: \"it's a US business address\",\n published_or_given: 'they or their company published it, or they gave it to you',\n verified_deliverable: 'it was verified as deliverable',\n}\n\nfunction listInWords(items: readonly string[]): string {\n if (items.length <= 1) return items.join('')\n return `${items.slice(0, -1).join('; ')}; and ${items[items.length - 1]}`\n}\n\nconst COULD_NOT_CHECK = 'Try again in a moment.'\n\n/** Every gate an address and a contact must pass — see the module note. */\nexport function evaluateOutreachGates(input: OutreachGateInput): OutreachGateResult {\n const lookups = input.lookups\n const settings = readOutreachSequenceSettings(input.settings)\n const email = normalizeContactEmail(input.email)\n const contact = input.contact ?? null\n const cold = isColdOutreachContact(\n contact,\n input.contactGroupId,\n lookups?.hasInboundEmail === true,\n )\n const blocks: OutreachGateBlock[] = []\n const block = (code: OutreachGateCode, reason: string) => blocks.push({ code, reason })\n // The gates below need both a record and an address to read.\n const refusedOutright = (): OutreachGateResult => ({\n allowed: false,\n email,\n cold,\n country: { country: null, source: null },\n blocks,\n hold: null,\n missingAttestations: [],\n })\n\n if (!contact) {\n block('contact_missing', 'This contact no longer exists in the CRM.')\n return refusedOutright()\n }\n\n // 1. A valid address, one that belongs to this contact and to a person.\n if (!email) {\n block('invalid_email', `\"${String(input.email ?? '').trim()}\" isn't a valid email address.`)\n return refusedOutright()\n }\n if (MACHINE_LOCAL_PART.test(email.slice(0, email.lastIndexOf('@')))) {\n block('invalid_email', `${email} is an automated address that no person reads.`)\n }\n if (!contactEmails(contact).includes(email)) {\n block('email_not_on_contact', `${email} isn't an address on this contact's record.`)\n }\n\n // 2. A business address for a cold contact.\n if (cold && isPublicMailboxDomain(email)) {\n block(\n 'free_mail',\n `${email} is a personal mailbox. Cold outreach goes only to business addresses.`,\n )\n }\n\n // 3. The country.\n const facet = readContactFacet(contact, input.contactGroupId)\n const companyAddress = input.company ? input.company['address'] : null\n const country = resolveOutreachRecipientCountry({\n email,\n contactAddress: facet.address,\n companyAddress,\n })\n const attestedUs = outreachAttestationHolds(input.attestations, 'us_business_address')\n if (country.country) {\n const name = outreachCountryName(country.country)\n const evidence =\n country.source === 'domain' ? `its domain places it in ${name}` : `it's in ${name}`\n if (!settings.allowedCountries.includes(country.country)) {\n block(\n 'country_not_allowed',\n `${email} can't be emailed from this sequence: ${evidence}, which isn't one of its countries.`,\n )\n } else if (cold && country.country !== 'US') {\n block(\n 'country_not_allowed',\n `Cold outreach goes only to US business addresses, and ${evidence}.`,\n )\n }\n } else if (!settings.allowedCountries.includes('US')) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Add the country to the contact's address.`,\n )\n } else if (!attestedUs) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Confirm it's a US business address, or add the country to the contact.`,\n )\n }\n\n // 4. Every list that says not to — the record's own verdict first\n // (AGL-3245), in the words the record page shows: what the lists below\n // hold, said by the reason it is held.\n const recordRefusal = emailStateRefusal(readEmailState(contact))\n if (recordRefusal) block('email_state', recordRefusal)\n if (lookups?.platformSuppressed === true) {\n block(\n 'platform_suppressed',\n `${email} is on the platform's suppression list after a hard bounce or a spam complaint.`,\n )\n } else if (lookups?.platformSuppressed !== false) {\n block(\n 'platform_suppressed',\n `We couldn't check the platform's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.hostSuppressed === true) {\n block('host_suppressed', `${email} unsubscribed from this site's email, or mail to it bounced.`)\n } else if (lookups?.hostSuppressed !== false) {\n block(\n 'host_suppressed',\n `We couldn't check this site's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.salesTopicState === 'opted-out') {\n block('sales_opted_out', `${email} opted out of sales outreach from this site.`)\n } else if (lookups?.salesTopicState !== 'subscribed' && lookups?.salesTopicState !== 'pending') {\n block(\n 'sales_opted_out',\n `We couldn't check whether ${email} opted out of sales outreach. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.doNotContact === true) {\n block('do_not_contact', `${email} is on your organization's do-not-contact list.`)\n } else if (lookups?.doNotContact !== false) {\n block(\n 'do_not_contact',\n `We couldn't check your do-not-contact list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n const domain = email.slice(email.lastIndexOf('@') + 1)\n if (lookups?.doNotContactDomain === true) {\n block(\n 'do_not_contact_domain',\n `${email} is at ${domain}, which is on your organization's do-not-contact list: no address there is emailed.`,\n )\n } else if (lookups?.doNotContactDomain !== false) {\n block(\n 'do_not_contact_domain',\n `We couldn't check your do-not-contact list for ${domain}. ${COULD_NOT_CHECK}`,\n )\n }\n\n // 4b. The mail gateway in front of the domain (AGL-3326): no MX means no\n // mail; a gateway that refused us twice lately holds, below.\n const gateway = lookups?.gateway ?? null\n if (!gateway) {\n block('no_mx', `We couldn't look up the mail server for ${domain}. ${COULD_NOT_CHECK}`)\n } else if (gateway.gateway === 'none') {\n block('no_mx', `${domain} has no MX record, so ${email} cannot receive mail.`)\n }\n const hold: OutreachGateHold | null =\n gateway && input.gatewayHoldReleased !== true && outreachGatewayHolds(gateway)\n ? {\n code: 'gateway_blocked_here',\n gateway: gateway.gateway,\n reason: outreachGatewayHoldReason(gateway.gateway, gateway.blocked30),\n }\n : null\n\n // 5. Not one of the workspace's own people.\n const members = lookups?.workspaceMembers\n if (!Array.isArray(members)) {\n block(\n 'workspace_member',\n `We couldn't check whether ${email} belongs to a member of this workspace. ${COULD_NOT_CHECK}`,\n )\n } else if (findMemberByEmailAddress(members, email)) {\n block('workspace_member', `${email} belongs to a member of this workspace.`)\n }\n\n // 6. Not a customer, unless the sequence is written for them.\n if (!settings.allowCustomers && isOutreachCustomer(contact, input.contactGroupId)) {\n block('customer', \"This contact is a customer, and this sequence doesn't include customers.\")\n }\n\n // 7. One sequence at a time.\n const enrollments = lookups?.openEnrollments\n if (!Array.isArray(enrollments)) {\n block(\n 'already_enrolled',\n `We couldn't check this contact's other sequences. ${COULD_NOT_CHECK}`,\n )\n } else if (\n enrollments.some(\n (enrollment) =>\n enrollment?.id !== input.enrollmentId &&\n OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment?.status),\n )\n ) {\n block(\n 'already_enrolled',\n 'This contact is already in another sequence. Finish or stop that one first.',\n )\n }\n\n // 8. What only the rep can vouch for, on a cold contact.\n const personalLine = normalizeOutreachPersonalLine(input.personalLine)\n const missingAttestations = cold\n ? OUTREACH_ATTESTATION_KINDS.filter(\n (kind) => !outreachAttestationHolds(input.attestations, kind),\n )\n : []\n if (missingAttestations.length) {\n const phrases = missingAttestations.map((kind) => `that ${ATTESTATION_PHRASES[kind]}`)\n block(\n 'attestations_missing',\n `Before emailing a cold contact, confirm ${listInWords(phrases)}.`,\n )\n }\n if (cold && !personalLine) {\n block(\n 'personal_line_missing',\n 'Write a personal line: one sentence on why you are writing to this person now.',\n )\n }\n if (personalLine.length > OUTREACH_PERSONAL_LINE_MAX) {\n block(\n 'personal_line_too_long',\n `Keep the personal line to one sentence, under ${OUTREACH_PERSONAL_LINE_MAX} characters.`,\n )\n }\n\n return {\n allowed: blocks.length === 0,\n email,\n cold,\n country,\n blocks,\n hold,\n missingAttestations,\n }\n}\n\n"],"names":["contactEmails","normalizeContactEmail","readContactFacet","isPublicMailboxDomain","emailStateRefusal","readEmailState","findMemberByEmailAddress","OUTREACH_ATTESTATION_KINDS","OUTREACH_PERSONAL_LINE_MAX","outreachGatewayHoldReason","outreachGatewayHolds","outreachCountryName","resolveOutreachRecipientCountry","readOutreachSequenceSettings","OUTREACH_INBOUND_CONTACT_SOURCES","OUTREACH_OPEN_ENROLLMENT_STATUSES","MACHINE_LOCAL_PART","isColdOutreachContact","contact","contactGroupId","hasInboundEmail","sources","some","source","isOutreachCustomer","facet","lifecycleStage","order","Number","ordersCount","outreachAttestationHolds","attestations","kind","entry","uid","trim","atMs","isFinite","normalizeOutreachPersonalLine","value","replace","ATTESTATION_PHRASES","us_business_address","published_or_given","verified_deliverable","listInWords","items","length","join","slice","COULD_NOT_CHECK","evaluateOutreachGates","input","lookups","settings","email","cold","blocks","block","code","reason","push","refusedOutright","allowed","country","hold","missingAttestations","String","test","lastIndexOf","includes","companyAddress","company","contactAddress","address","attestedUs","name","evidence","allowedCountries","recordRefusal","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","domain","doNotContactDomain","gateway","gatewayHoldReleased","blocked30","members","workspaceMembers","Array","isArray","allowCustomers","enrollments","openEnrollments","enrollment","id","enrollmentId","status","personalLine","filter","phrases","map"],"mappings":"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"}