@aglyn/plugins-outreach 1.0.0-beta.143

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 (282) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +93 -0
  3. package/package.json +52 -0
  4. package/src/index.d.ts +22 -0
  5. package/src/index.js +29 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/compliance-section.d.ts +20 -0
  8. package/src/lib/components/compliance-section.js +291 -0
  9. package/src/lib/components/compliance-section.js.map +1 -0
  10. package/src/lib/components/enroll-dialog.d.ts +36 -0
  11. package/src/lib/components/enroll-dialog.js +656 -0
  12. package/src/lib/components/enroll-dialog.js.map +1 -0
  13. package/src/lib/components/enrollments-table.d.ts +28 -0
  14. package/src/lib/components/enrollments-table.js +375 -0
  15. package/src/lib/components/enrollments-table.js.map +1 -0
  16. package/src/lib/components/mailbox-card.d.ts +46 -0
  17. package/src/lib/components/mailbox-card.js +517 -0
  18. package/src/lib/components/mailbox-card.js.map +1 -0
  19. package/src/lib/components/mailboxes-section.d.ts +24 -0
  20. package/src/lib/components/mailboxes-section.js +258 -0
  21. package/src/lib/components/mailboxes-section.js.map +1 -0
  22. package/src/lib/components/outreach-console-page.d.ts +17 -0
  23. package/src/lib/components/outreach-console-page.js +81 -0
  24. package/src/lib/components/outreach-console-page.js.map +1 -0
  25. package/src/lib/components/outreach-console-sections.d.ts +43 -0
  26. package/src/lib/components/outreach-console-sections.js +55 -0
  27. package/src/lib/components/outreach-console-sections.js.map +1 -0
  28. package/src/lib/components/outreach-ui.d.ts +51 -0
  29. package/src/lib/components/outreach-ui.js +171 -0
  30. package/src/lib/components/outreach-ui.js.map +1 -0
  31. package/src/lib/components/send-window-fields.d.ts +25 -0
  32. package/src/lib/components/send-window-fields.js +178 -0
  33. package/src/lib/components/send-window-fields.js.map +1 -0
  34. package/src/lib/components/sequence-detail.d.ts +54 -0
  35. package/src/lib/components/sequence-detail.js +435 -0
  36. package/src/lib/components/sequence-detail.js.map +1 -0
  37. package/src/lib/components/sequence-editor.d.ts +37 -0
  38. package/src/lib/components/sequence-editor.js +485 -0
  39. package/src/lib/components/sequence-editor.js.map +1 -0
  40. package/src/lib/components/sequence-mailbox-picker.d.ts +44 -0
  41. package/src/lib/components/sequence-mailbox-picker.js +137 -0
  42. package/src/lib/components/sequence-mailbox-picker.js.map +1 -0
  43. package/src/lib/components/sequence-preview.d.ts +27 -0
  44. package/src/lib/components/sequence-preview.js +146 -0
  45. package/src/lib/components/sequence-preview.js.map +1 -0
  46. package/src/lib/components/sequence-step-card.d.ts +31 -0
  47. package/src/lib/components/sequence-step-card.js +326 -0
  48. package/src/lib/components/sequence-step-card.js.map +1 -0
  49. package/src/lib/components/sequences-section.d.ts +40 -0
  50. package/src/lib/components/sequences-section.js +310 -0
  51. package/src/lib/components/sequences-section.js.map +1 -0
  52. package/src/lib/components/use-outreach-api.d.ts +38 -0
  53. package/src/lib/components/use-outreach-api.js +139 -0
  54. package/src/lib/components/use-outreach-api.js.map +1 -0
  55. package/src/lib/components/use-outreach-crm.d.ts +50 -0
  56. package/src/lib/components/use-outreach-crm.js +200 -0
  57. package/src/lib/components/use-outreach-crm.js.map +1 -0
  58. package/src/lib/components/use-outreach-data.d.ts +55 -0
  59. package/src/lib/components/use-outreach-data.js +199 -0
  60. package/src/lib/components/use-outreach-data.js.map +1 -0
  61. package/src/lib/components/use-outreach-mailbox-api.d.ts +30 -0
  62. package/src/lib/components/use-outreach-mailbox-api.js +101 -0
  63. package/src/lib/components/use-outreach-mailbox-api.js.map +1 -0
  64. package/src/lib/components/use-outreach-mailboxes.d.ts +22 -0
  65. package/src/lib/components/use-outreach-mailboxes.js +68 -0
  66. package/src/lib/components/use-outreach-mailboxes.js.map +1 -0
  67. package/src/lib/components/use-outreach-settings.d.ts +17 -0
  68. package/src/lib/components/use-outreach-settings.js +64 -0
  69. package/src/lib/components/use-outreach-settings.js.map +1 -0
  70. package/src/lib/constants/api-routes.d.ts +68 -0
  71. package/src/lib/constants/api-routes.js +57 -0
  72. package/src/lib/constants/api-routes.js.map +1 -0
  73. package/src/lib/constants/bundle-common.d.ts +46 -0
  74. package/src/lib/constants/bundle-common.js +55 -0
  75. package/src/lib/constants/bundle-common.js.map +1 -0
  76. package/src/lib/constants/runtime-jobs.d.ts +24 -0
  77. package/src/lib/constants/runtime-jobs.js +24 -0
  78. package/src/lib/constants/runtime-jobs.js.map +1 -0
  79. package/src/lib/declarations.console-server.d.ts +38 -0
  80. package/src/lib/declarations.console-server.js +99 -0
  81. package/src/lib/declarations.console-server.js.map +1 -0
  82. package/src/lib/engine/compose.d.ts +86 -0
  83. package/src/lib/engine/compose.js +212 -0
  84. package/src/lib/engine/compose.js.map +1 -0
  85. package/src/lib/engine/delivery-status.d.ts +53 -0
  86. package/src/lib/engine/delivery-status.js +184 -0
  87. package/src/lib/engine/delivery-status.js.map +1 -0
  88. package/src/lib/engine/do-not-contact.d.ts +18 -0
  89. package/src/lib/engine/do-not-contact.js +34 -0
  90. package/src/lib/engine/do-not-contact.js.map +1 -0
  91. package/src/lib/engine/enrollment-state.d.ts +156 -0
  92. package/src/lib/engine/enrollment-state.js +303 -0
  93. package/src/lib/engine/enrollment-state.js.map +1 -0
  94. package/src/lib/engine/gates.d.ts +125 -0
  95. package/src/lib/engine/gates.js +232 -0
  96. package/src/lib/engine/gates.js.map +1 -0
  97. package/src/lib/engine/index.d.ts +28 -0
  98. package/src/lib/engine/index.js +33 -0
  99. package/src/lib/engine/index.js.map +1 -0
  100. package/src/lib/engine/mailbox-health.d.ts +49 -0
  101. package/src/lib/engine/mailbox-health.js +83 -0
  102. package/src/lib/engine/mailbox-health.js.map +1 -0
  103. package/src/lib/engine/opt-out-intent.d.ts +41 -0
  104. package/src/lib/engine/opt-out-intent.js +220 -0
  105. package/src/lib/engine/opt-out-intent.js.map +1 -0
  106. package/src/lib/engine/recipient-country.d.ts +41 -0
  107. package/src/lib/engine/recipient-country.js +171 -0
  108. package/src/lib/engine/recipient-country.js.map +1 -0
  109. package/src/lib/engine/schedule.d.ts +70 -0
  110. package/src/lib/engine/schedule.js +115 -0
  111. package/src/lib/engine/schedule.js.map +1 -0
  112. package/src/lib/engine/sending-capacity.d.ts +96 -0
  113. package/src/lib/engine/sending-capacity.js +172 -0
  114. package/src/lib/engine/sending-capacity.js.map +1 -0
  115. package/src/lib/engine/sequence-validation.d.ts +90 -0
  116. package/src/lib/engine/sequence-validation.js +291 -0
  117. package/src/lib/engine/sequence-validation.js.map +1 -0
  118. package/src/lib/engine/thread-classification.d.ts +78 -0
  119. package/src/lib/engine/thread-classification.js +224 -0
  120. package/src/lib/engine/thread-classification.js.map +1 -0
  121. package/src/lib/engine/thread-message.d.ts +80 -0
  122. package/src/lib/engine/thread-message.js +143 -0
  123. package/src/lib/engine/thread-message.js.map +1 -0
  124. package/src/lib/enrollment/enroll-people.d.ts +81 -0
  125. package/src/lib/enrollment/enroll-people.js +250 -0
  126. package/src/lib/enrollment/enroll-people.js.map +1 -0
  127. package/src/lib/enrollment/gate-lookups.d.ts +32 -0
  128. package/src/lib/enrollment/gate-lookups.js +154 -0
  129. package/src/lib/enrollment/gate-lookups.js.map +1 -0
  130. package/src/lib/mailboxes/mailbox-api.d.ts +126 -0
  131. package/src/lib/mailboxes/mailbox-api.js +66 -0
  132. package/src/lib/mailboxes/mailbox-api.js.map +1 -0
  133. package/src/lib/mailboxes/mailbox-credentials.d.ts +95 -0
  134. package/src/lib/mailboxes/mailbox-credentials.js +102 -0
  135. package/src/lib/mailboxes/mailbox-credentials.js.map +1 -0
  136. package/src/lib/mailboxes/mailbox-erasure.d.ts +69 -0
  137. package/src/lib/mailboxes/mailbox-erasure.js +148 -0
  138. package/src/lib/mailboxes/mailbox-erasure.js.map +1 -0
  139. package/src/lib/mailboxes/mailbox-gate.d.ts +75 -0
  140. package/src/lib/mailboxes/mailbox-gate.js +98 -0
  141. package/src/lib/mailboxes/mailbox-gate.js.map +1 -0
  142. package/src/lib/mailboxes/mailbox-revoke.d.ts +59 -0
  143. package/src/lib/mailboxes/mailbox-revoke.js +41 -0
  144. package/src/lib/mailboxes/mailbox-revoke.js.map +1 -0
  145. package/src/lib/mailboxes/mailbox-routes.d.ts +119 -0
  146. package/src/lib/mailboxes/mailbox-routes.js +605 -0
  147. package/src/lib/mailboxes/mailbox-routes.js.map +1 -0
  148. package/src/lib/mailboxes/mailbox-settings.d.ts +110 -0
  149. package/src/lib/mailboxes/mailbox-settings.js +212 -0
  150. package/src/lib/mailboxes/mailbox-settings.js.map +1 -0
  151. package/src/lib/mailboxes/mailbox-transport.d.ts +64 -0
  152. package/src/lib/mailboxes/mailbox-transport.js +94 -0
  153. package/src/lib/mailboxes/mailbox-transport.js.map +1 -0
  154. package/src/lib/mailboxes/oauth-redirect.d.ts +41 -0
  155. package/src/lib/mailboxes/oauth-redirect.js +63 -0
  156. package/src/lib/mailboxes/oauth-redirect.js.map +1 -0
  157. package/src/lib/mailboxes/oauth-state.d.ts +152 -0
  158. package/src/lib/mailboxes/oauth-state.js +212 -0
  159. package/src/lib/mailboxes/oauth-state.js.map +1 -0
  160. package/src/lib/mailboxes/outreach-config.d.ts +64 -0
  161. package/src/lib/mailboxes/outreach-config.js +77 -0
  162. package/src/lib/mailboxes/outreach-config.js.map +1 -0
  163. package/src/lib/mailboxes/register-mailbox-routes.d.ts +47 -0
  164. package/src/lib/mailboxes/register-mailbox-routes.js +135 -0
  165. package/src/lib/mailboxes/register-mailbox-routes.js.map +1 -0
  166. package/src/lib/model/compliance-settings.d.ts +78 -0
  167. package/src/lib/model/compliance-settings.js +155 -0
  168. package/src/lib/model/compliance-settings.js.map +1 -0
  169. package/src/lib/model/outreach-api.d.ts +210 -0
  170. package/src/lib/model/outreach-api.js +28 -0
  171. package/src/lib/model/outreach-api.js.map +1 -0
  172. package/src/lib/model/outreach.types.d.ts +563 -0
  173. package/src/lib/model/outreach.types.js +225 -0
  174. package/src/lib/model/outreach.types.js.map +1 -0
  175. package/src/lib/model/sequence-draft.d.ts +84 -0
  176. package/src/lib/model/sequence-draft.js +239 -0
  177. package/src/lib/model/sequence-draft.js.map +1 -0
  178. package/src/lib/model/step-preview.d.ts +45 -0
  179. package/src/lib/model/step-preview.js +98 -0
  180. package/src/lib/model/step-preview.js.map +1 -0
  181. package/src/lib/model/stored-records.d.ts +23 -0
  182. package/src/lib/model/stored-records.js +84 -0
  183. package/src/lib/model/stored-records.js.map +1 -0
  184. package/src/lib/plugin.d.ts +46 -0
  185. package/src/lib/plugin.js +86 -0
  186. package/src/lib/plugin.js.map +1 -0
  187. package/src/lib/routes/enroll-routes.d.ts +64 -0
  188. package/src/lib/routes/enroll-routes.js +350 -0
  189. package/src/lib/routes/enroll-routes.js.map +1 -0
  190. package/src/lib/routes/enrollment-routes.d.ts +19 -0
  191. package/src/lib/routes/enrollment-routes.js +171 -0
  192. package/src/lib/routes/enrollment-routes.js.map +1 -0
  193. package/src/lib/routes/platform-deps.d.ts +51 -0
  194. package/src/lib/routes/platform-deps.js +66 -0
  195. package/src/lib/routes/platform-deps.js.map +1 -0
  196. package/src/lib/routes/preview-routes.d.ts +33 -0
  197. package/src/lib/routes/preview-routes.js +117 -0
  198. package/src/lib/routes/preview-routes.js.map +1 -0
  199. package/src/lib/routes/register-routes.d.ts +22 -0
  200. package/src/lib/routes/register-routes.js +92 -0
  201. package/src/lib/routes/register-routes.js.map +1 -0
  202. package/src/lib/routes/route-deps.d.ts +49 -0
  203. package/src/lib/routes/route-deps.js +27 -0
  204. package/src/lib/routes/route-deps.js.map +1 -0
  205. package/src/lib/routes/route-gate.d.ts +94 -0
  206. package/src/lib/routes/route-gate.js +87 -0
  207. package/src/lib/routes/route-gate.js.map +1 -0
  208. package/src/lib/routes/route-http.d.ts +32 -0
  209. package/src/lib/routes/route-http.js +62 -0
  210. package/src/lib/routes/route-http.js.map +1 -0
  211. package/src/lib/routes/sequence-routes.d.ts +32 -0
  212. package/src/lib/routes/sequence-routes.js +359 -0
  213. package/src/lib/routes/sequence-routes.js.map +1 -0
  214. package/src/lib/routes/settings-routes.d.ts +30 -0
  215. package/src/lib/routes/settings-routes.js +73 -0
  216. package/src/lib/routes/settings-routes.js.map +1 -0
  217. package/src/lib/runtime/enrollment-events.d.ts +86 -0
  218. package/src/lib/runtime/enrollment-events.js +141 -0
  219. package/src/lib/runtime/enrollment-events.js.map +1 -0
  220. package/src/lib/runtime/fixtures/fake-gmail.d.ts +121 -0
  221. package/src/lib/runtime/fixtures/fake-gmail.js +312 -0
  222. package/src/lib/runtime/fixtures/fake-gmail.js.map +1 -0
  223. package/src/lib/runtime/mailbox-health-store.d.ts +47 -0
  224. package/src/lib/runtime/mailbox-health-store.js +168 -0
  225. package/src/lib/runtime/mailbox-health-store.js.map +1 -0
  226. package/src/lib/runtime/person-erasure.d.ts +39 -0
  227. package/src/lib/runtime/person-erasure.js +57 -0
  228. package/src/lib/runtime/person-erasure.js.map +1 -0
  229. package/src/lib/runtime/platform-runtime-deps.d.ts +29 -0
  230. package/src/lib/runtime/platform-runtime-deps.js +98 -0
  231. package/src/lib/runtime/platform-runtime-deps.js.map +1 -0
  232. package/src/lib/runtime/runtime-deps.d.ts +77 -0
  233. package/src/lib/runtime/runtime-deps.js +18 -0
  234. package/src/lib/runtime/runtime-deps.js.map +1 -0
  235. package/src/lib/runtime/send-job.d.ts +42 -0
  236. package/src/lib/runtime/send-job.js +780 -0
  237. package/src/lib/runtime/send-job.js.map +1 -0
  238. package/src/lib/runtime/sync-job.d.ts +41 -0
  239. package/src/lib/runtime/sync-job.js +480 -0
  240. package/src/lib/runtime/sync-job.js.map +1 -0
  241. package/src/lib/runtime/timeline.d.ts +67 -0
  242. package/src/lib/runtime/timeline.js +93 -0
  243. package/src/lib/runtime/timeline.js.map +1 -0
  244. package/src/lib/runtime/unsubscribe-link.d.ts +75 -0
  245. package/src/lib/runtime/unsubscribe-link.js +121 -0
  246. package/src/lib/runtime/unsubscribe-link.js.map +1 -0
  247. package/src/lib/runtime/unsubscribe-route.d.ts +24 -0
  248. package/src/lib/runtime/unsubscribe-route.js +103 -0
  249. package/src/lib/runtime/unsubscribe-route.js.map +1 -0
  250. package/src/lib/server.d.ts +53 -0
  251. package/src/lib/server.js +94 -0
  252. package/src/lib/server.js.map +1 -0
  253. package/src/lib/storage/compliance-settings-store.d.ts +44 -0
  254. package/src/lib/storage/compliance-settings-store.js +78 -0
  255. package/src/lib/storage/compliance-settings-store.js.map +1 -0
  256. package/src/lib/storage/do-not-contact-store.d.ts +60 -0
  257. package/src/lib/storage/do-not-contact-store.js +168 -0
  258. package/src/lib/storage/do-not-contact-store.js.map +1 -0
  259. package/src/lib/storage/outreach-records.d.ts +33 -0
  260. package/src/lib/storage/outreach-records.js +35 -0
  261. package/src/lib/storage/outreach-records.js.map +1 -0
  262. package/src/lib/subprocessors.d.ts +48 -0
  263. package/src/lib/subprocessors.js +70 -0
  264. package/src/lib/subprocessors.js.map +1 -0
  265. package/src/lib/transport/gmail-client.d.ts +145 -0
  266. package/src/lib/transport/gmail-client.js +229 -0
  267. package/src/lib/transport/gmail-client.js.map +1 -0
  268. package/src/lib/transport/gmail-errors.d.ts +84 -0
  269. package/src/lib/transport/gmail-errors.js +138 -0
  270. package/src/lib/transport/gmail-errors.js.map +1 -0
  271. package/src/lib/transport/google-oauth.d.ts +138 -0
  272. package/src/lib/transport/google-oauth.js +247 -0
  273. package/src/lib/transport/google-oauth.js.map +1 -0
  274. package/src/lib/transport/http.d.ts +53 -0
  275. package/src/lib/transport/http.js +79 -0
  276. package/src/lib/transport/http.js.map +1 -0
  277. package/src/lib/transport/rfc5322.d.ts +116 -0
  278. package/src/lib/transport/rfc5322.js +330 -0
  279. package/src/lib/transport/rfc5322.js.map +1 -0
  280. package/src/lib/transport/send-message.d.ts +69 -0
  281. package/src/lib/transport/send-message.js +77 -0
  282. package/src/lib/transport/send-message.js.map +1 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * WHAT A SEQUENCE MAY BE (AGL-2979).
18
+ *
19
+ * One validator for the three readers that need it: the editor, which shows
20
+ * every issue beside the field it belongs to; the route that saves, which
21
+ * refuses a sequence with an error; and activation, which also refuses while
22
+ * the organization's footer cannot be written. Each issue names its field by
23
+ * a dotted path (`steps.2.subject`) and says what to do in a sentence.
24
+ *
25
+ * ERRORS AND WARNINGS. An error is a sequence the engine cannot send or the
26
+ * rules forbid: a fifth email, a first email with no subject, a `Re:` on a
27
+ * message that answers nothing. A warning is a sequence that sends and
28
+ * probably is not what its author meant — a merge field nobody fills, a
29
+ * first email that never uses the personal line the rep writes at
30
+ * enrollment.
31
+ *==========================================*/ import { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
32
+ import { CRM_MERGE_FIELDS, crmMergeFieldsIn } from "@aglyn/aglyn/app-utils/crm-email-templates";
33
+ import { crmThreadSubject } from "@aglyn/aglyn/app-utils/crm-inbound";
34
+ import { OUTREACH_DEFAULT_ALLOWED_COUNTRIES, OUTREACH_MAX_EMAIL_STEPS, OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS, OUTREACH_MAX_STEPS, OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS, OUTREACH_TASK_KINDS } from "../model/outreach.types.js";
35
+ /** The merge field the rep's signal sentence is written into a step with. */ export const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine';
36
+ /**
37
+ * Every field an Outreach step may use: the CRM's, which the composer fills
38
+ * through the CRM's own resolver, and the enrollment's.
39
+ */ export const OUTREACH_MERGE_FIELDS = [
40
+ ...CRM_MERGE_FIELDS,
41
+ {
42
+ key: OUTREACH_PERSONAL_LINE_FIELD,
43
+ label: 'Personal line',
44
+ group: 'Enrollment'
45
+ }
46
+ ];
47
+ /** The longest name a sequence carries. */ export const OUTREACH_SEQUENCE_NAME_MAX = 120;
48
+ /** The longest title a task step carries. */ export const OUTREACH_TASK_TITLE_MAX = 200;
49
+ /** Whether any issue in the list stops a save or an activation. */ export function hasOutreachValidationErrors(issues) {
50
+ return issues.some((issue)=>issue.severity === 'error');
51
+ }
52
+ const error = (path, code, message)=>({
53
+ path,
54
+ code,
55
+ message,
56
+ severity: 'error'
57
+ });
58
+ const warning = (path, code, message)=>({
59
+ path,
60
+ code,
61
+ message,
62
+ severity: 'warning'
63
+ });
64
+ const asText = (value)=>typeof value === 'string' ? value : '';
65
+ const isEmailStep = (step)=>(step == null ? void 0 : step.kind) === 'email';
66
+ /** The index of the first email step, or `-1` for a sequence with none. */ export function firstEmailStepIndex(steps) {
67
+ return (steps != null ? steps : []).findIndex(isEmailStep);
68
+ }
69
+ /**
70
+ * Whether the email at `index` is sent as a reply in the thread an earlier
71
+ * email started: every email after the first unless it says otherwise. The
72
+ * first email starts the thread whatever its `replyInThread` says.
73
+ */ export function isInThreadEmailStep(steps, index) {
74
+ const step = steps == null ? void 0 : steps[index];
75
+ if (!isEmailStep(step)) return false;
76
+ const first = firstEmailStepIndex(steps);
77
+ return index > first && step.replyInThread !== false;
78
+ }
79
+ /**
80
+ * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,
81
+ * `AW:` — which on an email that answers nothing tells the recipient a
82
+ * conversation exists that does not. The prefixes are the CRM thread
83
+ * reader's, so the two agree on what one looks like.
84
+ */ export function outreachSubjectHasReplyPrefix(subject) {
85
+ const collapsed = asText(subject).replace(/\s+/g, ' ').trim();
86
+ return collapsed !== '' && crmThreadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX);
87
+ }
88
+ const WEEKDAY_NAMES = [
89
+ 'Sunday',
90
+ 'Monday',
91
+ 'Tuesday',
92
+ 'Wednesday',
93
+ 'Thursday',
94
+ 'Friday',
95
+ 'Saturday'
96
+ ];
97
+ /** A sending window's shape: at least one weekday, and an opening before a close. */ export function validateOutreachSendWindow(window, path = 'window') {
98
+ const issues = [];
99
+ const days = Array.isArray(window == null ? void 0 : window.days) ? window.days : [];
100
+ const daysValid = days.length > 0 && days.every((day)=>Number.isInteger(day) && day >= 0 && day < WEEKDAY_NAMES.length) && new Set(days).size === days.length;
101
+ if (!daysValid) {
102
+ issues.push(error(`${path}.days`, 'window_days_invalid', 'Pick at least one day of the week to send on.'));
103
+ }
104
+ const start = window == null ? void 0 : window.startMinute;
105
+ const end = window == null ? void 0 : window.endMinute;
106
+ const hoursValid = Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end <= 24 * 60 && start < end;
107
+ if (!hoursValid) {
108
+ issues.push(error(path, 'window_hours_invalid', 'The sending hours need a start time before the end time, within one day.'));
109
+ }
110
+ return issues;
111
+ }
112
+ /**
113
+ * The allowed countries, as stored or typed, reduced to unique uppercase
114
+ * alpha-2 codes in the order given. Anything that is not two letters is
115
+ * dropped, so a caller that needs to report it checks the raw value.
116
+ */ export function normalizeOutreachAllowedCountries(value) {
117
+ if (!Array.isArray(value)) return [];
118
+ const codes = [];
119
+ for (const entry of value){
120
+ const code = asText(entry).trim().toUpperCase();
121
+ if (/^[A-Z]{2}$/.test(code) && !codes.includes(code)) codes.push(code);
122
+ }
123
+ return codes;
124
+ }
125
+ /**
126
+ * A stored settings map with the defaults applied: the United States alone,
127
+ * no customers, the mailbox's own window. A field a writer never set reads
128
+ * as its default rather than as a permission nobody gave.
129
+ */ export function readOutreachSequenceSettings(raw) {
130
+ const data = raw != null ? raw : {};
131
+ const countries = normalizeOutreachAllowedCountries(data['allowedCountries']);
132
+ const window = data['window'];
133
+ return {
134
+ window: window && typeof window === 'object' ? window : null,
135
+ allowedCountries: Array.isArray(data['allowedCountries']) ? countries : [
136
+ ...OUTREACH_DEFAULT_ALLOWED_COUNTRIES
137
+ ],
138
+ allowCustomers: data['allowCustomers'] === true
139
+ };
140
+ }
141
+ export function validateOutreachSequenceSettings(settings, path = 'settings') {
142
+ const issues = [];
143
+ if (settings == null ? void 0 : settings.window) {
144
+ issues.push(...validateOutreachSendWindow(settings.window, `${path}.window`));
145
+ }
146
+ const raw = Array.isArray(settings == null ? void 0 : settings.allowedCountries) ? settings.allowedCountries : [];
147
+ if (!raw.length) {
148
+ issues.push(error(`${path}.allowedCountries`, 'countries_required', 'Choose at least one country this sequence may send to.'));
149
+ }
150
+ raw.forEach((entry, index)=>{
151
+ if (!/^[A-Z]{2}$/.test(asText(entry))) {
152
+ issues.push(error(`${path}.allowedCountries.${index}`, 'country_invalid', `"${asText(entry)}" isn't a two-letter country code, like US.`));
153
+ }
154
+ });
155
+ return issues;
156
+ }
157
+ const KNOWN_MERGE_FIELDS = new Set(OUTREACH_MERGE_FIELDS.map((field)=>field.key));
158
+ function mergeFieldWarnings(text, path) {
159
+ return crmMergeFieldsIn(text).filter((key)=>!KNOWN_MERGE_FIELDS.has(key)).map((key)=>warning(path, 'unknown_merge_field', `{{${key}}} isn't a merge field, so it will be sent empty.`));
160
+ }
161
+ function delayIssues(steps, step, index, path) {
162
+ const delay = step.delayBusinessDays;
163
+ const followsAnEmail = isEmailStep(step) && index > firstEmailStepIndex(steps);
164
+ const min = followsAnEmail ? OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS : 0;
165
+ if (Number.isInteger(delay) && delay >= min && delay <= OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS) {
166
+ return [];
167
+ }
168
+ const message = min > 0 ? `Wait ${min}–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this email.` : `Wait 0–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this step.`;
169
+ return [
170
+ error(`${path}.delayBusinessDays`, 'delay_invalid', message)
171
+ ];
172
+ }
173
+ function emailStepIssues(steps, step, index, path) {
174
+ const issues = [];
175
+ const first = firstEmailStepIndex(steps) === index;
176
+ const startsThread = !isInThreadEmailStep(steps, index);
177
+ const subject = asText(step.subject);
178
+ if (startsThread) {
179
+ if (!subject.trim()) {
180
+ issues.push(error(`${path}.subject`, 'subject_required', first ? 'The first email needs a subject.' : 'An email that starts a new thread needs a subject.'));
181
+ } else if (outreachSubjectHasReplyPrefix(subject)) {
182
+ issues.push(error(`${path}.subject`, 'subject_reply_prefix', 'Remove the "Re:" or "Fwd:" — this email starts a conversation, it doesn\'t answer one.'));
183
+ }
184
+ if (subject.length > CRM_EMAIL_SUBJECT_MAX) {
185
+ issues.push(error(`${path}.subject`, 'subject_too_long', `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`));
186
+ }
187
+ issues.push(...mergeFieldWarnings(subject, `${path}.subject`));
188
+ }
189
+ const body = asText(step.body);
190
+ const templateId = asText(step.templateId).trim();
191
+ if (body.trim() && templateId) {
192
+ issues.push(error(`${path}.body`, 'body_and_template', 'Write the email here or pick a template, not both.'));
193
+ } else if (!body.trim() && !templateId) {
194
+ issues.push(error(`${path}.body`, 'body_required', 'Write the email, or pick a template.'));
195
+ }
196
+ if (body.length > CRM_EMAIL_BODY_MAX) {
197
+ issues.push(error(`${path}.body`, 'body_too_long', `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`));
198
+ }
199
+ if (body.trim()) {
200
+ issues.push(...mergeFieldWarnings(body, `${path}.body`));
201
+ if (first && !crmMergeFieldsIn(body).includes(OUTREACH_PERSONAL_LINE_FIELD)) {
202
+ issues.push(warning(`${path}.body`, 'personal_line_unused', `The first email doesn't use {{${OUTREACH_PERSONAL_LINE_FIELD}}}, so the line written for each person won't appear.`));
203
+ }
204
+ }
205
+ return issues;
206
+ }
207
+ /** The steps of a sequence: their count, their ids, and each one's own fields. */ export function validateOutreachSteps(steps, path = 'steps') {
208
+ const list = Array.isArray(steps) ? steps : [];
209
+ const issues = [];
210
+ if (!list.length) {
211
+ issues.push(error(path, 'steps_required', 'Add at least one step.'));
212
+ return issues;
213
+ }
214
+ if (list.length > OUTREACH_MAX_STEPS) {
215
+ issues.push(error(path, 'too_many_steps', `A sequence holds at most ${OUTREACH_MAX_STEPS} steps.`));
216
+ }
217
+ const emailCount = list.filter(isEmailStep).length;
218
+ if (emailCount > OUTREACH_MAX_EMAIL_STEPS) {
219
+ issues.push(error(path, 'too_many_email_steps', `A sequence sends at most ${OUTREACH_MAX_EMAIL_STEPS} emails to one person.`));
220
+ }
221
+ const seen = new Set();
222
+ list.forEach((step, index)=>{
223
+ const stepPath = `${path}.${index}`;
224
+ const id = asText(step == null ? void 0 : step.id).trim();
225
+ if (!id) {
226
+ issues.push(error(`${stepPath}.id`, 'step_id_required', 'Every step needs an id.'));
227
+ } else if (seen.has(id)) {
228
+ issues.push(error(`${stepPath}.id`, 'duplicate_step_id', 'Two steps share an id.'));
229
+ }
230
+ seen.add(id);
231
+ if (isEmailStep(step)) {
232
+ issues.push(...delayIssues(list, step, index, stepPath));
233
+ issues.push(...emailStepIssues(list, step, index, stepPath));
234
+ } else if ((step == null ? void 0 : step.kind) === 'task') {
235
+ issues.push(...delayIssues(list, step, index, stepPath));
236
+ if (!OUTREACH_TASK_KINDS.includes(step.taskKind)) {
237
+ issues.push(error(`${stepPath}.taskKind`, 'task_kind_invalid', 'Pick LinkedIn, Call or To-do.'));
238
+ }
239
+ const title = asText(step.title);
240
+ if (!title.trim()) {
241
+ issues.push(error(`${stepPath}.title`, 'task_title_required', 'Give the task a title.'));
242
+ } else if (title.length > OUTREACH_TASK_TITLE_MAX) {
243
+ issues.push(error(`${stepPath}.title`, 'task_title_too_long', `Keep the title under ${OUTREACH_TASK_TITLE_MAX} characters.`));
244
+ }
245
+ } else {
246
+ issues.push(error(stepPath, 'unknown_step_kind', 'A step is an email or a task.'));
247
+ }
248
+ });
249
+ return issues;
250
+ }
251
+ /** Everything a stored sequence has to be before it may be saved. */ export function validateOutreachSequence(sequence) {
252
+ var _ref;
253
+ const issues = [];
254
+ const name = asText(sequence == null ? void 0 : sequence.name);
255
+ if (!name.trim()) {
256
+ issues.push(error('name', 'name_required', 'Name the sequence.'));
257
+ } else if (name.length > OUTREACH_SEQUENCE_NAME_MAX) {
258
+ issues.push(error('name', 'name_too_long', `Keep the name under ${OUTREACH_SEQUENCE_NAME_MAX} characters.`));
259
+ }
260
+ const steps = (_ref = sequence == null ? void 0 : sequence.steps) != null ? _ref : [];
261
+ if (steps.some(isEmailStep) && !asText(sequence == null ? void 0 : sequence.mailboxId).trim()) {
262
+ issues.push(error('mailboxId', 'mailbox_required', 'Choose the mailbox this sequence sends from.'));
263
+ }
264
+ issues.push(...validateOutreachSteps(steps));
265
+ issues.push(...validateOutreachSequenceSettings(sequence == null ? void 0 : sequence.settings));
266
+ return issues;
267
+ }
268
+ /**
269
+ * The organization's footer fields: a legal name and a postal address, the
270
+ * two things the law requires every commercial email to carry.
271
+ */ export function validateOutreachOrgSettings(settings, path = 'orgSettings') {
272
+ const issues = [];
273
+ if (!asText(settings == null ? void 0 : settings.legalName).trim()) {
274
+ issues.push(error(`${path}.legalName`, 'legal_name_required', "Add your organization's legal name in Sequences settings. It goes in every email's footer."));
275
+ }
276
+ if (!asText(settings == null ? void 0 : settings.postalAddress).trim()) {
277
+ issues.push(error(`${path}.postalAddress`, 'postal_address_required', "Add your organization's postal address in Sequences settings. The law requires one in every sales email."));
278
+ }
279
+ return issues;
280
+ }
281
+ /**
282
+ * What activating a sequence refuses on: every issue saving refuses on, and
283
+ * an organization whose footer cannot be written yet.
284
+ */ export function validateOutreachSequenceActivation(sequence, orgSettings) {
285
+ return [
286
+ ...validateOutreachSequence(sequence),
287
+ ...validateOutreachOrgSettings(orgSettings)
288
+ ];
289
+ }
290
+
291
+ //# sourceMappingURL=sequence-validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sequence-validation.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT A SEQUENCE MAY BE (AGL-2979).\n *\n * One validator for the three readers that need it: the editor, which shows\n * every issue beside the field it belongs to; the route that saves, which\n * refuses a sequence with an error; and activation, which also refuses while\n * the organization's footer cannot be written. Each issue names its field by\n * a dotted path (`steps.2.subject`) and says what to do in a sentence.\n *\n * ERRORS AND WARNINGS. An error is a sequence the engine cannot send or the\n * rules forbid: a fifth email, a first email with no subject, a `Re:` on a\n * message that answers nothing. A warning is a sequence that sends and\n * probably is not what its author meant — a merge field nobody fills, a\n * first email that never uses the personal line the rep writes at\n * enrollment.\n *==========================================*/\n\nimport { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n CRM_MERGE_FIELDS,\n type CrmMergeFieldGroup,\n crmMergeFieldsIn,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport {\n OUTREACH_DEFAULT_ALLOWED_COUNTRIES,\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n OUTREACH_MAX_STEPS,\n OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS,\n OUTREACH_TASK_KINDS,\n type OutreachEmailStep,\n type OutreachOrgSettings,\n type OutreachSendWindow,\n type OutreachSequence,\n type OutreachSequenceSettings,\n type OutreachSequenceStep,\n} from '../model/outreach.types'\n\n/** The merge field the rep's signal sentence is written into a step with. */\nexport const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine'\n\n/** A merge field as the Outreach editor's picker offers it. */\nexport interface OutreachMergeFieldDefinition {\n key: string\n label: string\n group: CrmMergeFieldGroup | 'Enrollment'\n}\n\n/**\n * Every field an Outreach step may use: the CRM's, which the composer fills\n * through the CRM's own resolver, and the enrollment's.\n */\nexport const OUTREACH_MERGE_FIELDS: readonly OutreachMergeFieldDefinition[] = [\n ...CRM_MERGE_FIELDS,\n { key: OUTREACH_PERSONAL_LINE_FIELD, label: 'Personal line', group: 'Enrollment' },\n]\n\n/** The longest name a sequence carries. */\nexport const OUTREACH_SEQUENCE_NAME_MAX = 120\n\n/** The longest title a task step carries. */\nexport const OUTREACH_TASK_TITLE_MAX = 200\n\nexport type OutreachValidationSeverity = 'error' | 'warning'\n\nexport type OutreachValidationCode =\n | 'name_required'\n | 'name_too_long'\n | 'mailbox_required'\n | 'steps_required'\n | 'too_many_steps'\n | 'too_many_email_steps'\n | 'step_id_required'\n | 'duplicate_step_id'\n | 'unknown_step_kind'\n | 'delay_invalid'\n | 'subject_required'\n | 'subject_too_long'\n | 'subject_reply_prefix'\n | 'body_required'\n | 'body_and_template'\n | 'body_too_long'\n | 'unknown_merge_field'\n | 'personal_line_unused'\n | 'task_kind_invalid'\n | 'task_title_required'\n | 'task_title_too_long'\n | 'window_days_invalid'\n | 'window_hours_invalid'\n | 'countries_required'\n | 'country_invalid'\n | 'legal_name_required'\n | 'postal_address_required'\n\nexport interface OutreachValidationIssue {\n /** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */\n path: string\n code: OutreachValidationCode\n message: string\n severity: OutreachValidationSeverity\n}\n\n/** Whether any issue in the list stops a save or an activation. */\nexport function hasOutreachValidationErrors(\n issues: readonly OutreachValidationIssue[],\n): boolean {\n return issues.some((issue) => issue.severity === 'error')\n}\n\nconst error = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'error' })\n\nconst warning = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'warning' })\n\nconst asText = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nconst isEmailStep = (step: OutreachSequenceStep | null | undefined): step is OutreachEmailStep =>\n step?.kind === 'email'\n\n/** The index of the first email step, or `-1` for a sequence with none. */\nexport function firstEmailStepIndex(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n): number {\n return (steps ?? []).findIndex(isEmailStep)\n}\n\n/**\n * Whether the email at `index` is sent as a reply in the thread an earlier\n * email started: every email after the first unless it says otherwise. The\n * first email starts the thread whatever its `replyInThread` says.\n */\nexport function isInThreadEmailStep(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n): boolean {\n const step = steps?.[index]\n if (!isEmailStep(step)) return false\n const first = firstEmailStepIndex(steps)\n return index > first && step.replyInThread !== false\n}\n\n/**\n * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,\n * `AW:` — which on an email that answers nothing tells the recipient a\n * conversation exists that does not. The prefixes are the CRM thread\n * reader's, so the two agree on what one looks like.\n */\nexport function outreachSubjectHasReplyPrefix(subject: unknown): boolean {\n const collapsed = asText(subject).replace(/\\s+/g, ' ').trim()\n return collapsed !== '' && crmThreadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\nconst WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']\n\n/** A sending window's shape: at least one weekday, and an opening before a close. */\nexport function validateOutreachSendWindow(\n window: OutreachSendWindow | null | undefined,\n path = 'window',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const days = Array.isArray(window?.days) ? window.days : []\n const daysValid =\n days.length > 0 &&\n days.every((day) => Number.isInteger(day) && day >= 0 && day < WEEKDAY_NAMES.length) &&\n new Set(days).size === days.length\n if (!daysValid) {\n issues.push(\n error(`${path}.days`, 'window_days_invalid', 'Pick at least one day of the week to send on.'),\n )\n }\n const start = window?.startMinute\n const end = window?.endMinute\n const hoursValid =\n Number.isInteger(start) &&\n Number.isInteger(end) &&\n start >= 0 &&\n end <= 24 * 60 &&\n start < end\n if (!hoursValid) {\n issues.push(\n error(\n path,\n 'window_hours_invalid',\n 'The sending hours need a start time before the end time, within one day.',\n ),\n )\n }\n return issues\n}\n\n/**\n * The allowed countries, as stored or typed, reduced to unique uppercase\n * alpha-2 codes in the order given. Anything that is not two letters is\n * dropped, so a caller that needs to report it checks the raw value.\n */\nexport function normalizeOutreachAllowedCountries(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const codes: string[] = []\n for (const entry of value) {\n const code = asText(entry).trim().toUpperCase()\n if (/^[A-Z]{2}$/.test(code) && !codes.includes(code)) codes.push(code)\n }\n return codes\n}\n\n/**\n * A stored settings map with the defaults applied: the United States alone,\n * no customers, the mailbox's own window. A field a writer never set reads\n * as its default rather than as a permission nobody gave.\n */\nexport function readOutreachSequenceSettings(\n raw: Partial<OutreachSequenceSettings> | Record<string, unknown> | null | undefined,\n): OutreachSequenceSettings {\n const data = (raw ?? {}) as Record<string, unknown>\n const countries = normalizeOutreachAllowedCountries(data['allowedCountries'])\n const window = data['window'] as OutreachSendWindow | null | undefined\n return {\n window: window && typeof window === 'object' ? window : null,\n allowedCountries: Array.isArray(data['allowedCountries'])\n ? countries\n : [...OUTREACH_DEFAULT_ALLOWED_COUNTRIES],\n allowCustomers: data['allowCustomers'] === true,\n }\n}\n\nexport function validateOutreachSequenceSettings(\n settings: OutreachSequenceSettings | null | undefined,\n path = 'settings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (settings?.window) {\n issues.push(...validateOutreachSendWindow(settings.window, `${path}.window`))\n }\n const raw = Array.isArray(settings?.allowedCountries) ? settings.allowedCountries : []\n if (!raw.length) {\n issues.push(\n error(\n `${path}.allowedCountries`,\n 'countries_required',\n 'Choose at least one country this sequence may send to.',\n ),\n )\n }\n raw.forEach((entry, index) => {\n if (!/^[A-Z]{2}$/.test(asText(entry))) {\n issues.push(\n error(\n `${path}.allowedCountries.${index}`,\n 'country_invalid',\n `\"${asText(entry)}\" isn't a two-letter country code, like US.`,\n ),\n )\n }\n })\n return issues\n}\n\nconst KNOWN_MERGE_FIELDS = new Set(OUTREACH_MERGE_FIELDS.map((field) => field.key))\n\nfunction mergeFieldWarnings(text: string, path: string): OutreachValidationIssue[] {\n return crmMergeFieldsIn(text)\n .filter((key) => !KNOWN_MERGE_FIELDS.has(key))\n .map((key) =>\n warning(\n path,\n 'unknown_merge_field',\n `{{${key}}} isn't a merge field, so it will be sent empty.`,\n ),\n )\n}\n\nfunction delayIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachSequenceStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const delay = step.delayBusinessDays\n const followsAnEmail = isEmailStep(step) && index > firstEmailStepIndex(steps)\n const min = followsAnEmail ? OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS : 0\n if (Number.isInteger(delay) && delay >= min && delay <= OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS) {\n return []\n }\n const message =\n min > 0\n ? `Wait ${min}–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this email.`\n : `Wait 0–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this step.`\n return [error(`${path}.delayBusinessDays`, 'delay_invalid', message)]\n}\n\nfunction emailStepIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachEmailStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const first = firstEmailStepIndex(steps) === index\n const startsThread = !isInThreadEmailStep(steps, index)\n const subject = asText(step.subject)\n if (startsThread) {\n if (!subject.trim()) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_required',\n first\n ? 'The first email needs a subject.'\n : 'An email that starts a new thread needs a subject.',\n ),\n )\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_too_long',\n `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`,\n ),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(step.body)\n const templateId = asText(step.templateId).trim()\n if (body.trim() && templateId) {\n issues.push(\n error(\n `${path}.body`,\n 'body_and_template',\n 'Write the email here or pick a template, not both.',\n ),\n )\n } else if (!body.trim() && !templateId) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email, or pick a template.'))\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n if (body.trim()) {\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n if (first && !crmMergeFieldsIn(body).includes(OUTREACH_PERSONAL_LINE_FIELD)) {\n issues.push(\n warning(\n `${path}.body`,\n 'personal_line_unused',\n `The first email doesn't use {{${OUTREACH_PERSONAL_LINE_FIELD}}}, so the line written for each person won't appear.`,\n ),\n )\n }\n }\n return issues\n}\n\n/** The steps of a sequence: their count, their ids, and each one's own fields. */\nexport function validateOutreachSteps(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n path = 'steps',\n): OutreachValidationIssue[] {\n const list = Array.isArray(steps) ? steps : []\n const issues: OutreachValidationIssue[] = []\n if (!list.length) {\n issues.push(error(path, 'steps_required', 'Add at least one step.'))\n return issues\n }\n if (list.length > OUTREACH_MAX_STEPS) {\n issues.push(\n error(path, 'too_many_steps', `A sequence holds at most ${OUTREACH_MAX_STEPS} steps.`),\n )\n }\n const emailCount = list.filter(isEmailStep).length\n if (emailCount > OUTREACH_MAX_EMAIL_STEPS) {\n issues.push(\n error(\n path,\n 'too_many_email_steps',\n `A sequence sends at most ${OUTREACH_MAX_EMAIL_STEPS} emails to one person.`,\n ),\n )\n }\n const seen = new Set<string>()\n list.forEach((step, index) => {\n const stepPath = `${path}.${index}`\n const id = asText(step?.id).trim()\n if (!id) {\n issues.push(error(`${stepPath}.id`, 'step_id_required', 'Every step needs an id.'))\n } else if (seen.has(id)) {\n issues.push(error(`${stepPath}.id`, 'duplicate_step_id', 'Two steps share an id.'))\n }\n seen.add(id)\n if (isEmailStep(step)) {\n issues.push(...delayIssues(list, step, index, stepPath))\n issues.push(...emailStepIssues(list, step, index, stepPath))\n } else if (step?.kind === 'task') {\n issues.push(...delayIssues(list, step, index, stepPath))\n if (!(OUTREACH_TASK_KINDS as readonly string[]).includes(step.taskKind)) {\n issues.push(\n error(`${stepPath}.taskKind`, 'task_kind_invalid', 'Pick LinkedIn, Call or To-do.'),\n )\n }\n const title = asText(step.title)\n if (!title.trim()) {\n issues.push(error(`${stepPath}.title`, 'task_title_required', 'Give the task a title.'))\n } else if (title.length > OUTREACH_TASK_TITLE_MAX) {\n issues.push(\n error(\n `${stepPath}.title`,\n 'task_title_too_long',\n `Keep the title under ${OUTREACH_TASK_TITLE_MAX} characters.`,\n ),\n )\n }\n } else {\n issues.push(\n error(stepPath, 'unknown_step_kind', 'A step is an email or a task.'),\n )\n }\n })\n return issues\n}\n\n/** Everything a stored sequence has to be before it may be saved. */\nexport function validateOutreachSequence(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const name = asText(sequence?.name)\n if (!name.trim()) {\n issues.push(error('name', 'name_required', 'Name the sequence.'))\n } else if (name.length > OUTREACH_SEQUENCE_NAME_MAX) {\n issues.push(\n error('name', 'name_too_long', `Keep the name under ${OUTREACH_SEQUENCE_NAME_MAX} characters.`),\n )\n }\n const steps = sequence?.steps ?? []\n if (steps.some(isEmailStep) && !asText(sequence?.mailboxId).trim()) {\n issues.push(\n error('mailboxId', 'mailbox_required', 'Choose the mailbox this sequence sends from.'),\n )\n }\n issues.push(...validateOutreachSteps(steps))\n issues.push(...validateOutreachSequenceSettings(sequence?.settings))\n return issues\n}\n\n/**\n * The organization's footer fields: a legal name and a postal address, the\n * two things the law requires every commercial email to carry.\n */\nexport function validateOutreachOrgSettings(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n path = 'orgSettings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (!asText(settings?.legalName).trim()) {\n issues.push(\n error(\n `${path}.legalName`,\n 'legal_name_required',\n \"Add your organization's legal name in Sequences settings. It goes in every email's footer.\",\n ),\n )\n }\n if (!asText(settings?.postalAddress).trim()) {\n issues.push(\n error(\n `${path}.postalAddress`,\n 'postal_address_required',\n \"Add your organization's postal address in Sequences settings. The law requires one in every sales email.\",\n ),\n )\n }\n return issues\n}\n\n/**\n * What activating a sequence refuses on: every issue saving refuses on, and\n * an organization whose footer cannot be written yet.\n */\nexport function validateOutreachSequenceActivation(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n): OutreachValidationIssue[] {\n return [...validateOutreachSequence(sequence), ...validateOutreachOrgSettings(orgSettings)]\n}\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","CRM_MERGE_FIELDS","crmMergeFieldsIn","crmThreadSubject","OUTREACH_DEFAULT_ALLOWED_COUNTRIES","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS","OUTREACH_MAX_STEPS","OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS","OUTREACH_TASK_KINDS","OUTREACH_PERSONAL_LINE_FIELD","OUTREACH_MERGE_FIELDS","key","label","group","OUTREACH_SEQUENCE_NAME_MAX","OUTREACH_TASK_TITLE_MAX","hasOutreachValidationErrors","issues","some","issue","severity","error","path","code","message","warning","asText","value","isEmailStep","step","kind","firstEmailStepIndex","steps","findIndex","isInThreadEmailStep","index","first","replyInThread","outreachSubjectHasReplyPrefix","subject","collapsed","replace","trim","slice","WEEKDAY_NAMES","validateOutreachSendWindow","window","days","Array","isArray","daysValid","length","every","day","Number","isInteger","Set","size","push","start","startMinute","end","endMinute","hoursValid","normalizeOutreachAllowedCountries","codes","entry","toUpperCase","test","includes","readOutreachSequenceSettings","raw","data","countries","allowedCountries","allowCustomers","validateOutreachSequenceSettings","settings","forEach","KNOWN_MERGE_FIELDS","map","field","mergeFieldWarnings","text","filter","has","delayIssues","delay","delayBusinessDays","followsAnEmail","min","emailStepIssues","startsThread","body","templateId","toLocaleString","validateOutreachSteps","list","emailCount","seen","stepPath","id","add","taskKind","title","validateOutreachSequence","sequence","name","mailboxId","validateOutreachOrgSettings","legalName","postalAddress","validateOutreachSequenceActivation","orgSettings"],"mappings":"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"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type OutreachThreadMessage } from './thread-message';
18
+ export type OutreachMessageKind = 'self' | 'hard_bounce' | 'soft_bounce' | 'delivery_report' | 'auto_reply' | 'opt_out' | 'reply';
19
+ export interface OutreachBounceDetail {
20
+ /** The addresses the report says failed; empty when it names none. */
21
+ recipients: string[];
22
+ status: string | null;
23
+ diagnostic: string | null;
24
+ }
25
+ export interface OutreachMessageClassification {
26
+ messageId: string;
27
+ atMs: number;
28
+ kind: OutreachMessageKind;
29
+ /** The sender's address, normalized, when the `From` carried one. */
30
+ from: string | null;
31
+ /** Set on a bounce. */
32
+ bounce: OutreachBounceDetail | null;
33
+ /** An opt-out that calls the email spam. */
34
+ complaint: boolean;
35
+ /** What decided it, for the timeline and the log. */
36
+ evidence: string | null;
37
+ }
38
+ export interface OutreachMessageContext {
39
+ /** The mailbox's own addresses: the account's and every alias it sends as. */
40
+ selfAddresses: readonly string[];
41
+ /** The address a `List-Unsubscribe` mailto points at, when the runtime set one. */
42
+ unsubscribeAddress?: string | null;
43
+ /** The subject the thread was started with, so a new subject can be read on its own. */
44
+ threadSubject?: string | null;
45
+ }
46
+ /**
47
+ * Why a message is an automatic reply, or `null` when it is not one — see
48
+ * the module note for the headers and subjects read.
49
+ */
50
+ export declare function outreachAutoReplyEvidence(message: Pick<OutreachThreadMessage, 'headers' | 'subject'>): string | null;
51
+ /** What one message in an enrollment's thread is — see the module note. */
52
+ export declare function classifyOutreachMessage(message: OutreachThreadMessage, context: OutreachMessageContext): OutreachMessageClassification;
53
+ export type OutreachThreadOutcome = 'none' | 'postpone' | 'bounced' | 'replied' | 'opted_out';
54
+ export interface OutreachThreadDecisionInput extends OutreachMessageContext {
55
+ /** The thread's messages, in any order. */
56
+ messages: readonly OutreachThreadMessage[];
57
+ /** The enrollment's recipient. */
58
+ recipient: string;
59
+ /** Messages received at or before this instant were handled by an earlier run. */
60
+ afterMs?: number | null;
61
+ /** Ids of messages an earlier run handled. */
62
+ handledMessageIds?: readonly string[];
63
+ }
64
+ export interface OutreachThreadDecision {
65
+ outcome: OutreachThreadOutcome;
66
+ /** The message that decided the outcome; `null` for `none`. */
67
+ decidedBy: OutreachMessageClassification | null;
68
+ /** Every new message, classified, oldest first. */
69
+ classifications: OutreachMessageClassification[];
70
+ /** A new reply called the email spam: the mailbox is paused as well. */
71
+ complaint: boolean;
72
+ /** New hard bounces that report this recipient. */
73
+ hardBounces: number;
74
+ /** New soft bounces that report this recipient. */
75
+ softBounces: number;
76
+ }
77
+ /** What the new messages in an enrollment's thread mean for it — see the module note. */
78
+ export declare function decideOutreachThread(input: OutreachThreadDecisionInput): OutreachThreadDecision;
@@ -0,0 +1,224 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /*==========================================
18
+ * WHAT CAME BACK (AGL-2979).
19
+ *
20
+ * The runtime reads each enrollment's threads and asks what the new
21
+ * messages mean. Each message is one of:
22
+ *
23
+ * - `self` — the mailbox's own: a step, or the rep writing in the thread.
24
+ * - `hard_bounce` / `soft_bounce` — a delivery report (`./delivery-status.ts`).
25
+ * - `delivery_report` — a mail system's message that reports no failure.
26
+ * - `auto_reply` — an out-of-office or an autoresponder. It POSTPONES the
27
+ * next step and does not stop the sequence: nobody answered.
28
+ * - `opt_out` — a reply that asks to be left alone (`./opt-out-intent.ts`),
29
+ * or a message sent to the unsubscribe address.
30
+ * - `reply` — any other message from anybody else.
31
+ *
32
+ * ## Automatic replies
33
+ *
34
+ * Read from the headers the standards give an autoresponder —
35
+ * `Auto-Submitted` with any value but `no` (RFC 3834), `X-Autoreply`,
36
+ * `X-Autorespond`, `Precedence: auto_reply`, `bulk` or `junk` — and, for
37
+ * the servers that set none, from an out-of-office subject in the usual
38
+ * languages.
39
+ *
40
+ * ## The thread's outcome
41
+ *
42
+ * `opted_out` outranks `replied`, which outranks `bounced`, which outranks
43
+ * `postpone`: an opt-out is a reply that also has to reach the do-not-contact
44
+ * list, a person who wrote back matters more than a report about an earlier
45
+ * send, and an out-of-office only matters when nothing else happened. The
46
+ * bounces that report this recipient are counted either way, because mailbox
47
+ * health counts every one.
48
+ *==========================================*/ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
49
+ import { crmThreadSubject, emailAddressOf, htmlToPlainText } from "@aglyn/aglyn/app-utils/crm-inbound";
50
+ import { readOutreachDeliveryReport, isMailerDaemonMessage } from "./delivery-status.js";
51
+ import { detectOutreachOptOutIntent, detectOutreachOptOutSubject } from "./opt-out-intent.js";
52
+ import { outreachHeader } from "./thread-message.js";
53
+ const AUTO_REPLY_SUBJECT = new RegExp([
54
+ '^\\s*\\[?\\s*(',
55
+ 'auto(matic)?[\\s-]*(reply|response|responder|answer)',
56
+ '|out[\\s-]+of[\\s-]+(the[\\s-]+)?office',
57
+ '|ooo\\b',
58
+ "|i('m| am) (currently )?(out of the office|away|on (vacation|leave|holiday))",
59
+ '|(on )?(vacation|holiday|annual leave)[\\s-]*(reply|response|notice|message|autoreply)',
60
+ '|away from (the |my )?(office|desk)',
61
+ '|abwesenheitsnotiz|abwesenheit',
62
+ '|r[ée]ponse automatique|absence du bureau',
63
+ '|respuesta autom[áa]tica|fuera de la oficina',
64
+ '|risposta automatica|fuori ufficio',
65
+ '|automatisch antwoord|afwezigheidsbericht',
66
+ '|resposta autom[áa]tica|fora do escrit[óo]rio',
67
+ '|autosvar|automaattinen vastaus',
68
+ ')'
69
+ ].join(''), 'i');
70
+ /**
71
+ * Why a message is an automatic reply, or `null` when it is not one — see
72
+ * the module note for the headers and subjects read.
73
+ */ export function outreachAutoReplyEvidence(message) {
74
+ var _ref;
75
+ const autoSubmitted = outreachHeader(message, 'Auto-Submitted').split(';')[0].trim().toLowerCase();
76
+ if (autoSubmitted && autoSubmitted !== 'no') return `Auto-Submitted: ${autoSubmitted}`;
77
+ for (const name of [
78
+ 'X-Autoreply',
79
+ 'X-Autorespond'
80
+ ]){
81
+ const value = outreachHeader(message, name).trim();
82
+ if (value && value.toLowerCase() !== 'no') return `${name}: ${value}`;
83
+ }
84
+ const precedence = outreachHeader(message, 'Precedence').trim().toLowerCase();
85
+ if ([
86
+ 'auto_reply',
87
+ 'bulk',
88
+ 'junk'
89
+ ].includes(precedence)) return `Precedence: ${precedence}`;
90
+ const subject = String((_ref = message == null ? void 0 : message.subject) != null ? _ref : '').replace(/^\s*(re|fwd?)\s*:\s*/i, '');
91
+ const match = AUTO_REPLY_SUBJECT.exec(subject);
92
+ return match ? `Subject: ${match[0].trim()}` : null;
93
+ }
94
+ function addressesIn(header) {
95
+ return header.split(',').map((part)=>emailAddressOf(part)).filter((address)=>address !== null);
96
+ }
97
+ function readableText(message) {
98
+ var _message_snippet;
99
+ if (message.textBody && message.textBody.trim()) return message.textBody;
100
+ if (message.htmlBody && message.htmlBody.trim()) return htmlToPlainText(message.htmlBody);
101
+ return (_message_snippet = message.snippet) != null ? _message_snippet : '';
102
+ }
103
+ /** What one message in an enrollment's thread is — see the module note. */ export function classifyOutreachMessage(message, context) {
104
+ var _message_id, _context_selfAddresses, _message_to, _message_subject;
105
+ const from = emailAddressOf(message.from);
106
+ const base = {
107
+ messageId: String((_message_id = message.id) != null ? _message_id : ''),
108
+ atMs: Number.isFinite(message.internalDateMs) ? message.internalDateMs : 0,
109
+ from,
110
+ bounce: null,
111
+ complaint: false,
112
+ evidence: null
113
+ };
114
+ const report = readOutreachDeliveryReport(message);
115
+ if (report) {
116
+ if (!report.kind) return _extends({}, base, {
117
+ kind: 'delivery_report',
118
+ evidence: 'delivery report'
119
+ });
120
+ return _extends({}, base, {
121
+ kind: report.kind === 'hard' ? 'hard_bounce' : 'soft_bounce',
122
+ bounce: {
123
+ recipients: report.failedAddresses,
124
+ status: report.status,
125
+ diagnostic: report.diagnostic
126
+ },
127
+ evidence: report.status ? `Status: ${report.status}` : 'delivery report'
128
+ });
129
+ }
130
+ const self = new Set(((_context_selfAddresses = context.selfAddresses) != null ? _context_selfAddresses : []).map((address)=>normalizeContactEmail(address)).filter((address)=>address !== null));
131
+ if (from && self.has(from)) return _extends({}, base, {
132
+ kind: 'self'
133
+ });
134
+ if (isMailerDaemonMessage(message)) {
135
+ return _extends({}, base, {
136
+ kind: 'delivery_report',
137
+ evidence: 'mail system message'
138
+ });
139
+ }
140
+ const unsubscribe = normalizeContactEmail(context.unsubscribeAddress);
141
+ if (unsubscribe && addressesIn((_message_to = message.to) != null ? _message_to : '').includes(unsubscribe)) {
142
+ return _extends({}, base, {
143
+ kind: 'opt_out',
144
+ evidence: `To: ${unsubscribe}`
145
+ });
146
+ }
147
+ const autoReply = outreachAutoReplyEvidence(message);
148
+ if (autoReply) return _extends({}, base, {
149
+ kind: 'auto_reply',
150
+ evidence: autoReply
151
+ });
152
+ const intent = detectOutreachOptOutIntent(readableText(message));
153
+ if (intent.optOut) {
154
+ return _extends({}, base, {
155
+ kind: 'opt_out',
156
+ complaint: intent.complaint,
157
+ evidence: intent.matched
158
+ });
159
+ }
160
+ // A subject is read only on a message that starts fresh — no `Re:`, and
161
+ // not the thread's own — and only when it says nothing but stop.
162
+ const subject = String((_message_subject = message.subject) != null ? _message_subject : '').replace(/\s+/g, ' ').trim();
163
+ const fresh = crmThreadSubject(subject) === subject;
164
+ if (subject && fresh && subject !== crmThreadSubject(context.threadSubject)) {
165
+ const subjectIntent = detectOutreachOptOutSubject(subject);
166
+ if (subjectIntent.optOut) {
167
+ return _extends({}, base, {
168
+ kind: 'opt_out',
169
+ evidence: `Subject: ${subjectIntent.matched}`
170
+ });
171
+ }
172
+ }
173
+ return _extends({}, base, {
174
+ kind: 'reply'
175
+ });
176
+ }
177
+ const RANK = {
178
+ none: 0,
179
+ postpone: 1,
180
+ bounced: 2,
181
+ replied: 3,
182
+ opted_out: 4
183
+ };
184
+ const OUTCOME_OF = {
185
+ auto_reply: 'postpone',
186
+ hard_bounce: 'bounced',
187
+ reply: 'replied',
188
+ opt_out: 'opted_out'
189
+ };
190
+ /** What the new messages in an enrollment's thread mean for it — see the module note. */ export function decideOutreachThread(input) {
191
+ var _input_handledMessageIds, _input_messages;
192
+ const recipient = normalizeContactEmail(input.recipient);
193
+ const handled = new Set((_input_handledMessageIds = input.handledMessageIds) != null ? _input_handledMessageIds : []);
194
+ const afterMs = typeof input.afterMs === 'number' ? input.afterMs : null;
195
+ const fresh = [
196
+ ...(_input_messages = input.messages) != null ? _input_messages : []
197
+ ].filter((message)=>message && !handled.has(message.id)).filter((message)=>afterMs === null || message.internalDateMs > afterMs).sort((a, b)=>a.internalDateMs - b.internalDateMs);
198
+ const decision = {
199
+ outcome: 'none',
200
+ decidedBy: null,
201
+ classifications: [],
202
+ complaint: false,
203
+ hardBounces: 0,
204
+ softBounces: 0
205
+ };
206
+ for (const message of fresh){
207
+ const classification = classifyOutreachMessage(message, input);
208
+ decision.classifications.push(classification);
209
+ const bounce = classification.bounce;
210
+ const aboutRecipient = !bounce || !bounce.recipients.length || recipient !== null && bounce.recipients.includes(recipient);
211
+ if (classification.kind === 'hard_bounce' && aboutRecipient) decision.hardBounces += 1;
212
+ if (classification.kind === 'soft_bounce' && aboutRecipient) decision.softBounces += 1;
213
+ if (classification.complaint) decision.complaint = true;
214
+ const outcome = OUTCOME_OF[classification.kind];
215
+ if (!outcome || classification.kind === 'hard_bounce' && !aboutRecipient) continue;
216
+ if (RANK[outcome] > RANK[decision.outcome]) {
217
+ decision.outcome = outcome;
218
+ decision.decidedBy = classification;
219
+ }
220
+ }
221
+ return decision;
222
+ }
223
+
224
+ //# sourceMappingURL=thread-classification.js.map