@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.
- package/README.md +1 -1
- package/dist/column-run-notices.d.ts +11 -0
- package/dist/column-run-notices.js +37 -0
- package/dist/command-manifest.js +13 -8
- package/dist/help.js +78 -16
- package/dist/index.js +3873 -514
- package/dist/skills.js +106 -1
- package/node_modules/@oxygen/formula/dist/coerce.d.ts +8 -0
- package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
- package/node_modules/@oxygen/formula/dist/evaluate.d.ts +31 -0
- package/node_modules/@oxygen/formula/dist/evaluate.js +248 -0
- package/node_modules/@oxygen/formula/dist/expression.d.ts +64 -0
- package/node_modules/@oxygen/formula/dist/expression.js +428 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +71 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.js +1100 -0
- package/node_modules/@oxygen/formula/dist/index.d.ts +17 -0
- package/node_modules/@oxygen/formula/dist/index.js +17 -0
- package/node_modules/@oxygen/formula/dist/value-normalizers.d.ts +30 -0
- package/node_modules/@oxygen/formula/dist/value-normalizers.js +80 -0
- package/node_modules/@oxygen/formula/package.json +26 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +30 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.js +2 -2
- package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/billing-anchors.js +135 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +101 -5
- package/node_modules/@oxygen/shared/dist/billing.js +192 -8
- package/node_modules/@oxygen/shared/dist/call-outcomes.d.ts +59 -0
- package/node_modules/@oxygen/shared/dist/call-outcomes.js +73 -0
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/credit-guidance.js +3 -1
- package/node_modules/@oxygen/shared/dist/crm-reply-events.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/crm-reply-events.js +31 -0
- package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.js +65 -0
- package/node_modules/@oxygen/shared/dist/directory.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/directory.js +1 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +17 -1
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +52 -3
- package/node_modules/@oxygen/shared/dist/index.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/index.js +15 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +77 -0
- package/node_modules/@oxygen/shared/dist/langfuse.js +231 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +56 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -4
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +5 -4
- package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.js +7 -4
- package/node_modules/@oxygen/shared/dist/log.js +56 -4
- package/node_modules/@oxygen/shared/dist/microsoft-consent-url.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +29 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +61 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +636 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.js +199 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +89 -23
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +88 -24
- package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +291 -0
- package/node_modules/@oxygen/shared/dist/sequence-crm-events.js +224 -0
- package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +42 -1
- package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +287 -24
- package/node_modules/@oxygen/shared/dist/sequences.js +940 -60
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +70 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.js +106 -0
- package/node_modules/@oxygen/shared/dist/tags.d.ts +90 -1
- package/node_modules/@oxygen/shared/dist/tags.js +126 -6
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +4 -0
- package/node_modules/@oxygen/shared/dist/workspace-agents.d.ts +8 -7
- package/node_modules/@oxygen/shared/dist/workspace-agents.js +34 -7
- package/node_modules/@oxygen/shared/package.json +95 -0
- package/node_modules/@oxygen/workflows/dist/event-dispatch.d.ts +126 -0
- package/node_modules/@oxygen/workflows/dist/event-dispatch.js +173 -0
- package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +78 -0
- package/node_modules/@oxygen/workflows/dist/graph/expression.js +700 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.js +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +4 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +812 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +501 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +200 -0
- package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +86 -0
- package/node_modules/@oxygen/workflows/dist/graph/params.js +173 -0
- package/node_modules/@oxygen/workflows/dist/graph/remap.d.ts +48 -0
- package/node_modules/@oxygen/workflows/dist/graph/remap.js +213 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +46 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.js +280 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +270 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.js +93 -0
- package/node_modules/@oxygen/workflows/dist/index.d.ts +113 -1
- package/node_modules/@oxygen/workflows/dist/index.js +179 -13
- package/node_modules/@oxygen/workflows/dist/tool-effects.d.ts +1 -0
- package/node_modules/@oxygen/workflows/dist/tool-effects.js +19 -0
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +135 -4
- package/node_modules/@oxygen/workflows/package.json +4 -0
- 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
|
-
|
|
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
|
|
123
|
-
* - "prefer" — pick a same-ESP mailbox when one is available, else
|
|
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
|
-
|
|
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
|
|
149
|
-
* intentionally aligned with EMAIL_MAILBOX_PROVIDERS in
|
|
150
|
-
* `provider` and
|
|
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
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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|…}}`
|
|
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
|
}
|