@oxygen-agent/cli 1.226.15 → 1.242.6

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.
@@ -0,0 +1,162 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ export class CustomHttpUrlSafetyError extends Error {
4
+ reason;
5
+ details;
6
+ constructor(reason, message, details = {}) {
7
+ super(message);
8
+ this.name = "CustomHttpUrlSafetyError";
9
+ this.reason = reason;
10
+ this.details = details;
11
+ }
12
+ }
13
+ export function assertCustomHttpPublicUrlSyntax(url) {
14
+ const parsed = typeof url === "string" ? new URL(url) : url;
15
+ if (parsed.protocol !== "https:") {
16
+ throw new CustomHttpUrlSafetyError("protocol", "Custom HTTP URLs must use https.", {
17
+ url: safeUrlForDetails(parsed),
18
+ });
19
+ }
20
+ if (parsed.username || parsed.password) {
21
+ throw new CustomHttpUrlSafetyError("credentials", "Custom HTTP URLs cannot contain username or password credentials.", { url: safeUrlForDetails(parsed) });
22
+ }
23
+ const host = normalizeCustomHttpUrlHost(parsed.hostname);
24
+ if (isBlockedCustomHttpHost(host)) {
25
+ throw new CustomHttpUrlSafetyError("blocked_host", "Custom HTTP URL host is not allowed.", { host });
26
+ }
27
+ return parsed;
28
+ }
29
+ export async function assertCustomHttpResolvedHostAllowed(input) {
30
+ const parsed = assertCustomHttpPublicUrlSyntax(input.url);
31
+ const host = normalizeCustomHttpUrlHost(parsed.hostname);
32
+ if (isIP(host))
33
+ return;
34
+ const resolveHostname = input.resolveHostname ?? defaultResolveHostname;
35
+ let addresses;
36
+ try {
37
+ addresses = await resolveHostname(host);
38
+ }
39
+ catch (error) {
40
+ throw new CustomHttpUrlSafetyError("dns_lookup_failed", "Custom HTTP URL host could not be resolved.", {
41
+ host,
42
+ reason: error instanceof Error ? error.message : String(error),
43
+ });
44
+ }
45
+ if (addresses.length === 0) {
46
+ throw new CustomHttpUrlSafetyError("dns_lookup_failed", "Custom HTTP URL host did not resolve to any address.", {
47
+ host,
48
+ });
49
+ }
50
+ for (const address of addresses) {
51
+ const normalizedAddress = normalizeCustomHttpUrlHost(address.address);
52
+ if (isBlockedCustomHttpHost(normalizedAddress)) {
53
+ throw new CustomHttpUrlSafetyError("blocked_resolved_address", "Custom HTTP URL host resolved to a non-public address.", { host, address: normalizedAddress, family: address.family ?? null });
54
+ }
55
+ }
56
+ }
57
+ export function normalizeCustomHttpUrlHost(hostname) {
58
+ const host = hostname.toLowerCase().replace(/\.+$/, "");
59
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
60
+ }
61
+ export function isBlockedCustomHttpHost(host) {
62
+ return host === "localhost"
63
+ || host.endsWith(".localhost")
64
+ || host === "metadata.google.internal"
65
+ || isBlockedCustomHttpIpv4Address(host)
66
+ || isBlockedCustomHttpIpv6Address(host);
67
+ }
68
+ function safeUrlForDetails(parsed) {
69
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
70
+ }
71
+ async function defaultResolveHostname(hostname) {
72
+ return await lookup(hostname, { all: true, verbatim: true });
73
+ }
74
+ function isBlockedCustomHttpIpv4Address(host) {
75
+ const address = isIP(host) === 4 ? host : readLeadingCustomHttpIpv4Label(host);
76
+ if (!address)
77
+ return false;
78
+ const parts = address.split(".").map((part) => Number(part));
79
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
80
+ return true;
81
+ const [a = 0, b = 0, c = 0] = parts;
82
+ return a === 0
83
+ || a === 10
84
+ || a === 127
85
+ || (a === 169 && b === 254)
86
+ || (a === 172 && b >= 16 && b <= 31)
87
+ || (a === 192 && b === 168)
88
+ || (a === 100 && b >= 64 && b <= 127)
89
+ || (a === 192 && b === 0)
90
+ || (a === 198 && (b === 18 || b === 19))
91
+ || (a === 198 && b === 51 && c === 100)
92
+ || (a === 203 && b === 0 && c === 113)
93
+ || a >= 224;
94
+ }
95
+ function readLeadingCustomHttpIpv4Label(host) {
96
+ const match = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\.|$)/.exec(host);
97
+ return match?.[1] ?? null;
98
+ }
99
+ function isBlockedCustomHttpIpv6Address(host) {
100
+ if (isIP(host) !== 6)
101
+ return false;
102
+ const groups = expandCustomHttpIpv6Groups(host);
103
+ if (!groups)
104
+ return true;
105
+ const mappedIpv4 = customHttpIpv4FromIpv6(groups);
106
+ if (mappedIpv4 && isBlockedCustomHttpIpv4Address(mappedIpv4))
107
+ return true;
108
+ const [first = 0, second = 0] = groups;
109
+ const isUnspecified = groups.every((group) => group === 0);
110
+ const isLoopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
111
+ return isUnspecified
112
+ || isLoopback
113
+ || (first & 0xfe00) === 0xfc00
114
+ || (first & 0xffc0) === 0xfe80
115
+ || (first & 0xff00) === 0xff00
116
+ || (first === 0x2001 && second === 0x0db8);
117
+ }
118
+ function expandCustomHttpIpv6Groups(host) {
119
+ const address = host.toLowerCase();
120
+ const doubleColonParts = address.split("::");
121
+ if (doubleColonParts.length > 2)
122
+ return null;
123
+ const head = splitCustomHttpIpv6Part(doubleColonParts[0] ?? "");
124
+ const tail = splitCustomHttpIpv6Part(doubleColonParts[1] ?? "");
125
+ if (!head || !tail)
126
+ return null;
127
+ const fill = doubleColonParts.length === 2 ? 8 - head.length - tail.length : 0;
128
+ if (fill < 0)
129
+ return null;
130
+ const groups = doubleColonParts.length === 2
131
+ ? [...head, ...Array.from({ length: fill }, () => 0), ...tail]
132
+ : head;
133
+ return groups.length === 8 ? groups : null;
134
+ }
135
+ function splitCustomHttpIpv6Part(part) {
136
+ if (!part)
137
+ return [];
138
+ const groups = part.split(":");
139
+ const values = [];
140
+ for (const group of groups) {
141
+ if (!/^[0-9a-f]{1,4}$/.test(group))
142
+ return null;
143
+ values.push(Number.parseInt(group, 16));
144
+ }
145
+ return values;
146
+ }
147
+ function customHttpIpv4FromIpv6(groups) {
148
+ const mappedPrefix = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
149
+ const compatiblePrefix = groups.slice(0, 6).every((group) => group === 0);
150
+ if (!mappedPrefix && !compatiblePrefix)
151
+ return null;
152
+ const high = groups[6] ?? 0;
153
+ const low = groups[7] ?? 0;
154
+ if (high === 0 && low === 0)
155
+ return null;
156
+ return [
157
+ high >> 8,
158
+ high & 0xff,
159
+ low >> 8,
160
+ low & 0xff,
161
+ ].join(".");
162
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Signed token for the RFC 8058 one-click List-Unsubscribe link on native cold
3
+ * email. The worker MINTS the token at send time and bakes the URL into the
4
+ * List-Unsubscribe header; the public web webhook VERIFIES it. The token is the
5
+ * ONLY credential the anonymous unsubscribe endpoint trusts, so it must bind the
6
+ * recipient address — never let the caller pass an arbitrary email. The signing
7
+ * key (EMAIL_UNSUBSCRIBE_SECRET) lives in Doppler oxygen-shared so both the
8
+ * worker (mint) and web (verify) read the same value.
9
+ *
10
+ * Layering: this lives in @oxygen/shared because neither the worker nor the
11
+ * integrations send path can import apps/web, and the web route cannot import the
12
+ * worker. Pure + stateless: crypto only, no env policy (the route decides what a
13
+ * null verify means — a 401).
14
+ */
15
+ export type UnsubscribeTokenPayload = {
16
+ /** Schema version; only v1 is accepted. */
17
+ v: 1;
18
+ /** Organization id (also the webhook path segment; the route re-checks it). */
19
+ org: string;
20
+ /** The recipient address to suppress (normalized trim+lowercase, = the suppression key). */
21
+ email: string;
22
+ /** Sequence id, for provenance on the suppression row. */
23
+ seq?: string;
24
+ /** Enrollment id, so a one-click stop can halt the exact journey. */
25
+ enr?: string;
26
+ /** Sending mailbox id, for provenance. */
27
+ mbx?: string;
28
+ /** Issued-at (epoch ms), recorded as provenance. */
29
+ iat: number;
30
+ };
31
+ type EnvLike = Record<string, string | undefined>;
32
+ /**
33
+ * Sign a payload into `<base64url(json)>.<base64url(hmac-sha256)>`. The HMAC is
34
+ * computed over the encoded body, so any tamper to the payload invalidates the
35
+ * signature. Normalizes the email to the stored suppression form so the webhook
36
+ * write and the pre-send gate key on the identical string.
37
+ */
38
+ export declare function signUnsubscribeToken(payload: UnsubscribeTokenPayload, secret: string): string;
39
+ /**
40
+ * Verify a token's signature (constant-time) and shape. Returns the payload only
41
+ * when the signature matches AND it is a well-formed v1 payload with a non-empty
42
+ * org + a plausible email; returns null on ANY problem (bad shape, wrong/blank
43
+ * secret, tampered body, replayed garbage). Pure: the route maps null -> 401.
44
+ */
45
+ export declare function verifyUnsubscribeToken(token: string, secret: string): UnsubscribeTokenPayload | null;
46
+ /**
47
+ * The HMAC signing secret for unsubscribe tokens, or null when unset. The send
48
+ * path treats null as "omit the header" (a send with no one-click link still
49
+ * delivers); the webhook route treats null as fail-closed (reject everything).
50
+ */
51
+ export declare function unsubscribeSigningSecret(env?: EnvLike): string | null;
52
+ /**
53
+ * The public app base URL the unsubscribe link points at, trailing slash
54
+ * stripped. Prefers NEXT_PUBLIC_APP_URL, then OXYGEN_APP_URL, then the prod host.
55
+ */
56
+ export declare function oxygenAppBaseUrl(env?: EnvLike): string;
57
+ /**
58
+ * The canonical one-click unsubscribe URL: org id in the path (the route
59
+ * re-checks it against the token), token in the `token` query param.
60
+ */
61
+ export declare function buildUnsubscribeUrl(orgId: string, token: string, baseUrl: string): string;
62
+ export {};
@@ -0,0 +1,99 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ function base64urlEncode(value) {
3
+ return Buffer.from(value, "utf8").toString("base64url");
4
+ }
5
+ /**
6
+ * Sign a payload into `<base64url(json)>.<base64url(hmac-sha256)>`. The HMAC is
7
+ * computed over the encoded body, so any tamper to the payload invalidates the
8
+ * signature. Normalizes the email to the stored suppression form so the webhook
9
+ * write and the pre-send gate key on the identical string.
10
+ */
11
+ export function signUnsubscribeToken(payload, secret) {
12
+ const normalized = { ...payload, email: payload.email.trim().toLowerCase() };
13
+ const body = base64urlEncode(JSON.stringify(normalized));
14
+ const signature = createHmac("sha256", secret).update(body).digest("base64url");
15
+ return `${body}.${signature}`;
16
+ }
17
+ /** Constant-time HMAC-SHA256 check; false on any error (bad base64, length mismatch). */
18
+ function verifySignature(body, providedSig, secret) {
19
+ const expected = createHmac("sha256", secret).update(body).digest();
20
+ let provided;
21
+ try {
22
+ provided = Buffer.from(providedSig, "base64url");
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ // Equal-length guard before timingSafeEqual (it throws on length mismatch).
28
+ return provided.length === expected.length && timingSafeEqual(provided, expected);
29
+ }
30
+ /** Decode and validate the base64url body; returns typed payload or null on any problem. */
31
+ function parseTokenPayload(body) {
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ if (!parsed || typeof parsed !== "object")
40
+ return null;
41
+ const candidate = parsed;
42
+ if (candidate.v !== 1)
43
+ return null;
44
+ const org = typeof candidate.org === "string" ? candidate.org.trim() : "";
45
+ const email = typeof candidate.email === "string" ? candidate.email.trim().toLowerCase() : "";
46
+ if (!org || !email || !email.includes("@"))
47
+ return null;
48
+ return {
49
+ v: 1,
50
+ org,
51
+ email,
52
+ ...(typeof candidate.seq === "string" && candidate.seq ? { seq: candidate.seq } : {}),
53
+ ...(typeof candidate.enr === "string" && candidate.enr ? { enr: candidate.enr } : {}),
54
+ ...(typeof candidate.mbx === "string" && candidate.mbx ? { mbx: candidate.mbx } : {}),
55
+ iat: typeof candidate.iat === "number" && Number.isFinite(candidate.iat) ? candidate.iat : 0,
56
+ };
57
+ }
58
+ /**
59
+ * Verify a token's signature (constant-time) and shape. Returns the payload only
60
+ * when the signature matches AND it is a well-formed v1 payload with a non-empty
61
+ * org + a plausible email; returns null on ANY problem (bad shape, wrong/blank
62
+ * secret, tampered body, replayed garbage). Pure: the route maps null -> 401.
63
+ */
64
+ export function verifyUnsubscribeToken(token, secret) {
65
+ if (typeof token !== "string" || typeof secret !== "string" || !secret)
66
+ return null;
67
+ const dot = token.indexOf(".");
68
+ if (dot <= 0 || dot === token.length - 1)
69
+ return null;
70
+ const body = token.slice(0, dot);
71
+ const providedSig = token.slice(dot + 1);
72
+ if (!verifySignature(body, providedSig, secret))
73
+ return null;
74
+ return parseTokenPayload(body);
75
+ }
76
+ /**
77
+ * The HMAC signing secret for unsubscribe tokens, or null when unset. The send
78
+ * path treats null as "omit the header" (a send with no one-click link still
79
+ * delivers); the webhook route treats null as fail-closed (reject everything).
80
+ */
81
+ export function unsubscribeSigningSecret(env = process.env) {
82
+ const secret = env.EMAIL_UNSUBSCRIBE_SECRET?.trim();
83
+ return secret ? secret : null;
84
+ }
85
+ /**
86
+ * The public app base URL the unsubscribe link points at, trailing slash
87
+ * stripped. Prefers NEXT_PUBLIC_APP_URL, then OXYGEN_APP_URL, then the prod host.
88
+ */
89
+ export function oxygenAppBaseUrl(env = process.env) {
90
+ const raw = (env.NEXT_PUBLIC_APP_URL || env.OXYGEN_APP_URL || "https://oxygen-agent.com").trim();
91
+ return raw.replace(/\/+$/, "");
92
+ }
93
+ /**
94
+ * The canonical one-click unsubscribe URL: org id in the path (the route
95
+ * re-checks it against the token), token in the `token` query param.
96
+ */
97
+ export function buildUnsubscribeUrl(orgId, token, baseUrl) {
98
+ return `${baseUrl}/api/webhooks/email/unsubscribe/${encodeURIComponent(orgId)}?token=${token}`;
99
+ }
@@ -7,6 +7,7 @@ export * from "./cli-envelope.js";
7
7
  export * from "./cli-result.js";
8
8
  export * from "./column-types.js";
9
9
  export * from "./credit-guidance.js";
10
+ export * from "./email-unsubscribe-token.js";
10
11
  export * from "./linkedin-post-url.js";
11
12
  export * from "./linkedin-sequences.js";
12
13
  export * from "./networks.js";
@@ -7,6 +7,7 @@ export * from "./cli-envelope.js";
7
7
  export * from "./cli-result.js";
8
8
  export * from "./column-types.js";
9
9
  export * from "./credit-guidance.js";
10
+ export * from "./email-unsubscribe-token.js";
10
11
  export * from "./linkedin-post-url.js";
11
12
  export * from "./linkedin-sequences.js";
12
13
  export * from "./networks.js";
@@ -1,4 +1,5 @@
1
1
  const LINKEDIN_HOST = "linkedin.com";
2
+ const CANONICAL_LINKEDIN_HOST = `www.${LINKEDIN_HOST}`;
2
3
  const URL_LIKE_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
3
4
  function normalizeHostname(hostname) {
4
5
  return hostname.toLowerCase().replace(/\.+$/, "");
@@ -19,6 +20,9 @@ function isLinkedInPostPath(pathname) {
19
20
  const path = safelyDecodePath(pathname).toLowerCase();
20
21
  return /^\/posts\/[^/]+\/?$/.test(path) || /^\/feed\/update\/urn:li:activity:[^/]+\/?$/.test(path);
21
22
  }
23
+ function canonicalizePostPath(pathname) {
24
+ return pathname.replace(/\/+$/, "") || pathname;
25
+ }
22
26
  export function isLinkedInPostUrlLike(value) {
23
27
  const trimmed = value.trim();
24
28
  return URL_LIKE_PATTERN.test(trimmed) || trimmed.toLowerCase().includes("linkedin.com");
@@ -42,7 +46,8 @@ export function normalizeLinkedInPostUrl(value) {
42
46
  if (!isLinkedInPostPath(url.pathname))
43
47
  return null;
44
48
  url.protocol = "https:";
45
- url.hostname = hostname;
49
+ url.hostname = CANONICAL_LINKEDIN_HOST;
50
+ url.pathname = canonicalizePostPath(url.pathname);
46
51
  url.username = "";
47
52
  url.password = "";
48
53
  url.search = "";
@@ -15,6 +15,15 @@ export declare function isStatusSemantic(semanticType: string | null | undefined
15
15
  * transcripts all use this.
16
16
  */
17
17
  export declare function isMarkdownColumnSemantic(semanticType: string | null | undefined): boolean;
18
+ /**
19
+ * `image` is a semantic over a `text` dataType holding an image URL (e.g. a
20
+ * LinkedIn profile picture): the grid renders the cell as a round avatar instead
21
+ * of a link, with initials/empty fallback. Storage stays text — no new dataType.
22
+ * `avatar`/`photo` are accepted as aliases so URLs landed by enrichment/CRM
23
+ * still render; the editor writes the canonical `IMAGE_COLUMN_SEMANTIC`.
24
+ */
25
+ export declare const IMAGE_COLUMN_SEMANTIC = "image";
26
+ export declare function isImageColumnSemantic(semanticType: string | null | undefined): boolean;
18
27
  export type SelectOption = {
19
28
  id: string;
20
29
  value: string;
@@ -55,6 +55,17 @@ export function isStatusSemantic(semanticType) {
55
55
  export function isMarkdownColumnSemantic(semanticType) {
56
56
  return semanticType === "markdown";
57
57
  }
58
+ /**
59
+ * `image` is a semantic over a `text` dataType holding an image URL (e.g. a
60
+ * LinkedIn profile picture): the grid renders the cell as a round avatar instead
61
+ * of a link, with initials/empty fallback. Storage stays text — no new dataType.
62
+ * `avatar`/`photo` are accepted as aliases so URLs landed by enrichment/CRM
63
+ * still render; the editor writes the canonical `IMAGE_COLUMN_SEMANTIC`.
64
+ */
65
+ export const IMAGE_COLUMN_SEMANTIC = "image";
66
+ export function isImageColumnSemantic(semanticType) {
67
+ return semanticType === "image" || semanticType === "avatar" || semanticType === "photo";
68
+ }
58
69
  const MAX_OPTIONS = 200;
59
70
  // Normalize a raw options array (from a column `definition.options`, CLI/MCP, or
60
71
  // the editor) into canonical SelectOptions: dedupe by value, default colors,
@@ -37,6 +37,17 @@ export type SequenceChannel = (typeof SEQUENCE_CHANNELS)[number];
37
37
  */
38
38
  export declare const SEQUENCE_SIGNALS: readonly ["linkedin_connected", "linkedin_replied", "email_sent", "email_opened", "email_clicked", "email_replied", "email_bounced", "company_hiring", "company_raised_funds", "job_change", "new_hire", "web_visit", "intent"];
39
39
  export type SequenceSignal = (typeof SEQUENCE_SIGNALS)[number];
40
+ /**
41
+ * Email engagement signals that ONLY arrive via the Instantly webhook
42
+ * (email_opened/email_clicked). Native email sends (Gmail API / Microsoft Graph)
43
+ * produce no open/click tracking, so a sequence that sends email natively can
44
+ * never accumulate these — gating a wait_for_signal or signal-branch on them
45
+ * silently always times out / always takes the else arm. validateSequenceDefinition
46
+ * rejects that misconfiguration. (email_replied/email_bounced DO arrive natively
47
+ * via the inbound-message + bounce paths, so they are not listed here.)
48
+ */
49
+ export declare const SEQUENCE_NATIVE_UNTRACKED_SIGNALS: readonly ["email_opened", "email_clicked"];
50
+ export type SequenceNativeUntrackedSignal = (typeof SEQUENCE_NATIVE_UNTRACKED_SIGNALS)[number];
40
51
  /** External GTM signals (the non-engagement subset of SEQUENCE_SIGNALS). */
41
52
  export declare const SEQUENCE_EXTERNAL_SIGNALS: readonly ["company_hiring", "company_raised_funds", "job_change", "new_hire", "web_visit", "intent"];
42
53
  export type SequenceExternalSignal = (typeof SEQUENCE_EXTERNAL_SIGNALS)[number];
@@ -48,6 +59,14 @@ export declare function isExternalSequenceSignal(value: string): value is Sequen
48
59
  * Single source of truth so the send path and the lookup path can't drift.
49
60
  */
50
61
  export declare const SEQUENCE_EMAIL_COLUMN_KEYS: readonly ["email", "email_address", "work_email", "primary_email", "Email"];
62
+ /**
63
+ * Resolve an enrollment's recipient email from its row_values, taking the first
64
+ * present SEQUENCE_EMAIL_COLUMN_KEYS value that looks like an address. Single
65
+ * source of truth for both the send path (the dispatcher's `to`) and the
66
+ * complaint loop (the address a bounce/opt-out suppresses), so they can't drift.
67
+ * Returns the trimmed raw value (callers normalize/lowercase as needed) or null.
68
+ */
69
+ export declare function recipientEmailFromRow(rowValues: Record<string, unknown> | null | undefined): string | null;
51
70
  /**
52
71
  * row_values keys an enrollment's phone number may live under, in
53
72
  * send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
@@ -58,6 +58,19 @@ export const SEQUENCE_SIGNALS = [
58
58
  "web_visit",
59
59
  "intent",
60
60
  ];
61
+ /**
62
+ * Email engagement signals that ONLY arrive via the Instantly webhook
63
+ * (email_opened/email_clicked). Native email sends (Gmail API / Microsoft Graph)
64
+ * produce no open/click tracking, so a sequence that sends email natively can
65
+ * never accumulate these — gating a wait_for_signal or signal-branch on them
66
+ * silently always times out / always takes the else arm. validateSequenceDefinition
67
+ * rejects that misconfiguration. (email_replied/email_bounced DO arrive natively
68
+ * via the inbound-message + bounce paths, so they are not listed here.)
69
+ */
70
+ export const SEQUENCE_NATIVE_UNTRACKED_SIGNALS = ["email_opened", "email_clicked"];
71
+ function isNativeUntrackedSignal(value) {
72
+ return SEQUENCE_NATIVE_UNTRACKED_SIGNALS.includes(value);
73
+ }
61
74
  /** External GTM signals (the non-engagement subset of SEQUENCE_SIGNALS). */
62
75
  export const SEQUENCE_EXTERNAL_SIGNALS = [
63
76
  "company_hiring",
@@ -77,6 +90,21 @@ export function isExternalSequenceSignal(value) {
77
90
  * Single source of truth so the send path and the lookup path can't drift.
78
91
  */
79
92
  export const SEQUENCE_EMAIL_COLUMN_KEYS = ["email", "email_address", "work_email", "primary_email", "Email"];
93
+ /**
94
+ * Resolve an enrollment's recipient email from its row_values, taking the first
95
+ * present SEQUENCE_EMAIL_COLUMN_KEYS value that looks like an address. Single
96
+ * source of truth for both the send path (the dispatcher's `to`) and the
97
+ * complaint loop (the address a bounce/opt-out suppresses), so they can't drift.
98
+ * Returns the trimmed raw value (callers normalize/lowercase as needed) or null.
99
+ */
100
+ export function recipientEmailFromRow(rowValues) {
101
+ for (const key of SEQUENCE_EMAIL_COLUMN_KEYS) {
102
+ const value = rowValues?.[key];
103
+ if (typeof value === "string" && value.includes("@"))
104
+ return value.trim();
105
+ }
106
+ return null;
107
+ }
80
108
  /**
81
109
  * row_values keys an enrollment's phone number may live under, in
82
110
  * send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
@@ -213,6 +241,14 @@ export function validateSequenceDefinition(input, options = {}) {
213
241
  }
214
242
  }
215
243
  });
244
+ // Pass 3: native-email engagement-gate guard. Native email sends produce no
245
+ // open/click signals (email_opened/email_clicked arrive only from the Instantly
246
+ // webhook). A sequence that sends email natively — has native email steps
247
+ // (email_send/email_reply) and is NOT delegated to an Instantly campaign (no
248
+ // email_enroll/email_move/email_stop steps) — that gates a wait_for_signal or
249
+ // signal-branch on those signals would silently always time out / always take
250
+ // the else arm. Reject it at write time so create/update report the dead gate.
251
+ reportNativeEngagementGates(normalized, issues);
216
252
  if (issues.length > 0) {
217
253
  throw new OxygenError("invalid_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
218
254
  }
@@ -233,6 +269,52 @@ export function lintSequenceDefinition(input, options = {}) {
233
269
  return [{ path: "steps", message: error instanceof Error ? error.message : "Invalid sequence." }];
234
270
  }
235
271
  }
272
+ /**
273
+ * Push an issue for every wait_for_signal / signal-branch that gates on a
274
+ * natively-untracked engagement signal (email_opened/email_clicked) inside a
275
+ * sequence that sends email natively. "Native email" = at least one
276
+ * email_send/email_reply step and NO Instantly-delegated email step
277
+ * (email_enroll/email_move/email_stop); an Instantly-bound sequence DOES receive
278
+ * those webhook signals, so it is left alone. Mutates `issues` in place.
279
+ */
280
+ function reportNativeEngagementGates(steps, issues) {
281
+ const hasNativeEmail = steps.some((s) => s.kind === "email_send" || s.kind === "email_reply");
282
+ const hasInstantlyEmail = steps.some((s) => s.kind === "email_enroll" || s.kind === "email_move" || s.kind === "email_stop");
283
+ if (!hasNativeEmail || hasInstantlyEmail)
284
+ return;
285
+ steps.forEach((step, index) => {
286
+ if (step.kind === "wait_for_signal" && isNativeUntrackedSignal(step.signal)) {
287
+ issues.push({
288
+ path: `steps[${index}].signal`,
289
+ message: `step '${step.id}' waits for '${step.signal}', but this sequence sends email natively (no Instantly binding), which produces no open/click signals — the gate would always time out. Use email_replied/email_bounced, or bind an Instantly campaign to track opens/clicks.`,
290
+ });
291
+ return;
292
+ }
293
+ if (step.kind === "branch" && typeof step.condition !== "string") {
294
+ const referenced = new Set();
295
+ collectConditionSignals(step.condition, referenced);
296
+ for (const signal of SEQUENCE_NATIVE_UNTRACKED_SIGNALS) {
297
+ if (referenced.has(signal)) {
298
+ issues.push({
299
+ path: `steps[${index}].condition`,
300
+ message: `step '${step.id}' branches on '${signal}', but this sequence sends email natively (no Instantly binding), which produces no open/click signals — the branch would always take the else arm. Use email_replied/email_bounced, or bind an Instantly campaign to track opens/clicks.`,
301
+ });
302
+ }
303
+ }
304
+ }
305
+ });
306
+ }
307
+ /** Collect every signal name referenced anywhere in a signal-branch condition. */
308
+ function collectConditionSignals(condition, out) {
309
+ if ("signal" in condition)
310
+ out.add(condition.signal);
311
+ else if ("all" in condition)
312
+ condition.all.forEach((c) => collectConditionSignals(c, out));
313
+ else if ("any" in condition)
314
+ condition.any.forEach((c) => collectConditionSignals(c, out));
315
+ else
316
+ collectConditionSignals(condition.not, out);
317
+ }
236
318
  function collectSteps(input, issues) {
237
319
  const record = isRecord(input) ? input : null;
238
320
  const steps = record?.steps;
@@ -759,18 +841,13 @@ function normalizeVariants(raw, path, fields, issues) {
759
841
  });
760
842
  return out.length > 0 ? out : undefined;
761
843
  }
762
- function normalizeSendWindow(raw, path, issues) {
763
- if (raw === undefined || raw === null)
764
- return undefined;
765
- if (!isRecord(raw)) {
766
- issues.push({ path, message: "send_window must be an object." });
767
- return undefined;
768
- }
844
+ function normalizeSendWindowConfiguredTimezone(raw, path, issues) {
769
845
  const timezone = typeof raw.timezone === "string" && raw.timezone.trim() ? raw.timezone.trim() : undefined;
770
846
  if (!timezone)
771
847
  issues.push({ path: `${path}.timezone`, message: "timezone (IANA, e.g. America/New_York) is required." });
772
- const start = normalizeHhmm(raw.start, `${path}.start`, issues);
773
- const end = normalizeHhmm(raw.end, `${path}.end`, issues);
848
+ return timezone ?? "UTC";
849
+ }
850
+ function validateSendWindowBounds(start, end, path, issues) {
774
851
  // A zero-width window (start === end) is never open — isWithinSendWindow
775
852
  // returns false for every instant — so a step carrying it defers
776
853
  // (outside_send_window) on every dispatch and the enrollment silently stalls
@@ -779,7 +856,8 @@ function normalizeSendWindow(raw, path, issues) {
779
856
  if (start !== undefined && end !== undefined && hhmmToMinutes(start) === hhmmToMinutes(end)) {
780
857
  issues.push({ path: `${path}.end`, message: "must differ from start — a zero-width send_window never sends." });
781
858
  }
782
- const days = normalizeWeekdays(raw.days, `${path}.days`, issues);
859
+ }
860
+ function normalizeSendWindowTimezoneMode(raw, path, issues) {
783
861
  const mode = raw.timezone_mode === "recipient" ? "recipient" : "fixed";
784
862
  const column = mode === "recipient"
785
863
  ? optionalString(raw.recipient_timezone_column, `${path}.recipient_timezone_column`, issues)
@@ -787,8 +865,23 @@ function normalizeSendWindow(raw, path, issues) {
787
865
  if (mode === "recipient" && !column) {
788
866
  issues.push({ path: `${path}.recipient_timezone_column`, message: 'recipient_timezone_column is required when timezone_mode is "recipient".' });
789
867
  }
868
+ return { mode, column };
869
+ }
870
+ function normalizeSendWindow(raw, path, issues) {
871
+ if (raw === undefined || raw === null)
872
+ return undefined;
873
+ if (!isRecord(raw)) {
874
+ issues.push({ path, message: "send_window must be an object." });
875
+ return undefined;
876
+ }
877
+ const timezone = normalizeSendWindowConfiguredTimezone(raw, path, issues);
878
+ const start = normalizeHhmm(raw.start, `${path}.start`, issues);
879
+ const end = normalizeHhmm(raw.end, `${path}.end`, issues);
880
+ validateSendWindowBounds(start, end, path, issues);
881
+ const days = normalizeWeekdays(raw.days, `${path}.days`, issues);
882
+ const { mode, column } = normalizeSendWindowTimezoneMode(raw, path, issues);
790
883
  return {
791
- timezone: timezone ?? "UTC",
884
+ timezone,
792
885
  ...(days ? { days } : {}),
793
886
  start: start ?? "09:00",
794
887
  end: end ?? "17:00",
@@ -1,2 +1,2 @@
1
- export declare const OXYGEN_VERSION = "1.226.15";
1
+ export declare const OXYGEN_VERSION = "1.242.6";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.226.15";
1
+ export const OXYGEN_VERSION = "1.242.6";
2
2
  // Bump this only when deployed CLI/API contracts require a newer CLI.
3
3
  // 1.181.0: paid table action runs and background columns run require
4
4
  // approved=true in addition to max_credits; older CLIs cannot send the flag.
@@ -15,6 +15,11 @@
15
15
  "types": "./dist/file-import.d.ts",
16
16
  "import": "./dist/file-import.js",
17
17
  "default": "./dist/file-import.js"
18
+ },
19
+ "./custom-http-safety": {
20
+ "types": "./dist/custom-http-safety.d.ts",
21
+ "import": "./dist/custom-http-safety.js",
22
+ "default": "./dist/custom-http-safety.js"
18
23
  }
19
24
  },
20
25
  "dependencies": {}
@@ -44,6 +44,7 @@ export type WorkflowTriggerManifest = {
44
44
  type: "cron";
45
45
  cron: string;
46
46
  timezone?: string | null;
47
+ metadata?: Record<string, unknown>;
47
48
  status?: WorkflowStatus;
48
49
  } | {
49
50
  type: "event";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.226.15",
3
+ "version": "1.242.6",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",