@oxygen-agent/cli 1.365.3 → 1.575.19

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 (99) hide show
  1. package/README.md +1 -1
  2. package/dist/column-run-notices.d.ts +11 -0
  3. package/dist/column-run-notices.js +37 -0
  4. package/dist/command-manifest.js +13 -8
  5. package/dist/help.js +78 -16
  6. package/dist/index.js +3873 -514
  7. package/dist/skills.js +106 -1
  8. package/node_modules/@oxygen/formula/dist/coerce.d.ts +8 -0
  9. package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
  10. package/node_modules/@oxygen/formula/dist/evaluate.d.ts +31 -0
  11. package/node_modules/@oxygen/formula/dist/evaluate.js +248 -0
  12. package/node_modules/@oxygen/formula/dist/expression.d.ts +64 -0
  13. package/node_modules/@oxygen/formula/dist/expression.js +428 -0
  14. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +71 -0
  15. package/node_modules/@oxygen/formula/dist/formula-functions.js +1100 -0
  16. package/node_modules/@oxygen/formula/dist/index.d.ts +17 -0
  17. package/node_modules/@oxygen/formula/dist/index.js +17 -0
  18. package/node_modules/@oxygen/formula/dist/value-normalizers.d.ts +30 -0
  19. package/node_modules/@oxygen/formula/dist/value-normalizers.js +80 -0
  20. package/node_modules/@oxygen/formula/package.json +26 -0
  21. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +30 -0
  22. package/node_modules/@oxygen/recipe-sdk/dist/index.js +2 -2
  23. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +60 -0
  24. package/node_modules/@oxygen/shared/dist/billing-anchors.js +135 -0
  25. package/node_modules/@oxygen/shared/dist/billing.d.ts +101 -5
  26. package/node_modules/@oxygen/shared/dist/billing.js +192 -8
  27. package/node_modules/@oxygen/shared/dist/call-outcomes.d.ts +59 -0
  28. package/node_modules/@oxygen/shared/dist/call-outcomes.js +73 -0
  29. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  30. package/node_modules/@oxygen/shared/dist/credit-guidance.js +3 -1
  31. package/node_modules/@oxygen/shared/dist/crm-reply-events.d.ts +35 -0
  32. package/node_modules/@oxygen/shared/dist/crm-reply-events.js +31 -0
  33. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.d.ts +50 -0
  34. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.js +65 -0
  35. package/node_modules/@oxygen/shared/dist/directory.d.ts +1 -1
  36. package/node_modules/@oxygen/shared/dist/directory.js +1 -0
  37. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +17 -1
  38. package/node_modules/@oxygen/shared/dist/hosted-ai.js +52 -3
  39. package/node_modules/@oxygen/shared/dist/index.d.ts +11 -0
  40. package/node_modules/@oxygen/shared/dist/index.js +15 -0
  41. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +77 -0
  42. package/node_modules/@oxygen/shared/dist/langfuse.js +231 -0
  43. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +31 -0
  44. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +56 -0
  45. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -4
  46. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +5 -4
  47. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +22 -0
  48. package/node_modules/@oxygen/shared/dist/linkedin-url.js +7 -4
  49. package/node_modules/@oxygen/shared/dist/log.js +56 -4
  50. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.d.ts +7 -0
  51. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +29 -0
  52. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +31 -0
  53. package/node_modules/@oxygen/shared/dist/object-storage.js +61 -0
  54. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +636 -0
  55. package/node_modules/@oxygen/shared/dist/plan-limits.js +199 -0
  56. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +89 -23
  57. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +88 -24
  58. package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +291 -0
  59. package/node_modules/@oxygen/shared/dist/sequence-crm-events.js +224 -0
  60. package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +42 -1
  61. package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
  62. package/node_modules/@oxygen/shared/dist/sequences.d.ts +287 -24
  63. package/node_modules/@oxygen/shared/dist/sequences.js +940 -60
  64. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +70 -0
  65. package/node_modules/@oxygen/shared/dist/spend-safety.js +106 -0
  66. package/node_modules/@oxygen/shared/dist/tags.d.ts +90 -1
  67. package/node_modules/@oxygen/shared/dist/tags.js +126 -6
  68. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  69. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  70. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +1 -1
  71. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +4 -0
  72. package/node_modules/@oxygen/shared/dist/workspace-agents.d.ts +8 -7
  73. package/node_modules/@oxygen/shared/dist/workspace-agents.js +34 -7
  74. package/node_modules/@oxygen/shared/package.json +95 -0
  75. package/node_modules/@oxygen/workflows/dist/event-dispatch.d.ts +126 -0
  76. package/node_modules/@oxygen/workflows/dist/event-dispatch.js +173 -0
  77. package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +78 -0
  78. package/node_modules/@oxygen/workflows/dist/graph/expression.js +700 -0
  79. package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +20 -0
  80. package/node_modules/@oxygen/workflows/dist/graph/index.js +20 -0
  81. package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +4 -0
  82. package/node_modules/@oxygen/workflows/dist/graph/lint.js +812 -0
  83. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +501 -0
  84. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +200 -0
  85. package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +86 -0
  86. package/node_modules/@oxygen/workflows/dist/graph/params.js +173 -0
  87. package/node_modules/@oxygen/workflows/dist/graph/remap.d.ts +48 -0
  88. package/node_modules/@oxygen/workflows/dist/graph/remap.js +213 -0
  89. package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +46 -0
  90. package/node_modules/@oxygen/workflows/dist/graph/topology.js +280 -0
  91. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +270 -0
  92. package/node_modules/@oxygen/workflows/dist/graph/types.js +93 -0
  93. package/node_modules/@oxygen/workflows/dist/index.d.ts +113 -1
  94. package/node_modules/@oxygen/workflows/dist/index.js +179 -13
  95. package/node_modules/@oxygen/workflows/dist/tool-effects.d.ts +1 -0
  96. package/node_modules/@oxygen/workflows/dist/tool-effects.js +19 -0
  97. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +135 -4
  98. package/node_modules/@oxygen/workflows/package.json +4 -0
  99. package/package.json +10 -5
@@ -1,5 +1,5 @@
1
1
  import { OxygenError } from "./cli-result.js";
2
- import { hashVariantKey, renderTemplate, templateColumnKeys, } from "./sequence-template.js";
2
+ import { hashVariantKey, renderTemplate, spintaxSyntaxIssues, templateColumnKeys, } from "./sequence-template.js";
3
3
  /**
4
4
  * Multichannel sequence DSL — the shared contract validated identically by CLI,
5
5
  * MCP, API, and web. A sequence is an ordered list of steps applied to each
@@ -28,7 +28,7 @@ import { hashVariantKey, renderTemplate, templateColumnKeys, } from "./sequence-
28
28
  * and inbox rotation while Oxygen owns the cross-channel timeline + reply
29
29
  * suppression.
30
30
  */
31
- export const SEQUENCE_CHANNELS = ["linkedin", "email", "whatsapp"];
31
+ export const SEQUENCE_CHANNELS = ["linkedin", "email", "whatsapp", "call", "crm"];
32
32
  /**
33
33
  * Signals an enrollment accumulates from provider webhooks. wait_for_signal gates
34
34
  * and signal-branch conditions read these. linkedin_connected also advances the
@@ -53,6 +53,15 @@ export const SEQUENCE_SIGNALS = [
53
53
  // like email_bounced. branch / wait_for_signal consume both like any other signal.
54
54
  "meeting_booked",
55
55
  "email_unsubscribed",
56
+ // Call outcomes, recorded when a rep dispositions a call_task. Unlike a reply,
57
+ // NONE of these is terminal: "no answer" is the common case and the sequence is
58
+ // expected to continue (email follow-up, retry later), so a branch can route on
59
+ // them and later steps still run. call_connected covers every disposition where
60
+ // a live conversation happened, including negative ones — the enrollment should
61
+ // take the "we spoke to them" arm whether the answer was yes or no.
62
+ "call_connected",
63
+ "call_no_answer",
64
+ "call_voicemail",
56
65
  // External GTM signals — recorded onto an enrollment by an Oxygen monitor,
57
66
  // enrichment column, or the published `sequences signal` callable (NOT a
58
67
  // provider webhook). branch / wait_for_signal consume these identically to
@@ -80,6 +89,33 @@ export const SEQUENCE_NATIVE_UNTRACKED_SIGNALS = ["email_opened", "email_clicked
80
89
  function isNativeUntrackedSignal(value) {
81
90
  return SEQUENCE_NATIVE_UNTRACKED_SIGNALS.includes(value);
82
91
  }
92
+ /**
93
+ * Signals that ALWAYS terminalize the enrollment the moment they fire, so no
94
+ * later step can ever observe them. A reply on any channel unconditionally stops
95
+ * the replying lead's enrollment (`status: 'replied'`, reason `lead_replied` —
96
+ * see the LinkedIn / WhatsApp / email inbound paths), and the one-click
97
+ * List-Unsubscribe webhook stops it too (`unsubscribed_one_click`).
98
+ *
99
+ * They are still RECORDED on the enrollment (analytics, the timeline, and the
100
+ * reply-rate rollups all read them), but a branch or wait_for_signal gated on one
101
+ * is dead code: the enrollment is already terminal when the signal lands, so the
102
+ * gate always times out / always takes the `else` arm. The builder therefore does
103
+ * not offer them as branch or gate conditions — reply-stop is built in.
104
+ */
105
+ export const SEQUENCE_TERMINAL_SIGNALS = [
106
+ "linkedin_replied",
107
+ "whatsapp_replied",
108
+ "email_replied",
109
+ "email_unsubscribed",
110
+ ];
111
+ export function isTerminalSequenceSignal(value) {
112
+ return SEQUENCE_TERMINAL_SIGNALS.includes(value);
113
+ }
114
+ /**
115
+ * The signals a branch / wait_for_signal condition can meaningfully read — every
116
+ * signal except the terminal ones above. This is what authoring surfaces offer.
117
+ */
118
+ export const SEQUENCE_BRANCHABLE_SIGNALS = SEQUENCE_SIGNALS.filter((signal) => !isTerminalSequenceSignal(signal));
83
119
  /** External GTM signals (the non-engagement subset of SEQUENCE_SIGNALS). */
84
120
  export const SEQUENCE_EXTERNAL_SIGNALS = [
85
121
  "company_hiring",
@@ -106,8 +142,12 @@ export const SEQUENCE_EMAIL_COLUMN_KEYS = ["email", "email_address", "work_email
106
142
  * complaint loop (the address a bounce/opt-out suppresses), so they can't drift.
107
143
  * Returns the trimmed raw value (callers normalize/lowercase as needed) or null.
108
144
  */
109
- export function recipientEmailFromRow(rowValues) {
110
- for (const key of SEQUENCE_EMAIL_COLUMN_KEYS) {
145
+ export function recipientEmailFromRow(rowValues, emailColumnKey) {
146
+ // An explicit per-sequence email column (settings.email_column_key) wins so a
147
+ // non-English / non-standard header (e.g. "E-Mail") resolves; the built-in
148
+ // English keys stay as fallbacks when no column is mapped.
149
+ const keys = emailColumnKey ? [emailColumnKey, ...SEQUENCE_EMAIL_COLUMN_KEYS] : [...SEQUENCE_EMAIL_COLUMN_KEYS];
150
+ for (const key of keys) {
111
151
  const value = rowValues?.[key];
112
152
  if (typeof value === "string" && value.includes("@"))
113
153
  return value.trim();
@@ -119,14 +159,19 @@ export function recipientEmailFromRow(rowValues) {
119
159
  * Deliverability lore: a send lands better when the SENDING mailbox and the
120
160
  * RECIPIENT sit on the same provider (Google→Google, Microsoft→Microsoft), so at
121
161
  * mailbox-pick the dispatcher can bias rotation toward a same-ESP mailbox.
122
- * - "off" — no ESP bias (default); rotation is pure LRU / domain-spread.
123
- * - "prefer" — pick a same-ESP mailbox when one is available, else fall back to
124
- * any sendable mailbox (never blocks a send).
162
+ * - "off" — no ESP bias; rotation is pure LRU / domain-spread.
163
+ * - "prefer" — (DEFAULT) pick a same-ESP mailbox when one is available, else
164
+ * fall back to any sendable mailbox (never blocks a send). On by default
165
+ * because it only ever improves deliverability and cannot starve sends.
125
166
  * - "strict" — require a same-ESP mailbox; when none is available the action is
126
167
  * DEFERRED with a visible skipped_reason rather than sent cross-provider.
168
+ * Power-user setting, reachable via CLI/MCP; the web "Provider Matching"
169
+ * checkbox toggles off↔prefer only.
127
170
  */
128
171
  export const ESP_MATCHING_MODES = ["off", "prefer", "strict"];
129
- export const DEFAULT_ESP_MATCHING_MODE = "off";
172
+ // Provider Matching is ON by default (prefer): a same-provider send lands better,
173
+ // and prefer never blocks — an absent setting biases mailbox pick without risk.
174
+ export const DEFAULT_ESP_MATCHING_MODE = "prefer";
130
175
  export function isEspMatchingMode(value) {
131
176
  return typeof value === "string" && ESP_MATCHING_MODES.includes(value);
132
177
  }
@@ -145,14 +190,111 @@ export function validateEspMatchingSetting(value) {
145
190
  }
146
191
  }
147
192
  /**
148
- * The email-provider FAMILY a recipient (or sending mailbox) routes through. Kept
149
- * intentionally aligned with EMAIL_MAILBOX_PROVIDERS in tenant-db so a mailbox's
150
- * `provider` and an inferred recipient ESP compare directly.
193
+ * The MATCHABLE email-provider a recipient routes through — the narrow set ESP
194
+ * matching can act on. Kept intentionally aligned with EMAIL_MAILBOX_PROVIDERS in
195
+ * tenant-db so a mailbox's `provider` and a resolved recipient ESP compare
196
+ * directly: we can only bias rotation toward a provider we can actually SEND
197
+ * from, and the pool is Google/Microsoft only.
198
+ *
199
+ * This is deliberately NARROWER than RECIPIENT_ESP_FAMILIES (what we DISPLAY).
200
+ * A domain behind Proofpoint has family "proofpoint" but may still be matchable
201
+ * as "microsoft" — see resolveEspFamily.
151
202
  */
152
203
  export const RECIPIENT_ESPS = ["google", "microsoft"];
153
- /** Consumer domains that deterministically route to Google / Microsoft mail. */
154
- const GOOGLE_CONSUMER_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
155
- const MICROSOFT_CONSUMER_DOMAINS = new Set(["outlook.com", "hotmail.com", "live.com", "msn.com"]);
204
+ /**
205
+ * The DISPLAY taxonomy: the mail-infrastructure family a recipient domain sits
206
+ * on. Wider than RecipientEsp because "everything that isn't Google or Microsoft"
207
+ * is not one thing — a list that is 23% Proofpoint is a very different
208
+ * deliverability problem from one that is 23% self-hosted, and collapsing both to
209
+ * null (today's behavior) hides that. Split into three groups:
210
+ *
211
+ * - MAILBOX providers (google, microsoft) — imply a matchable ESP.
212
+ * - GATEWAY vendors (proofpoint, mimecast, ...) — security filters that sit IN
213
+ * FRONT of a mailbox provider. Their MX tells you the filter, NOT where the
214
+ * mailbox lives; the SPF fallback is what sees behind them.
215
+ * - OTHER mailbox hosts (zoho, yahoo, fastmail, ...) and self_hosted — real
216
+ * destinations we simply cannot send as, so never matchable.
217
+ */
218
+ export const RECIPIENT_ESP_FAMILIES = [
219
+ "google",
220
+ "microsoft",
221
+ "proofpoint",
222
+ "mimecast",
223
+ "barracuda",
224
+ "cisco",
225
+ "zscaler",
226
+ "trendmicro",
227
+ "sophos",
228
+ "zoho",
229
+ "yahoo",
230
+ "apple",
231
+ "proton",
232
+ "fastmail",
233
+ "gmx",
234
+ "amazon",
235
+ "self_hosted",
236
+ ];
237
+ export function isRecipientEspFamily(value) {
238
+ return typeof value === "string" && RECIPIENT_ESP_FAMILIES.includes(value);
239
+ }
240
+ /**
241
+ * Families that are inbound SECURITY GATEWAYS rather than mailbox hosts. Seeing
242
+ * one in MX means the real mailbox provider is hidden behind it, so the resolver
243
+ * must fall through to the SPF signal instead of concluding "not Google/Microsoft".
244
+ * This is the single most important distinction in this file: treating a
245
+ * Proofpoint MX as "no known ESP" is how a Microsoft-hosted list silently loses
246
+ * ESP matching.
247
+ */
248
+ const GATEWAY_FAMILIES = new Set([
249
+ "proofpoint",
250
+ "mimecast",
251
+ "barracuda",
252
+ "cisco",
253
+ "zscaler",
254
+ "trendmicro",
255
+ "sophos",
256
+ ]);
257
+ export function isEspGatewayFamily(family) {
258
+ return family !== null && family !== undefined && GATEWAY_FAMILIES.has(family);
259
+ }
260
+ /**
261
+ * The matchable ESP a display family implies, or null when the family is a
262
+ * gateway (unknown until SPF) or a provider we cannot send as. The ONLY place
263
+ * family → esp is decided, so display and matching can't drift.
264
+ */
265
+ export function matchableEspFromFamily(family) {
266
+ if (family === "google" || family === "microsoft")
267
+ return family;
268
+ return null;
269
+ }
270
+ /**
271
+ * Consumer/freemail domains that resolve deterministically from the domain string
272
+ * with NO network call. Only google/microsoft are matchable; the rest are display
273
+ * families (a list heavy on these is a list-quality signal worth surfacing).
274
+ */
275
+ const CONSUMER_DOMAIN_FAMILIES = new Map([
276
+ ["gmail.com", "google"],
277
+ ["googlemail.com", "google"],
278
+ ["outlook.com", "microsoft"],
279
+ ["hotmail.com", "microsoft"],
280
+ ["live.com", "microsoft"],
281
+ ["msn.com", "microsoft"],
282
+ ["yahoo.com", "yahoo"],
283
+ ["yahoo.co.uk", "yahoo"],
284
+ ["ymail.com", "yahoo"],
285
+ ["aol.com", "yahoo"],
286
+ ["icloud.com", "apple"],
287
+ ["me.com", "apple"],
288
+ ["mac.com", "apple"],
289
+ ["proton.me", "proton"],
290
+ ["protonmail.com", "proton"],
291
+ ["pm.me", "proton"],
292
+ ["fastmail.com", "fastmail"],
293
+ ["zoho.com", "zoho"],
294
+ ["gmx.com", "gmx"],
295
+ ["gmx.de", "gmx"],
296
+ ["web.de", "gmx"],
297
+ ]);
156
298
  /**
157
299
  * Normalize a recipient domain: lowercase, trim, drop a trailing FQDN dot, and if
158
300
  * a full address is passed take the part after the last '@'. Returns "" for junk.
@@ -167,32 +309,57 @@ function normalizeRecipientDomain(domain) {
167
309
  return d.replace(/\.+$/, "");
168
310
  }
169
311
  /**
170
- * Infer a recipient's ESP from its email domain, DETERMINISTICALLY, for the
171
- * well-known consumer domains (gmail/googlemail → google; outlook/hotmail/live/
172
- * msn → microsoft). Returns null for any other domain — the caller then resolves
173
- * the ESP via a cached MX lookup (custom domains fronted by Google Workspace /
174
- * Microsoft 365 aren't decidable from the domain string alone). Pure and
175
- * side-effect-free so it's the single source of truth for the deterministic map.
312
+ * Does `host` sit at or under `suffix`? Requires a DOT boundary, so "google.com"
313
+ * matches "aspmx.l.google.com" and itself but NOT "notgoogle.com" — a bare
314
+ * endsWith() would map an unrelated lookalike domain onto a real ESP family and,
315
+ * under strict matching, route a send at the wrong pool.
176
316
  */
177
- export function inferRecipientEsp(domain) {
178
- const d = normalizeRecipientDomain(domain);
179
- if (!d)
180
- return null;
181
- if (GOOGLE_CONSUMER_DOMAINS.has(d))
182
- return "google";
183
- if (MICROSOFT_CONSUMER_DOMAINS.has(d))
184
- return "microsoft";
185
- return null;
317
+ function hostMatchesSuffix(host, suffix) {
318
+ return host === suffix || host.endsWith(`.${suffix}`);
186
319
  }
187
320
  /**
188
- * Map a domain's resolved MX exchange hostnames to an ESP family, for the MX-cache
189
- * path (custom domains on Google Workspace / Microsoft 365). Google MX hosts end
190
- * in `google.com` / `googlemail.com` (e.g. aspmx.l.google.com); Microsoft 365 MX
191
- * hosts end in `outlook.com` / `protection.outlook.com` (e.g.
192
- * acme-com.mail.protection.outlook.com). Returns null when no host matches either
193
- * family. Pure so both the resolver and its tests share one mapping.
321
+ * MX exchange-host suffix → display family, most specific first. Every entry is
322
+ * the vendor's own mail-routing domain (what shows up in a real MX answer):
323
+ * Google Workspace publishes aspmx.l.google.com, Microsoft 365 publishes
324
+ * <tenant>.mail.protection.outlook.com, Proofpoint pphosted.com, and so on.
194
325
  */
195
- export function espFromMxHosts(hosts) {
326
+ const MX_FAMILY_SUFFIXES = [
327
+ ["protection.outlook.com", "microsoft"],
328
+ ["outlook.com", "microsoft"],
329
+ ["google.com", "google"],
330
+ ["googlemail.com", "google"],
331
+ ["pphosted.com", "proofpoint"],
332
+ ["ppe-hosted.com", "proofpoint"],
333
+ ["mimecast.com", "mimecast"],
334
+ ["mimecast.co.za", "mimecast"],
335
+ ["mimecast-offshore.com", "mimecast"],
336
+ ["barracudanetworks.com", "barracuda"],
337
+ ["barracuda.com", "barracuda"],
338
+ ["iphmx.com", "cisco"],
339
+ ["cisco.com", "cisco"],
340
+ ["zscaler.net", "zscaler"],
341
+ ["zscalerthree.net", "zscaler"],
342
+ ["trendmicro.com", "trendmicro"],
343
+ ["trendmicro.eu", "trendmicro"],
344
+ ["sophos.com", "sophos"],
345
+ ["zoho.com", "zoho"],
346
+ ["zoho.eu", "zoho"],
347
+ ["yahoodns.net", "yahoo"],
348
+ ["icloud.com", "apple"],
349
+ ["protonmail.ch", "proton"],
350
+ ["proton.me", "proton"],
351
+ ["messagingengine.com", "fastmail"],
352
+ ["gmx.net", "gmx"],
353
+ ["amazonaws.com", "amazon"],
354
+ ["awsapps.com", "amazon"],
355
+ ];
356
+ /**
357
+ * Map a domain's resolved MX exchange hostnames to a DISPLAY family. Returns null
358
+ * when no host matches any known vendor — the caller records that as
359
+ * "self_hosted" only once it is sure the lookup itself succeeded (an empty/failed
360
+ * answer is "unknown", which is a different fact).
361
+ */
362
+ export function familyFromMxHosts(hosts) {
196
363
  if (!Array.isArray(hosts))
197
364
  return null;
198
365
  for (const raw of hosts) {
@@ -201,13 +368,92 @@ export function espFromMxHosts(hosts) {
201
368
  const host = raw.trim().toLowerCase().replace(/\.+$/, "");
202
369
  if (!host)
203
370
  continue;
204
- if (host.endsWith("google.com") || host.endsWith("googlemail.com"))
205
- return "google";
206
- if (host.endsWith("outlook.com") || host.endsWith("protection.outlook.com"))
207
- return "microsoft";
371
+ for (const [suffix, family] of MX_FAMILY_SUFFIXES) {
372
+ if (hostMatchesSuffix(host, suffix))
373
+ return family;
374
+ }
208
375
  }
209
376
  return null;
210
377
  }
378
+ /**
379
+ * SPF `include:` targets that identify the ESP a domain SENDS through — the
380
+ * signal that sees behind an inbound security gateway. An org filtering inbound
381
+ * mail through Proofpoint still relays its own outbound via Microsoft 365, and
382
+ * says so in SPF, so this recovers a matchable ESP that MX alone cannot.
383
+ */
384
+ const SPF_INCLUDE_ESPS = [
385
+ ["spf.protection.outlook.com", "microsoft"],
386
+ ["spf.protection.office365.us", "microsoft"],
387
+ ["_spf.google.com", "google"],
388
+ ["_spf.googlemail.com", "google"],
389
+ ["aspmx.googlemail.com", "google"],
390
+ ];
391
+ /**
392
+ * Resolve the matchable ESP from a domain's TXT records by reading its SPF
393
+ * mechanisms. Scans every `v=spf1` record's include:/redirect= targets.
394
+ *
395
+ * AMBIGUITY IS NOT GUESSED: a domain mid-migration (or running Workspace
396
+ * alongside an Office tenant) can include BOTH vendors. Returning either one
397
+ * would be a coin flip that strict matching then acts on, so a both-match
398
+ * degrades to null ("unknown") and the caller falls back / defers honestly.
399
+ */
400
+ export function espFromSpfRecords(records) {
401
+ if (!Array.isArray(records))
402
+ return null;
403
+ const found = new Set();
404
+ for (const raw of records) {
405
+ if (typeof raw !== "string")
406
+ continue;
407
+ const record = raw.trim().toLowerCase().replace(/^"|"$/g, "");
408
+ if (!record.startsWith("v=spf1"))
409
+ continue;
410
+ for (const term of record.split(/\s+/)) {
411
+ const target = term.startsWith("include:")
412
+ ? term.slice("include:".length)
413
+ : term.startsWith("redirect=")
414
+ ? term.slice("redirect=".length)
415
+ : null;
416
+ if (!target)
417
+ continue;
418
+ const host = target.replace(/\.+$/, "");
419
+ for (const [suffix, esp] of SPF_INCLUDE_ESPS) {
420
+ if (hostMatchesSuffix(host, suffix))
421
+ found.add(esp);
422
+ }
423
+ }
424
+ }
425
+ return found.size === 1 ? [...found][0] : null;
426
+ }
427
+ /**
428
+ * Infer a recipient's DISPLAY family from its domain string alone,
429
+ * DETERMINISTICALLY, for the well-known consumer/freemail domains. Returns null
430
+ * for any other domain — the caller then resolves via the cached MX (+ SPF)
431
+ * lookup, because a custom domain fronted by Workspace / M365 / a gateway is not
432
+ * decidable from the string. Pure: the single source of truth for the map.
433
+ */
434
+ export function inferRecipientEspFamily(domain) {
435
+ const d = normalizeRecipientDomain(domain);
436
+ if (!d)
437
+ return null;
438
+ return CONSUMER_DOMAIN_FAMILIES.get(d) ?? null;
439
+ }
440
+ /**
441
+ * Infer a recipient's MATCHABLE ESP from its domain string. Narrow by design:
442
+ * only the Google/Microsoft consumer domains resolve here, everything else
443
+ * (including yahoo/proton/icloud, which are real but unsendable-as) is null.
444
+ * Unchanged contract — the dispatcher's fast path still calls this.
445
+ */
446
+ export function inferRecipientEsp(domain) {
447
+ return matchableEspFromFamily(inferRecipientEspFamily(domain));
448
+ }
449
+ /**
450
+ * Back-compat narrow view of familyFromMxHosts: MX hosts → matchable ESP. Now
451
+ * derived from the family table so the two can't drift, and gateway MX hosts
452
+ * correctly yield null here (they need the SPF signal to become matchable).
453
+ */
454
+ export function espFromMxHosts(hosts) {
455
+ return matchableEspFromFamily(familyFromMxHosts(hosts));
456
+ }
211
457
  /**
212
458
  * row_values keys an enrollment's phone number may live under, in
213
459
  * send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
@@ -252,6 +498,66 @@ export function whatsAppAttendeeIdFromRow(rowValues, phoneColumnKey) {
252
498
  }
253
499
  return null;
254
500
  }
501
+ /**
502
+ * Normalize a raw phone number to strict E.164 (`+` then 2-15 digits, no leading
503
+ * zero) for the CALL channel. Deliberately stricter than
504
+ * whatsAppAttendeeIdForPhone, which accepts any 7-15 bare digits: on WhatsApp a
505
+ * bad guess fails an API call, but on the phone it rings a real stranger.
506
+ *
507
+ * Rules — anything ambiguous returns null rather than guessing:
508
+ * - already "+…" -> strip separators, validate, keep.
509
+ * - 10 bare digits -> NANP national number, prefix "+1".
510
+ * - 11 bare digits starting with "1" -> NANP with country code, prefix "+".
511
+ * - anything else -> null.
512
+ *
513
+ * SCOPE: bare (non-"+") input is completed ONLY for the NANP, where the national
514
+ * number is always exactly 10 digits. Every other country has a variable national
515
+ * length (UK 10, Germany 6-11, …), so a bare string genuinely cannot be resolved
516
+ * to one number — those callers must supply full E.164. That matches v1, which
517
+ * provisions US/Canada numbers only.
518
+ *
519
+ * When international dialing lands, replace this with libphonenumber-js rather
520
+ * than extending the heuristic — per-country national-number rules are a lookup
521
+ * table, not something to hand-roll against live prospects.
522
+ */
523
+ export function callPhoneForLead(phone) {
524
+ const trimmed = phone.trim();
525
+ if (!trimmed)
526
+ return null;
527
+ const hasPlus = trimmed.startsWith("+");
528
+ const digits = trimmed.replace(/[^\d]/g, "");
529
+ if (!digits)
530
+ return null;
531
+ if (hasPlus) {
532
+ return /^[1-9]\d{1,14}$/.test(digits) ? `+${digits}` : null;
533
+ }
534
+ if (digits.length === 10)
535
+ return `+1${digits}`;
536
+ if (digits.length === 11 && digits.startsWith("1"))
537
+ return `+${digits}`;
538
+ // No "+" and not a NANP shape: the country code is unknowable. Returning null
539
+ // makes the dispatcher skip the step rather than dial a guess.
540
+ return null;
541
+ }
542
+ /**
543
+ * Resolve a lead's dialable E.164 number from its row_values, reusing the SAME
544
+ * column precedence WhatsApp uses (SEQUENCE_PHONE_COLUMN_KEYS) so the two phone
545
+ * channels can never disagree about which column holds the number.
546
+ */
547
+ export function callPhoneFromRow(rowValues, phoneColumnKey) {
548
+ if (!rowValues)
549
+ return null;
550
+ const keys = phoneColumnKey ? [phoneColumnKey, ...SEQUENCE_PHONE_COLUMN_KEYS] : [...SEQUENCE_PHONE_COLUMN_KEYS];
551
+ for (const key of keys) {
552
+ const value = rowValues[key];
553
+ if (typeof value === "string" && value.trim()) {
554
+ const normalized = callPhoneForLead(value);
555
+ if (normalized)
556
+ return normalized;
557
+ }
558
+ }
559
+ return null;
560
+ }
255
561
  /**
256
562
  * Base content + up to this many alternates per A/B step (base counts as
257
563
  * variant "a", so the ceiling is "a"–"z" — Instantly-class A/Z testing).
@@ -309,12 +615,29 @@ export const SEQUENCE_STEP_KINDS = [
309
615
  "email_stop",
310
616
  // whatsapp channel (native dispatch via Unipile)
311
617
  "whatsapp_message",
618
+ // call channel — the ONLY step whose executor is a human. It enqueues an
619
+ // ox_sequencer.call_tasks row and parks the enrollment on a call signal; a rep
620
+ // dialing and dispositioning is what resumes it. Nothing is sent, nothing is
621
+ // metered, and no provider is touched at plan time.
622
+ "call_task",
623
+ // CRM channel — creates an inspectable task in a connected CRM. The step
624
+ // contract is provider-neutral; HubSpot is the first enabled adapter.
625
+ "crm_task",
312
626
  // control (channel-agnostic)
313
627
  "wait",
314
628
  "wait_for_signal",
315
629
  "branch",
316
630
  "stop",
317
631
  ];
632
+ // ---- CRM-channel steps (external writes through a connected CRM) ----
633
+ export const SEQUENCE_CRM_TASK_PROVIDERS = ["hubspot"];
634
+ export const SEQUENCE_CRM_IDENTITY_KINDS = [
635
+ "provider_id",
636
+ "email",
637
+ "domain",
638
+ "linkedin_url",
639
+ "exact",
640
+ ];
318
641
  /**
319
642
  * Conditions a connection branch routes on. Both need live LinkedIn access, so
320
643
  * the dispatcher (not the planner) resolves them over time:
@@ -327,6 +650,14 @@ export const SEQUENCE_CONNECTION_BRANCH_CONDITIONS = ["connection_accepted", "al
327
650
  const MAX_SEQUENCE_STEPS = 50;
328
651
  const MAX_TEMPLATE_LENGTH = 8_000;
329
652
  const MAX_NOTE_LENGTH = 300;
653
+ const MAX_CRM_TASK_PROPERTIES = 100;
654
+ const MAX_CRM_TASK_ASSOCIATIONS = 20;
655
+ const MAX_CRM_RECORD_MAPPINGS = 10;
656
+ const MAX_CRM_RECORD_IDENTITIES = 10;
657
+ const MAX_CRM_RECORD_LINKS = 20;
658
+ const MAX_CRM_MAPPING_KEY_LENGTH = 128;
659
+ const MAX_CRM_RELATIVE_TIME_MINUTES = 525_600;
660
+ const FORBIDDEN_CRM_MAPPING_KEYS = new Set(["__proto__", "constructor", "prototype"]);
330
661
  /** Cap on step Cc/Bcc recipients — a handful of fixed addresses (e.g. an AE, a CRM drop), not a list. */
331
662
  const MAX_STEP_EMAIL_RECIPIENTS = 10;
332
663
  const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
@@ -344,6 +675,64 @@ export function sequenceChannels(definition) {
344
675
  }
345
676
  return [...channels];
346
677
  }
678
+ /**
679
+ * Steps that still have empty copy — the launch gate for definitions stored
680
+ * stored under `draft`. Empty means nothing to send, so starting would
681
+ * either dispatch a blank message or fail per lead at send time.
682
+ */
683
+ export function sequenceStepsMissingCopy(steps) {
684
+ const missing = [];
685
+ const check = (step, field, value) => {
686
+ if (typeof value !== "string" || !value.trim())
687
+ missing.push({ stepId: step.id, kind: step.kind, field });
688
+ };
689
+ for (const step of steps) {
690
+ switch (step.kind) {
691
+ case "message":
692
+ case "whatsapp_message":
693
+ check(step, "template", step.template);
694
+ break;
695
+ case "inmail":
696
+ check(step, "subject_template", step.subject_template);
697
+ check(step, "template", step.template);
698
+ break;
699
+ case "email_send":
700
+ check(step, "subject_template", step.subject_template);
701
+ check(step, "body_template", step.body_template);
702
+ break;
703
+ case "email_reply":
704
+ check(step, "body_template", step.body_template);
705
+ break;
706
+ case "crm_task":
707
+ for (const [property, mapping] of Object.entries(step.property_mappings)) {
708
+ if (mapping.source === "template") {
709
+ check(step, `property_mappings.${property}.template`, mapping.template);
710
+ }
711
+ }
712
+ for (const [recordRef, record] of Object.entries(step.record_mappings ?? {})) {
713
+ for (const [index, identity] of record.identities.entries()) {
714
+ if (identity.value.source === "template") {
715
+ check(step, `record_mappings.${recordRef}.identities[${index}].value.template`, identity.value.template);
716
+ }
717
+ }
718
+ for (const [property, mapping] of Object.entries(record.property_mappings)) {
719
+ if (mapping.source === "template") {
720
+ check(step, `record_mappings.${recordRef}.property_mappings.${property}.template`, mapping.template);
721
+ }
722
+ }
723
+ }
724
+ for (const [index, association] of (step.associations ?? []).entries()) {
725
+ if (association.object_id?.source === "template") {
726
+ check(step, `associations[${index}].object_id.template`, association.object_id.template);
727
+ }
728
+ }
729
+ break;
730
+ default:
731
+ break;
732
+ }
733
+ }
734
+ return missing;
735
+ }
347
736
  /**
348
737
  * Validate + normalize a raw sequence definition. Assigns stable ids to steps
349
738
  * that omit one (s{index}), verifies branch/gate targets resolve, and enforces
@@ -352,7 +741,7 @@ export function sequenceChannels(definition) {
352
741
  */
353
742
  export function validateSequenceDefinition(input, options = {}) {
354
743
  const issues = [];
355
- const rawSteps = collectSteps(input, issues);
744
+ const rawSteps = collectSteps(input, options, issues);
356
745
  // Pass 1: normalize each step + assign ids.
357
746
  const normalized = [];
358
747
  const usedIds = new Set();
@@ -471,14 +860,16 @@ function collectConditionSignals(condition, out) {
471
860
  else
472
861
  collectConditionSignals(condition.not, out);
473
862
  }
474
- function collectSteps(input, issues) {
863
+ function collectSteps(input, options, issues) {
475
864
  const record = isRecord(input) ? input : null;
476
865
  const steps = record?.steps;
477
866
  if (!Array.isArray(steps)) {
478
867
  issues.push({ path: "steps", message: "steps must be an array." });
479
868
  return [];
480
869
  }
481
- if (steps.length === 0) {
870
+ // A draft may be empty: a new sequence starts blank, and deleting the last step
871
+ // is a normal edit. Launch is where a sequence must actually have something.
872
+ if (steps.length === 0 && options.draft !== true) {
482
873
  issues.push({ path: "steps", message: "A sequence needs at least one step." });
483
874
  }
484
875
  if (steps.length > MAX_SEQUENCE_STEPS) {
@@ -526,8 +917,8 @@ raw, index, options, issues) {
526
917
  };
527
918
  }
528
919
  case "message": {
529
- const template = requiredTemplate(raw.template, `${path}.template`, issues);
530
- const variants = normalizeVariants(raw.variants, `${path}.variants`, ["template"], issues);
920
+ const template = requiredTemplate(raw.template, `${path}.template`, issues, options.draft === true);
921
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, ["template"], issues, options.draft === true);
531
922
  const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
532
923
  const attachments = normalizeLinkedInAttachments(raw.attachments, `${path}.attachments`, issues);
533
924
  return {
@@ -538,9 +929,9 @@ raw, index, options, issues) {
538
929
  };
539
930
  }
540
931
  case "inmail": {
541
- const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues);
542
- const template = requiredTemplate(raw.template, `${path}.template`, issues);
543
- const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "template"], issues);
932
+ const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues, options.draft === true);
933
+ const template = requiredTemplate(raw.template, `${path}.template`, issues, options.draft === true);
934
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "template"], issues, options.draft === true);
544
935
  const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
545
936
  return {
546
937
  id, channel: "linkedin", kind: "inmail", subject_template: subject ?? "", template: template ?? "",
@@ -562,10 +953,10 @@ raw, index, options, issues) {
562
953
  return { id, channel: "email", kind: "email_move", subsequence_id: subsequenceId ?? "" };
563
954
  }
564
955
  case "email_send": {
565
- const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues);
566
- const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues);
956
+ const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues, options.draft === true);
957
+ const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues, options.draft === true);
567
958
  const bodyHtml = optionalTemplate(raw.body_html_template, `${path}.body_html_template`, MAX_TEMPLATE_LENGTH, issues);
568
- const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "body_template", "body_html_template"], issues);
959
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "body_template", "body_html_template"], issues, options.draft === true);
569
960
  const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
570
961
  const cc = optionalEmailList(raw.cc, `${path}.cc`, issues);
571
962
  const bcc = optionalEmailList(raw.bcc, `${path}.bcc`, issues);
@@ -581,9 +972,9 @@ raw, index, options, issues) {
581
972
  };
582
973
  }
583
974
  case "email_reply": {
584
- const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues);
975
+ const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues, options.draft === true);
585
976
  const bodyHtml = optionalTemplate(raw.body_html_template, `${path}.body_html_template`, MAX_TEMPLATE_LENGTH, issues);
586
- const variants = normalizeVariants(raw.variants, `${path}.variants`, ["body_template", "body_html_template"], issues);
977
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, ["body_template", "body_html_template"], issues, options.draft === true);
587
978
  const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
588
979
  const cc = optionalEmailList(raw.cc, `${path}.cc`, issues);
589
980
  const bcc = optionalEmailList(raw.bcc, `${path}.bcc`, issues);
@@ -601,8 +992,8 @@ raw, index, options, issues) {
601
992
  case "email_stop":
602
993
  return { id, channel: "email", kind: "email_stop" };
603
994
  case "whatsapp_message": {
604
- const template = requiredTemplate(raw.template, `${path}.template`, issues);
605
- const variants = normalizeVariants(raw.variants, `${path}.variants`, ["template"], issues);
995
+ const template = requiredTemplate(raw.template, `${path}.template`, issues, options.draft === true);
996
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, ["template"], issues, options.draft === true);
606
997
  const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
607
998
  return {
608
999
  id, channel: "whatsapp", kind: "whatsapp_message", template: template ?? "",
@@ -610,6 +1001,67 @@ raw, index, options, issues) {
610
1001
  ...(autoOptimize ? { auto_optimize: autoOptimize } : {}),
611
1002
  };
612
1003
  }
1004
+ case "call_task": {
1005
+ // `note` is talking points for the rep, never transmitted, so it is
1006
+ // optional and unvalidated for emptiness — unlike message copy, a blank one
1007
+ // cannot produce a bad send.
1008
+ const note = typeof raw.note === "string" ? raw.note : undefined;
1009
+ const priority = raw.priority === undefined || raw.priority === null
1010
+ ? undefined
1011
+ : positiveInt(raw.priority, `${path}.priority`, issues) ?? undefined;
1012
+ const timeoutDays = raw.timeout_days === undefined || raw.timeout_days === null
1013
+ ? 3
1014
+ : positiveInt(raw.timeout_days, `${path}.timeout_days`, issues) ?? 3;
1015
+ return {
1016
+ id,
1017
+ channel: "call",
1018
+ kind: "call_task",
1019
+ ...(note !== undefined ? { note } : {}),
1020
+ ...(priority !== undefined ? { priority } : {}),
1021
+ timeout_days: timeoutDays,
1022
+ // Defaults to "continue", the opposite of wait_for_signal's "stop". A
1023
+ // human failing to get to the queue is an operational fact, not a signal
1024
+ // the lead is uninterested — killing the enrollment because nobody dialed
1025
+ // would silently drop leads whenever the team gets busy.
1026
+ on_timeout: raw.on_timeout === "stop" ? "stop" : "continue",
1027
+ };
1028
+ }
1029
+ case "crm_task": {
1030
+ const provider = typeof raw.provider === "string"
1031
+ && SEQUENCE_CRM_TASK_PROVIDERS.includes(raw.provider)
1032
+ ? raw.provider
1033
+ : "hubspot";
1034
+ if (raw.provider !== undefined
1035
+ && (!(typeof raw.provider === "string")
1036
+ || !SEQUENCE_CRM_TASK_PROVIDERS.includes(raw.provider))) {
1037
+ issues.push({
1038
+ path: `${path}.provider`,
1039
+ message: `provider must be one of: ${SEQUENCE_CRM_TASK_PROVIDERS.join(", ")}.`,
1040
+ });
1041
+ }
1042
+ const connectionId = optionalString(raw.connection_id, `${path}.connection_id`, issues);
1043
+ const propertyMappings = normalizeCrmTaskPropertyMappings(raw.property_mappings, `${path}.property_mappings`, issues, options.draft === true);
1044
+ const recordMappings = normalizeCrmRecordMappings(raw.record_mappings, `${path}.record_mappings`, issues, options.draft === true);
1045
+ const recordLinks = normalizeCrmRecordLinks(raw.record_links, `${path}.record_links`, recordMappings, issues);
1046
+ const associations = normalizeCrmTaskAssociations(raw.associations, `${path}.associations`, recordMappings, issues, options.draft === true);
1047
+ if (provider === "hubspot" && !Object.hasOwn(propertyMappings, "hs_timestamp")) {
1048
+ issues.push({
1049
+ path: `${path}.property_mappings.hs_timestamp`,
1050
+ message: "HubSpot CRM tasks require an hs_timestamp mapping for the due date.",
1051
+ });
1052
+ }
1053
+ return {
1054
+ id,
1055
+ channel: "crm",
1056
+ kind: "crm_task",
1057
+ provider,
1058
+ ...(connectionId !== undefined ? { connection_id: connectionId } : {}),
1059
+ property_mappings: propertyMappings,
1060
+ ...(recordMappings ? { record_mappings: recordMappings } : {}),
1061
+ ...(recordLinks ? { record_links: recordLinks } : {}),
1062
+ ...(associations ? { associations } : {}),
1063
+ };
1064
+ }
613
1065
  case "wait": {
614
1066
  const days = optionalNonNegativeInt(raw.days, `${path}.days`, issues);
615
1067
  const hours = optionalNonNegativeInt(raw.hours, `${path}.hours`, issues);
@@ -767,8 +1219,395 @@ function channelForKind(kind) {
767
1219
  return "email";
768
1220
  if (kind === "whatsapp_message")
769
1221
  return "whatsapp";
1222
+ if (kind === "call_task")
1223
+ return "call";
1224
+ if (kind === "crm_task")
1225
+ return "crm";
770
1226
  return "linkedin";
771
1227
  }
1228
+ function normalizeCrmTaskPropertyMappings(raw, path, issues, draft, requireNonEmpty = true) {
1229
+ if (!isRecord(raw)) {
1230
+ issues.push({ path, message: "property_mappings must be an object keyed by CRM property internal name." });
1231
+ return {};
1232
+ }
1233
+ const entries = Object.entries(raw);
1234
+ if (requireNonEmpty && entries.length === 0) {
1235
+ issues.push({ path, message: "property_mappings must contain at least one CRM property." });
1236
+ }
1237
+ if (entries.length > MAX_CRM_TASK_PROPERTIES) {
1238
+ issues.push({ path, message: `property_mappings may contain at most ${MAX_CRM_TASK_PROPERTIES} properties.` });
1239
+ }
1240
+ const mappings = {};
1241
+ for (const [rawKey, value] of entries.slice(0, MAX_CRM_TASK_PROPERTIES)) {
1242
+ const key = rawKey.trim();
1243
+ const keyPath = `${path}.${rawKey}`;
1244
+ if (!key
1245
+ || key.length > MAX_CRM_MAPPING_KEY_LENGTH
1246
+ || !/^[A-Za-z0-9_.-]+$/.test(key)
1247
+ || FORBIDDEN_CRM_MAPPING_KEYS.has(key)) {
1248
+ issues.push({
1249
+ path: keyPath,
1250
+ message: `CRM property names must be safe keys matching [A-Za-z0-9_.-]{1,${MAX_CRM_MAPPING_KEY_LENGTH}}.`,
1251
+ });
1252
+ continue;
1253
+ }
1254
+ const mapping = normalizeCrmTaskValueMapping(value, keyPath, issues, draft);
1255
+ if (mapping)
1256
+ mappings[key] = mapping;
1257
+ }
1258
+ return mappings;
1259
+ }
1260
+ function normalizeCrmTaskValueMapping(raw, path, issues, draft) {
1261
+ if (!isRecord(raw) || typeof raw.source !== "string") {
1262
+ issues.push({
1263
+ path,
1264
+ message: "mapping must use source literal, row, template, or relative_time.",
1265
+ });
1266
+ return null;
1267
+ }
1268
+ switch (raw.source) {
1269
+ case "literal": {
1270
+ const value = raw.value;
1271
+ if (typeof value !== "string"
1272
+ && typeof value !== "number"
1273
+ && typeof value !== "boolean") {
1274
+ issues.push({ path: `${path}.value`, message: "literal value must be a string, number, or boolean." });
1275
+ return null;
1276
+ }
1277
+ if (typeof value === "number" && !Number.isFinite(value)) {
1278
+ issues.push({ path: `${path}.value`, message: "literal number must be finite." });
1279
+ return null;
1280
+ }
1281
+ if (typeof value === "string" && value.length > MAX_TEMPLATE_LENGTH) {
1282
+ issues.push({ path: `${path}.value`, message: `literal string may be at most ${MAX_TEMPLATE_LENGTH} characters.` });
1283
+ }
1284
+ return { source: "literal", value: value };
1285
+ }
1286
+ case "row": {
1287
+ const key = typeof raw.key === "string" ? raw.key.trim() : "";
1288
+ if (!key || key.length > MAX_CRM_MAPPING_KEY_LENGTH) {
1289
+ issues.push({ path: `${path}.key`, message: `row key must be 1-${MAX_CRM_MAPPING_KEY_LENGTH} characters.` });
1290
+ }
1291
+ return { source: "row", key };
1292
+ }
1293
+ case "template": {
1294
+ const template = requiredTemplate(raw.template, `${path}.template`, issues, draft);
1295
+ return { source: "template", template: template ?? "" };
1296
+ }
1297
+ case "relative_time": {
1298
+ const offset = optionalNonNegativeInt(raw.offset_minutes, `${path}.offset_minutes`, issues);
1299
+ if (offset === undefined || offset > MAX_CRM_RELATIVE_TIME_MINUTES) {
1300
+ if (raw.offset_minutes === undefined || raw.offset_minutes === null) {
1301
+ issues.push({
1302
+ path: `${path}.offset_minutes`,
1303
+ message: "offset_minutes is required.",
1304
+ });
1305
+ }
1306
+ else if (offset !== undefined) {
1307
+ issues.push({
1308
+ path: `${path}.offset_minutes`,
1309
+ message: `offset_minutes may be at most ${MAX_CRM_RELATIVE_TIME_MINUTES}.`,
1310
+ });
1311
+ }
1312
+ return null;
1313
+ }
1314
+ return { source: "relative_time", offset_minutes: offset };
1315
+ }
1316
+ default:
1317
+ issues.push({
1318
+ path: `${path}.source`,
1319
+ message: "source must be one of: literal, row, template, relative_time.",
1320
+ });
1321
+ return null;
1322
+ }
1323
+ }
1324
+ function normalizeCrmRecordMappings(raw, path, issues, draft) {
1325
+ if (raw === undefined || raw === null)
1326
+ return undefined;
1327
+ if (!isRecord(raw)) {
1328
+ issues.push({ path, message: "record_mappings must be an object keyed by a local record reference." });
1329
+ return undefined;
1330
+ }
1331
+ const entries = Object.entries(raw);
1332
+ if (entries.length > MAX_CRM_RECORD_MAPPINGS) {
1333
+ issues.push({ path, message: `record_mappings may contain at most ${MAX_CRM_RECORD_MAPPINGS} records.` });
1334
+ }
1335
+ const mappings = {};
1336
+ for (const [rawRef, rawMapping] of entries.slice(0, MAX_CRM_RECORD_MAPPINGS)) {
1337
+ const ref = rawRef.trim();
1338
+ const entryPath = `${path}.${rawRef}`;
1339
+ if (!isSafeCrmKey(ref)) {
1340
+ issues.push({
1341
+ path: entryPath,
1342
+ message: `record reference must be a safe key matching [A-Za-z0-9_.-]{1,${MAX_CRM_MAPPING_KEY_LENGTH}}.`,
1343
+ });
1344
+ continue;
1345
+ }
1346
+ if (!isRecord(rawMapping)) {
1347
+ issues.push({ path: entryPath, message: "record mapping must be an object." });
1348
+ continue;
1349
+ }
1350
+ const objectType = typeof rawMapping.object_type === "string"
1351
+ ? rawMapping.object_type.trim()
1352
+ : "";
1353
+ if (!isSafeCrmKey(objectType)) {
1354
+ issues.push({
1355
+ path: `${entryPath}.object_type`,
1356
+ message: `object_type must match [A-Za-z0-9_.-]{1,${MAX_CRM_MAPPING_KEY_LENGTH}}.`,
1357
+ });
1358
+ }
1359
+ const identities = normalizeCrmRecordIdentities(rawMapping.identities, `${entryPath}.identities`, issues, draft);
1360
+ const propertyMappings = normalizeCrmTaskPropertyMappings(rawMapping.property_mappings ?? {}, `${entryPath}.property_mappings`, issues, draft, false);
1361
+ const onMissing = rawMapping.on_missing === "create"
1362
+ ? "create"
1363
+ : "fail";
1364
+ if (rawMapping.on_missing !== undefined
1365
+ && rawMapping.on_missing !== null
1366
+ && rawMapping.on_missing !== "fail"
1367
+ && rawMapping.on_missing !== "create") {
1368
+ issues.push({
1369
+ path: `${entryPath}.on_missing`,
1370
+ message: "on_missing must be fail or create.",
1371
+ });
1372
+ }
1373
+ const onMatch = rawMapping.on_match === "update"
1374
+ ? "update"
1375
+ : "reuse";
1376
+ if (rawMapping.on_match !== undefined
1377
+ && rawMapping.on_match !== null
1378
+ && rawMapping.on_match !== "reuse"
1379
+ && rawMapping.on_match !== "update") {
1380
+ issues.push({
1381
+ path: `${entryPath}.on_match`,
1382
+ message: "on_match must be reuse or update.",
1383
+ });
1384
+ }
1385
+ mappings[ref] = {
1386
+ object_type: objectType,
1387
+ identities,
1388
+ property_mappings: propertyMappings,
1389
+ on_missing: onMissing,
1390
+ on_match: onMatch,
1391
+ };
1392
+ }
1393
+ return Object.keys(mappings).length > 0 ? mappings : undefined;
1394
+ }
1395
+ function normalizeCrmRecordIdentities(raw, path, issues, draft) {
1396
+ if (!Array.isArray(raw) || raw.length === 0) {
1397
+ issues.push({
1398
+ path,
1399
+ message: "identities must contain at least one exact provider_id, email, domain, linkedin_url, or exact mapping.",
1400
+ });
1401
+ return [];
1402
+ }
1403
+ if (raw.length > MAX_CRM_RECORD_IDENTITIES) {
1404
+ issues.push({ path, message: `identities may contain at most ${MAX_CRM_RECORD_IDENTITIES} entries.` });
1405
+ }
1406
+ const identities = [];
1407
+ const seen = new Set();
1408
+ raw.slice(0, MAX_CRM_RECORD_IDENTITIES).forEach((entry, index) => {
1409
+ const entryPath = `${path}[${index}]`;
1410
+ if (!isRecord(entry)) {
1411
+ issues.push({ path: entryPath, message: "identity mapping must be an object." });
1412
+ return;
1413
+ }
1414
+ const kind = typeof entry.kind === "string"
1415
+ && SEQUENCE_CRM_IDENTITY_KINDS.includes(entry.kind)
1416
+ ? entry.kind
1417
+ : null;
1418
+ if (!kind) {
1419
+ issues.push({
1420
+ path: `${entryPath}.kind`,
1421
+ message: `kind must be one of: ${SEQUENCE_CRM_IDENTITY_KINDS.join(", ")}.`,
1422
+ });
1423
+ return;
1424
+ }
1425
+ const property = typeof entry.property === "string" ? entry.property.trim() : "";
1426
+ if (kind === "provider_id") {
1427
+ if (property) {
1428
+ issues.push({
1429
+ path: `${entryPath}.property`,
1430
+ message: "provider_id identities do not use a property.",
1431
+ });
1432
+ }
1433
+ }
1434
+ else if (!isSafeCrmKey(property)) {
1435
+ issues.push({
1436
+ path: `${entryPath}.property`,
1437
+ message: `identity property must be a safe key matching [A-Za-z0-9_.-]{1,${MAX_CRM_MAPPING_KEY_LENGTH}}.`,
1438
+ });
1439
+ }
1440
+ const value = normalizeCrmTaskValueMapping(entry.value, `${entryPath}.value`, issues, draft);
1441
+ if (value?.source === "relative_time") {
1442
+ issues.push({
1443
+ path: `${entryPath}.value.source`,
1444
+ message: "record identities cannot use relative_time.",
1445
+ });
1446
+ return;
1447
+ }
1448
+ const dedupeKey = `${kind}:${property.toLowerCase()}`;
1449
+ if (seen.has(dedupeKey)) {
1450
+ issues.push({
1451
+ path: entryPath,
1452
+ message: "duplicate identity kind/property mappings are not allowed.",
1453
+ });
1454
+ return;
1455
+ }
1456
+ seen.add(dedupeKey);
1457
+ if (value) {
1458
+ identities.push({
1459
+ kind,
1460
+ ...(kind !== "provider_id" ? { property } : {}),
1461
+ value,
1462
+ });
1463
+ }
1464
+ });
1465
+ return identities;
1466
+ }
1467
+ function normalizeCrmRecordLinks(raw, path, recordMappings, issues) {
1468
+ if (raw === undefined || raw === null)
1469
+ return undefined;
1470
+ if (!Array.isArray(raw)) {
1471
+ issues.push({ path, message: "record_links must be an array." });
1472
+ return undefined;
1473
+ }
1474
+ if (raw.length > MAX_CRM_RECORD_LINKS) {
1475
+ issues.push({ path, message: `record_links may contain at most ${MAX_CRM_RECORD_LINKS} links.` });
1476
+ }
1477
+ const links = [];
1478
+ const seen = new Set();
1479
+ raw.slice(0, MAX_CRM_RECORD_LINKS).forEach((entry, index) => {
1480
+ const entryPath = `${path}[${index}]`;
1481
+ if (!isRecord(entry)) {
1482
+ issues.push({ path: entryPath, message: "record link must be an object." });
1483
+ return;
1484
+ }
1485
+ const fromRecord = typeof entry.from_record === "string" ? entry.from_record.trim() : "";
1486
+ const toRecord = typeof entry.to_record === "string" ? entry.to_record.trim() : "";
1487
+ if (!recordMappings?.[fromRecord]) {
1488
+ issues.push({ path: `${entryPath}.from_record`, message: "from_record must reference record_mappings." });
1489
+ }
1490
+ if (!recordMappings?.[toRecord]) {
1491
+ issues.push({ path: `${entryPath}.to_record`, message: "to_record must reference record_mappings." });
1492
+ }
1493
+ if (fromRecord && fromRecord === toRecord) {
1494
+ issues.push({ path: entryPath, message: "record link cannot point a record at itself." });
1495
+ }
1496
+ const dedupeKey = `${fromRecord}:${toRecord}`;
1497
+ if (seen.has(dedupeKey)) {
1498
+ issues.push({ path: entryPath, message: "duplicate record link." });
1499
+ return;
1500
+ }
1501
+ seen.add(dedupeKey);
1502
+ const associationTypeId = entry.association_type_id === undefined || entry.association_type_id === null
1503
+ ? undefined
1504
+ : positiveInt(entry.association_type_id, `${entryPath}.association_type_id`, issues);
1505
+ const associationCategory = normalizeCrmAssociationCategory(entry.association_category, `${entryPath}.association_category`, issues);
1506
+ if (recordMappings?.[fromRecord] && recordMappings[toRecord] && fromRecord !== toRecord) {
1507
+ links.push({
1508
+ from_record: fromRecord,
1509
+ to_record: toRecord,
1510
+ ...(associationTypeId !== undefined ? { association_type_id: associationTypeId } : {}),
1511
+ ...(associationCategory ? { association_category: associationCategory } : {}),
1512
+ });
1513
+ }
1514
+ });
1515
+ return links.length > 0 ? links : undefined;
1516
+ }
1517
+ function normalizeCrmTaskAssociations(raw, path, recordMappings, issues, draft) {
1518
+ if (raw === undefined || raw === null)
1519
+ return undefined;
1520
+ if (!Array.isArray(raw)) {
1521
+ issues.push({ path, message: "associations must be an array." });
1522
+ return undefined;
1523
+ }
1524
+ if (raw.length > MAX_CRM_TASK_ASSOCIATIONS) {
1525
+ issues.push({ path, message: `associations may contain at most ${MAX_CRM_TASK_ASSOCIATIONS} records.` });
1526
+ }
1527
+ const associations = [];
1528
+ raw.slice(0, MAX_CRM_TASK_ASSOCIATIONS).forEach((entry, index) => {
1529
+ const entryPath = `${path}[${index}]`;
1530
+ if (!isRecord(entry)) {
1531
+ issues.push({ path: entryPath, message: "association must be an object." });
1532
+ return;
1533
+ }
1534
+ const recordRef = typeof entry.record_ref === "string" ? entry.record_ref.trim() : "";
1535
+ const objectType = typeof entry.object_type === "string" ? entry.object_type.trim() : "";
1536
+ const hasObjectId = entry.object_id !== undefined && entry.object_id !== null;
1537
+ if ((recordRef ? 1 : 0) + (hasObjectId ? 1 : 0) !== 1) {
1538
+ issues.push({
1539
+ path: entryPath,
1540
+ message: "association must set exactly one of record_ref or object_id.",
1541
+ });
1542
+ }
1543
+ if (recordRef && !recordMappings?.[recordRef]) {
1544
+ issues.push({
1545
+ path: `${entryPath}.record_ref`,
1546
+ message: "record_ref must reference record_mappings.",
1547
+ });
1548
+ }
1549
+ if (!recordRef && !isSafeCrmKey(objectType)) {
1550
+ issues.push({
1551
+ path: `${entryPath}.object_type`,
1552
+ message: `object_type must match [A-Za-z0-9_.-]{1,${MAX_CRM_MAPPING_KEY_LENGTH}}.`,
1553
+ });
1554
+ }
1555
+ else if (objectType && !isSafeCrmKey(objectType)) {
1556
+ issues.push({
1557
+ path: `${entryPath}.object_type`,
1558
+ message: `object_type must match [A-Za-z0-9_.-]{1,${MAX_CRM_MAPPING_KEY_LENGTH}}.`,
1559
+ });
1560
+ }
1561
+ const objectId = hasObjectId
1562
+ ? normalizeCrmTaskValueMapping(entry.object_id, `${entryPath}.object_id`, issues, draft)
1563
+ : null;
1564
+ if (objectId?.source === "relative_time") {
1565
+ issues.push({
1566
+ path: `${entryPath}.object_id.source`,
1567
+ message: "association object_id must use literal, row, or template (not relative_time).",
1568
+ });
1569
+ }
1570
+ const associationTypeId = entry.association_type_id === undefined || entry.association_type_id === null
1571
+ ? undefined
1572
+ : positiveInt(entry.association_type_id, `${entryPath}.association_type_id`, issues);
1573
+ const associationCategory = normalizeCrmAssociationCategory(entry.association_category, `${entryPath}.association_category`, issues);
1574
+ if (recordRef && recordMappings?.[recordRef]) {
1575
+ associations.push({
1576
+ record_ref: recordRef,
1577
+ ...(objectType ? { object_type: objectType } : {}),
1578
+ ...(associationTypeId !== undefined ? { association_type_id: associationTypeId } : {}),
1579
+ ...(associationCategory ? { association_category: associationCategory } : {}),
1580
+ });
1581
+ }
1582
+ else if (objectId && objectId.source !== "relative_time") {
1583
+ associations.push({
1584
+ object_type: objectType,
1585
+ object_id: objectId,
1586
+ ...(associationTypeId !== undefined ? { association_type_id: associationTypeId } : {}),
1587
+ ...(associationCategory ? { association_category: associationCategory } : {}),
1588
+ });
1589
+ }
1590
+ });
1591
+ return associations.length > 0 ? associations : undefined;
1592
+ }
1593
+ function normalizeCrmAssociationCategory(raw, path, issues) {
1594
+ if (raw === undefined || raw === null || raw === "HUBSPOT_DEFINED") {
1595
+ return "HUBSPOT_DEFINED";
1596
+ }
1597
+ if (raw === "USER_DEFINED")
1598
+ return "USER_DEFINED";
1599
+ issues.push({
1600
+ path,
1601
+ message: "association_category must be HUBSPOT_DEFINED or USER_DEFINED.",
1602
+ });
1603
+ return undefined;
1604
+ }
1605
+ function isSafeCrmKey(value) {
1606
+ return Boolean(value
1607
+ && value.length <= MAX_CRM_MAPPING_KEY_LENGTH
1608
+ && /^[A-Za-z0-9_.-]+$/.test(value)
1609
+ && !FORBIDDEN_CRM_MAPPING_KEYS.has(value));
1610
+ }
772
1611
  function normalizeId(value, index, path, issues) {
773
1612
  if (value === undefined || value === null)
774
1613
  return `s${index}`;
@@ -860,8 +1699,11 @@ function normalizeConditionList(raw, path, issues, depth) {
860
1699
  });
861
1700
  return out;
862
1701
  }
863
- function requiredTemplate(value, path, issues) {
864
- if (typeof value !== "string" || !value.trim()) {
1702
+ function requiredTemplate(value, path, issues,
1703
+ // Drafts may hold copy the user has not written yet — see the `draft` option.
1704
+ // The field must still BE a string; only emptiness is forgiven.
1705
+ allowEmpty = false) {
1706
+ if (typeof value !== "string" || (!value.trim() && !allowEmpty)) {
865
1707
  issues.push({ path, message: "is required and must be a non-empty string." });
866
1708
  return undefined;
867
1709
  }
@@ -869,6 +1711,8 @@ function requiredTemplate(value, path, issues) {
869
1711
  issues.push({ path, message: `must be at most ${MAX_TEMPLATE_LENGTH} characters.` });
870
1712
  return undefined;
871
1713
  }
1714
+ for (const issue of spintaxSyntaxIssues(value))
1715
+ issues.push({ path, message: issue });
872
1716
  return value;
873
1717
  }
874
1718
  function optionalTemplate(value, path, maxLength, issues) {
@@ -882,6 +1726,8 @@ function optionalTemplate(value, path, maxLength, issues) {
882
1726
  issues.push({ path, message: `must be at most ${maxLength} characters.` });
883
1727
  return undefined;
884
1728
  }
1729
+ for (const issue of spintaxSyntaxIssues(value))
1730
+ issues.push({ path, message: issue });
885
1731
  return value;
886
1732
  }
887
1733
  /**
@@ -992,7 +1838,8 @@ export function sequenceWaitStepDelayWithJitterMs(step, enrollmentId) {
992
1838
  /**
993
1839
  * Render a sequence-copy template against a row's values. Delegates to the shared
994
1840
  * deterministic engine (sequence-template.ts): `{{column}}` substitution plus
995
- * `{{column|fallback}}`, `{{RANDOM|…}}` spintax, and `{% if … %}` conditionals.
1841
+ * `{{column|fallback}}`, `{{RANDOM|…}}` and bare `{a|b|c}` spintax (nestable), and
1842
+ * `{% if … %}` conditionals.
996
1843
  * Pass `{ seed }` to make spintax choices replayable across retries/crash-replays.
997
1844
  */
998
1845
  export function renderSequenceTemplate(template, values, options) {
@@ -1015,6 +1862,36 @@ export function sequenceTemplateVariables(definition) {
1015
1862
  for (const step of definition.steps) {
1016
1863
  if (step.kind === "invite")
1017
1864
  scan(step.note_template);
1865
+ if (step.kind === "crm_task") {
1866
+ for (const mapping of Object.values(step.property_mappings)) {
1867
+ if (mapping.source === "template")
1868
+ scan(mapping.template);
1869
+ if (mapping.source === "row")
1870
+ vars.add(mapping.key);
1871
+ }
1872
+ for (const record of Object.values(step.record_mappings ?? {})) {
1873
+ for (const identity of record.identities) {
1874
+ if (identity.value.source === "template")
1875
+ scan(identity.value.template);
1876
+ if (identity.value.source === "row")
1877
+ vars.add(identity.value.key);
1878
+ }
1879
+ for (const mapping of Object.values(record.property_mappings)) {
1880
+ if (mapping.source === "template")
1881
+ scan(mapping.template);
1882
+ if (mapping.source === "row")
1883
+ vars.add(mapping.key);
1884
+ }
1885
+ }
1886
+ for (const association of step.associations ?? []) {
1887
+ if (!association.object_id)
1888
+ continue;
1889
+ if (association.object_id.source === "template")
1890
+ scan(association.object_id.template);
1891
+ if (association.object_id.source === "row")
1892
+ vars.add(association.object_id.key);
1893
+ }
1894
+ }
1018
1895
  // Scan base + every A/B variant's copy so a column referenced only in a
1019
1896
  // variant still surfaces in the start preview's variable-resolution check.
1020
1897
  for (const content of sequenceStepVariantContents(step)) {
@@ -1262,7 +2139,10 @@ function tzParts(now, tz) {
1262
2139
  }
1263
2140
  }
1264
2141
  // ===== Validation helpers for variants + send windows =====
1265
- function normalizeVariants(raw, path, fields, issues) {
2142
+ function normalizeVariants(raw, path, fields, issues,
2143
+ // Mirrors the base step's allowance: a variant seeded from unwritten copy is
2144
+ // empty too, and would otherwise wedge the same autosave.
2145
+ allowEmpty = false) {
1266
2146
  if (raw === undefined || raw === null)
1267
2147
  return undefined;
1268
2148
  if (!Array.isArray(raw)) {
@@ -1285,7 +2165,7 @@ function normalizeVariants(raw, path, fields, issues) {
1285
2165
  const value = entry[field];
1286
2166
  if (value === undefined || value === null)
1287
2167
  continue;
1288
- const template = requiredTemplate(value, `${path}[${i}].${field}`, issues);
2168
+ const template = requiredTemplate(value, `${path}[${i}].${field}`, issues, allowEmpty);
1289
2169
  if (template !== undefined)
1290
2170
  variant[field] = template;
1291
2171
  }