@oxygen-agent/cli 1.1014.1465 → 1.1014.1598
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/command-families/program.js +2 -0
- package/dist/command-manifest.js +12 -2
- package/dist/commands/budget.js +2 -2
- package/dist/commands/capabilities.js +3 -2
- package/dist/commands/columns.js +1 -1
- package/dist/commands/companies.js +3 -0
- package/dist/commands/domains.js +122 -5
- package/dist/commands/feedback.js +1 -1
- package/dist/commands/feeds.js +34 -17
- package/dist/commands/mailboxes.js +19 -9
- package/dist/commands/publishing.js +1 -1
- package/dist/commands/senders.js +2 -2
- package/dist/commands/sequences.js +75 -12
- package/dist/commands/subscriptions.d.ts +10 -0
- package/dist/commands/subscriptions.js +203 -0
- package/dist/commands/tags.js +4 -4
- package/dist/customer-operation-commands.js +17 -0
- package/dist/mailbox-fleet-output.d.ts +3 -0
- package/dist/mailbox-fleet-output.js +80 -0
- package/dist/native-chat-host.d.ts +2 -0
- package/dist/native-chat-host.js +6 -0
- package/dist/workspace-placement-commands.js +67 -2
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +59 -11
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +2 -2
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +18 -0
- package/node_modules/@oxygen/shared/dist/contracts/guided-activation.js +4 -4
- package/node_modules/@oxygen/shared/dist/contracts/native-chat.d.ts +120 -0
- package/node_modules/@oxygen/shared/dist/contracts/native-chat.js +35 -0
- package/node_modules/@oxygen/shared/dist/contracts/sender-limits.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +6 -1
- package/node_modules/@oxygen/shared/dist/contracts/sequence-list.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/contracts/sequence-list.js +2 -1
- package/node_modules/@oxygen/shared/dist/contracts/signal-search-compatibility.d.ts +78 -0
- package/node_modules/@oxygen/shared/dist/contracts/signal-search-compatibility.js +32 -0
- package/node_modules/@oxygen/shared/dist/dnc-rules.js +14 -0
- package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +150 -0
- package/node_modules/@oxygen/shared/dist/email-delivery.js +246 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.js +51 -14
- package/node_modules/@oxygen/shared/dist/email-health-summary.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/email-health-summary.js +28 -0
- package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/email-subscriptions.js +80 -0
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +28 -0
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +47 -0
- package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +4 -16
- package/node_modules/@oxygen/shared/dist/error-redaction.js +8 -17
- package/node_modules/@oxygen/shared/dist/guided-activation.d.ts +15 -1
- package/node_modules/@oxygen/shared/dist/guided-activation.js +3 -1
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/mailbox-standby-rollout.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/mailbox-standby-rollout.js +9 -0
- package/node_modules/@oxygen/shared/dist/mailbox-standby.d.ts +43 -0
- package/node_modules/@oxygen/shared/dist/mailbox-standby.js +52 -0
- package/node_modules/@oxygen/shared/dist/native-chat-discovery.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/native-chat-discovery.js +27 -0
- package/node_modules/@oxygen/shared/dist/native-chat.d.ts +124 -0
- package/node_modules/@oxygen/shared/dist/native-chat.js +1 -0
- package/node_modules/@oxygen/shared/dist/onboarding-plays.d.ts +14 -3
- package/node_modules/@oxygen/shared/dist/onboarding-plays.js +26 -13
- package/node_modules/@oxygen/shared/dist/onboarding-research.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/onboarding-research.js +8 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +6 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +1 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +6 -5
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +44 -2
- package/node_modules/@oxygen/shared/dist/tags.d.ts +7 -3
- package/node_modules/@oxygen/shared/dist/tags.js +31 -6
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-placement-bulk.d.ts +80 -0
- package/node_modules/@oxygen/shared/dist/workspace-placement-bulk.js +68 -0
- package/node_modules/@oxygen/shared/dist/workspace-placement-view.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/workspace-placement.d.ts +11 -1
- package/node_modules/@oxygen/shared/dist/workspace-placement.js +15 -4
- package/node_modules/@oxygen/shared/dist/write-rollout.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/write-rollout.js +59 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +176 -2
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +50 -1
- package/node_modules/@oxygen/workflows/dist/index.js +108 -3
- package/package.json +1 -1
|
@@ -1180,8 +1180,44 @@ function readDailyCapOption(value, flag) {
|
|
|
1180
1180
|
}
|
|
1181
1181
|
return parsed;
|
|
1182
1182
|
}
|
|
1183
|
+
/**
|
|
1184
|
+
* settings.email_delivery from `--delivery` and the sending-domain flags, the
|
|
1185
|
+
* same object the web Sending settings control saves. A sending-domain flag
|
|
1186
|
+
* alone means sending-domain delivery; on update the server keeps the stored
|
|
1187
|
+
* fields a flag does not name. `--delivery inboxes` clears the shape; `""`
|
|
1188
|
+
* clears --from-name and --reply-to-mailbox.
|
|
1189
|
+
*/
|
|
1190
|
+
function readEmailDeliveryOptions(options) {
|
|
1191
|
+
const fields = {};
|
|
1192
|
+
if (options.sendingDomain !== undefined)
|
|
1193
|
+
fields.sending_domain_id = options.sendingDomain.trim();
|
|
1194
|
+
if (options.fromAddress !== undefined)
|
|
1195
|
+
fields.from_address = options.fromAddress.trim();
|
|
1196
|
+
if (options.fromName !== undefined)
|
|
1197
|
+
fields.from_name = options.fromName.trim();
|
|
1198
|
+
if (options.replyToMailbox !== undefined)
|
|
1199
|
+
fields.reply_to_mailbox_id = options.replyToMailbox.trim() || null;
|
|
1200
|
+
if (options.subscriptionList !== undefined)
|
|
1201
|
+
fields.subscription_list_id = options.subscriptionList.trim();
|
|
1202
|
+
const mode = options.delivery?.trim().toLowerCase().replace(/-/g, "_");
|
|
1203
|
+
if (mode === "inboxes") {
|
|
1204
|
+
if (Object.keys(fields).length > 0) {
|
|
1205
|
+
throw new OxygenError("invalid_input", "--delivery inboxes takes no --sending-domain, --from-address, --from-name, --reply-to-mailbox or --subscription-list.", { exitCode: 2 });
|
|
1206
|
+
}
|
|
1207
|
+
return { mode: "inboxes" };
|
|
1208
|
+
}
|
|
1209
|
+
if (mode !== undefined && mode !== "sending_domain") {
|
|
1210
|
+
throw new OxygenError("invalid_input", "--delivery must be inboxes or sending-domain.", { exitCode: 2 });
|
|
1211
|
+
}
|
|
1212
|
+
if (mode === undefined && Object.keys(fields).length === 0)
|
|
1213
|
+
return undefined;
|
|
1214
|
+
return { mode: "sending_domain", ...fields };
|
|
1215
|
+
}
|
|
1183
1216
|
function readSequenceSettings(options) {
|
|
1184
1217
|
const settings = {};
|
|
1218
|
+
const emailDelivery = readEmailDeliveryOptions(options);
|
|
1219
|
+
if (emailDelivery)
|
|
1220
|
+
settings.email_delivery = emailDelivery;
|
|
1185
1221
|
// 0 is a real cap, as on the web's Sending limits (0-50).
|
|
1186
1222
|
const maxPerMailbox = readNonNegativeInt(options.maxEmailsPerMailboxPerDay);
|
|
1187
1223
|
if (maxPerMailbox !== undefined) {
|
|
@@ -1237,6 +1273,8 @@ function readSequenceSettings(options) {
|
|
|
1237
1273
|
settings.bounce_protection = JSON.parse(options.bounceProtection);
|
|
1238
1274
|
if (options.includeUnsubscribeHeaders !== undefined)
|
|
1239
1275
|
settings.include_unsubscribe_headers = options.includeUnsubscribeHeaders;
|
|
1276
|
+
if (options.replyToStopText !== undefined)
|
|
1277
|
+
settings.reply_to_stop_text = options.replyToStopText;
|
|
1240
1278
|
if (options.includeUnsubscribeLink !== undefined) {
|
|
1241
1279
|
settings.include_unsubscribe_link = options.includeUnsubscribeLink;
|
|
1242
1280
|
}
|
|
@@ -1333,6 +1371,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
|
|
|
1333
1371
|
const omit = typeof projection.omit === "string" ? readOption(projection.omit) : undefined;
|
|
1334
1372
|
if (omit)
|
|
1335
1373
|
params.set("omit", omit);
|
|
1374
|
+
params.set("view", typeof projection.view === "string" ? projection.view : "summary");
|
|
1336
1375
|
const suffix = params.toString();
|
|
1337
1376
|
return requestOxygen(`/api/cli/sequences${suffix ? `?${suffix}` : ""}`);
|
|
1338
1377
|
}, formatSequenceListHealth);
|
|
@@ -1591,7 +1630,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
|
|
|
1591
1630
|
.addHelpText("after", "\nSenders: each new lead gets one of the --sender-profiles people, the one with the fewest of this sequence's leads (a lead already talking to one of your LinkedIn accounts keeps that account's person), and its LinkedIn and email steps come from that person (email from one of their inboxes). Per step, in the steps file: email_send.sender_profile_id sends that email from any inbox of one selected person (the normal inbox rotation spreads leads across that person's inboxes and each lead keeps its one; a lead already assigned to that person keeps its own inbox; with none usable the lead waits, never using a colleague's inbox), and email_send.sender_mailbox_id from one exact inbox (set one or neither); a LinkedIn step's sender_profile_id sends it from one selected person. Invite, message, inmail and withdraw_invite steps must share one sender_profile_id or none: that person then owns every lead's LinkedIn thread (LinkedIn allows one of your accounts per conversation). visit_profile, follow, like_post and comment_post may each name anyone selected. An email whose From changes, including the return to the lead's inbox, needs its own subject_template. To check who sends each step, run `sequences start <sequence>` without --approved; nothing changes.\n")
|
|
1592
1631
|
.requiredOption("--name <name>", "Human-readable sequence name.")
|
|
1593
1632
|
.requiredOption("--slug <slug>", "Unique slug for the sequence.")
|
|
1594
|
-
.requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Every step's copy can also use {{sender_first_name}}, {{sender_last_name}} and {{sender_full_name}} (alias {{sender_name}}): the name of the person who sends that step (their sender profile, else the account or inbox display name), with no table column needed; email steps add {{sender_email}} (the from address). A row column of the same name WINS on collision, and a missing name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a contact leaf { has_contact: \"email\" | \"linkedin\" | \"phone\" } is true when that step's channel can reach the lead (resolved as sending resolves it; add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm; a data leaf { has_column: \"<key>\" } tests one row_values column. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. MINIMAL WAIT STEP SHAPE: { \"id\": \"w1\", \"kind\": \"wait\", \"days\": 3 } — days and/or hours, at least 1 hour in total. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, read `oxygen skills get oxygen-sequencer --file references/calls-and-crm-tasks.md`.")
|
|
1633
|
+
.requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Every step's copy can also use {{sender_first_name}}, {{sender_last_name}} and {{sender_full_name}} (alias {{sender_name}}): the name of the person who sends that step (their sender profile, else the account or inbox display name), with no table column needed; email steps add {{sender_email}} (the from address). A row column of the same name WINS on collision, and a missing name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a contact leaf { has_contact: \"email\" | \"linkedin\" | \"phone\" } is true when that step's channel can reach the lead (resolved as sending resolves it; add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm; a data leaf { has_column: \"<key>\" } tests one row_values column. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. WAIT OPTIONS: add working_days:true to count schedule weekdays (Mon-Fri by default). On wait_for_connection, withdraw_on_timeout:true withdraws an unanswered invite under the normal sender limits before on_timeout stop/continue; configure it while draft. Existing wait steps may change working_days after start with the same step IDs, kinds and order; connection-wait settings remain locked (paused included). MINIMAL WAIT STEP SHAPE: { \"id\": \"w1\", \"kind\": \"wait\", \"days\": 3 } — days and/or hours, at least 1 hour in total. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, read `oxygen skills get oxygen-sequencer --file references/calls-and-crm-tasks.md`.")
|
|
1595
1634
|
.option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp,call,crm. Defaults to the channels the journey touches. crm_task currently supports HubSpot.")
|
|
1596
1635
|
.option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live — WhatsApp via Unipile is unofficial WhatsApp Web, so cold-initiating is an explicit ban-risk opt-in.")
|
|
1597
1636
|
.addOption(new Option("--whatsapp-duplicate-scope <scope>", "Prevent duplicate WhatsApp copy in this campaign (default) or all campaigns.").choices(["campaign", "workspace"]))
|
|
@@ -1633,12 +1672,19 @@ export function registerSequencesFamily(program, handleAsyncAction) {
|
|
|
1633
1672
|
.option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
|
|
1634
1673
|
.option("--auto-optimize", "Enable the sequence-wide A/B auto-winner (default on).")
|
|
1635
1674
|
.option("--no-auto-optimize", "Disable the sequence-wide A/B auto-winner; manual variant decisions stay available.")
|
|
1636
|
-
.option("--
|
|
1675
|
+
.option("--reply-to-stop-text <text>", "Optional first-touch email footer (max 500 characters); empty clears.")
|
|
1676
|
+
.option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as the Sending settings switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. Use --reply-to-stop-text for an optional first-touch email footer. `sequences update` can switch on the link or the headers alone.")
|
|
1637
1677
|
.option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
|
|
1638
1678
|
.option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
1639
1679
|
.option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
|
|
1640
1680
|
.option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
1641
1681
|
.option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
|
|
1682
|
+
.addOption(new Option("--delivery <mode>", "How email steps leave: inboxes (default: the connected mailbox pool) or sending-domain (bulk mail to a subscription list's subscribers from one From address, through a sending domain in Sequencer → Accounts; unsubscribe link and headers always on, table rows never auto-enroll).").choices(["inboxes", "sending-domain"]))
|
|
1683
|
+
.option("--sending-domain <idOrDomain>", "Sending-domain delivery: the sending domain, by id or domain name (e.g. news.acme.com).")
|
|
1684
|
+
.option("--from-address <email>", "Sending-domain delivery: the From address, on the sending domain (and in its allowed From addresses, if it lists any).")
|
|
1685
|
+
.option("--from-name <name>", "Sending-domain delivery: the From display name.")
|
|
1686
|
+
.option("--reply-to-mailbox <id>", "Sending-domain delivery: the connected inbox id that receives answers. Defaults to the sending domain's Reply-To inbox.")
|
|
1687
|
+
.option("--subscription-list <idOrKey>", "Sending-domain delivery: the subscription list, by id or key, whose subscribed addresses this sends to.")
|
|
1642
1688
|
.option("--tags <csv>", "Comma-separated workspace tags linking this sequence to tables, workflows, and knowledge.")
|
|
1643
1689
|
.option("--json", "Print a JSON envelope.")
|
|
1644
1690
|
.action(async (options) => {
|
|
@@ -1728,7 +1774,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
|
|
|
1728
1774
|
.option("--activation-id <id>", "Guided activation UUID; requires --activation-revision and --expected-revision.")
|
|
1729
1775
|
.option("--activation-revision <revision>", "Current guided activation revision; requires --activation-id and --expected-revision.")
|
|
1730
1776
|
.option("--name <name>", "New human-readable sequence name.")
|
|
1731
|
-
.option("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] } replacing the journey. Once started, only wait
|
|
1777
|
+
.option("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] } replacing the journey. Once started (paused included), only existing wait steps may change days/hours/jitter_hours/working_days: same step IDs, kinds and order (zero a delay, never delete its step). Connection-wait timeout_days/on_timeout/withdraw_on_timeout are draft-only; duplicate to change them after start. Copy supports {{column}} interpolation; every step also takes {{sender_first_name}} / {{sender_last_name}} / {{sender_full_name}} for the person who sends it, and email adds {{sender_email}} (a same-named row column wins). A `branch` can also route on the lead's data with a contact leaf { has_contact: \"email\" | \"linkedin\" | \"phone\" } or a data leaf { has_column: \"<key>\" } (present:false for \"missing\").")
|
|
1732
1778
|
.option("--channels <list>", "Draft only: comma-separated channels: linkedin,email,whatsapp,call,crm.")
|
|
1733
1779
|
.option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live (draft-editable).")
|
|
1734
1780
|
.option("--no-whatsapp-cold-initiate", "WhatsApp: stop allowing cold chats; a live WhatsApp start then refuses.")
|
|
@@ -1737,7 +1783,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
|
|
|
1737
1783
|
.option("--email-column-key <key>", "Native email: row_values key holding each lead's email (else falls back to email/email_address/work_email). Maps a non-English header (e.g. E-Mail). \"\" clears it back to auto-detect.")
|
|
1738
1784
|
.option("--linkedin-url-column-key <key>", "LinkedIn: row_values key holding each lead's profile URL (else falls back to linkedin_url/linkedinUrl/linkedin/profile_url). \"\" clears it back to auto-detect.")
|
|
1739
1785
|
.option("--senders <ids>", "LinkedIn or WhatsApp accounts attached directly, besides the --sender-profiles people's own accounts: comma-separated sender account ids (or connection / Unipile ids; `senders list` / `whatsapp accounts` list them). Replaces the directly attached set; \"\" removes them all and keeps the selected senders. `sequences get` lists both under sender_pool. Draft/paused only.")
|
|
1740
|
-
.option("--source-table <idOrSlug>", "Bind the table this sequence enrolls leads from (id or slug).
|
|
1786
|
+
.option("--source-table <idOrSlug>", "Bind the table this sequence enrolls leads from (id or slug). Draft/paused only. Rebinding preserves existing enrollments and their source-row provenance.")
|
|
1741
1787
|
.option("--email-provider <provider>", "Optional linked-campaign provider: 'instantly'. Omit to preserve the existing binding; --clear-email removes it on a draft. Native email needs no provider binding and sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport.")
|
|
1742
1788
|
.option("--email-connection <id>", "Instantly connection id for the email track.")
|
|
1743
1789
|
.option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences).")
|
|
@@ -1776,7 +1822,8 @@ export function registerSequencesFamily(program, handleAsyncAction) {
|
|
|
1776
1822
|
.option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
|
|
1777
1823
|
.option("--auto-optimize", "Turn the A/B auto-winner on (the default): every step with variants is judged on replies, pausing losers once each variant has 100 sends and 5 replies, unless the step sets its own auto_optimize.")
|
|
1778
1824
|
.option("--no-auto-optimize", "Disable the sequence-wide A/B auto-winner; manual variant decisions stay available.")
|
|
1779
|
-
.option("--
|
|
1825
|
+
.option("--reply-to-stop-text <text>", "Optional first-touch email footer (max 500 characters); empty clears.")
|
|
1826
|
+
.option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as the Sending settings switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. Use --reply-to-stop-text for an optional first-touch email footer.")
|
|
1780
1827
|
.option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
|
|
1781
1828
|
.option("--include-unsubscribe-headers", "Only the one-click unsubscribe headers (default off); --include-unsubscribe turns on the headers and the visible link together.")
|
|
1782
1829
|
.option("--no-include-unsubscribe-headers", "Omit one-click headers.")
|
|
@@ -1786,6 +1833,12 @@ export function registerSequencesFamily(program, handleAsyncAction) {
|
|
|
1786
1833
|
.option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
|
|
1787
1834
|
.option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
1788
1835
|
.option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
|
|
1836
|
+
.addOption(new Option("--delivery <mode>", "How email steps leave: inboxes (the connected mailbox pool; clears the sending-domain settings) or sending-domain (bulk mail to a subscription list's subscribers through a sending domain). Draft or paused to change the account or list.").choices(["inboxes", "sending-domain"]))
|
|
1837
|
+
.option("--sending-domain <idOrDomain>", "Sending-domain delivery: the sending domain, by id or domain name. Any sending-domain flag alone keeps the other stored fields.")
|
|
1838
|
+
.option("--from-address <email>", "Sending-domain delivery: the From address, on the sending domain.")
|
|
1839
|
+
.option("--from-name <name>", "Sending-domain delivery: the From display name; \"\" clears it.")
|
|
1840
|
+
.option("--reply-to-mailbox <id>", "Sending-domain delivery: the connected inbox id that receives answers; \"\" clears it.")
|
|
1841
|
+
.option("--subscription-list <idOrKey>", "Sending-domain delivery: the subscription list, by id or key, whose subscribed addresses this sends to.")
|
|
1789
1842
|
.option("--tags <csv>", "Replace the sequence's workspace tags (comma-separated; \"\" clears them). The ONE field an ARCHIVED sequence still accepts — tag finished campaigns to link their learnings across the workspace.")
|
|
1790
1843
|
.option("--json", "Print a JSON envelope.")
|
|
1791
1844
|
.action(async (sequence, options) => {
|
|
@@ -1877,10 +1930,12 @@ export function registerSequencesFamily(program, handleAsyncAction) {
|
|
|
1877
1930
|
await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${query}`));
|
|
1878
1931
|
}))
|
|
1879
1932
|
.addCommand(new Command("enroll")
|
|
1880
|
-
.description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. In a
|
|
1933
|
+
.description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. In a mixed journey a row with no usable LinkedIn profile still enrolls when its email or phone reaches another step, or it has a stable table_row_id or lead_provider_id for crm_task steps. CRM tasks do not require email, LinkedIn or phone; their configured record identities and property mappings are validated when planned. Missing-channel steps are skipped; LinkedIn skips use no_linkedin_profile, a connection wait passes at once and a connection branch takes its not-connected path. These rows appear in linkedin_unavailable_lead_details; unresolved_linkedin_leads names only rows that were not enrolled. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn + email sequence auto-files an email-only lead the same way; a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those; in a sequence with LinkedIn steps a lead with a lead_profile_url and no table_row_id is filed the same way, onto the person's existing row in the bound table (another spelling of the same profile URL matches) or as a new row, deduped by profile URL, and a person the sequence already holds keeps that enrollment; where the sequence has no table and every new table needs a client signoff the enroll is refused instead (sequence_leads_table_needs_signoff). Like any table-row lead a filed one cannot re-enroll in that sequence once its enrollment ends; leads_without_table_row counts the leads in the request that no table lists (those given only a lead_provider_id, and people the sequence already holds without a row). A CRM-task lead in a CRM-only or mixed journey needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone or profile URL for auto-filed leads). WhatsApp-only campaigns also enroll each normalized number once across all source rows and enrollment statuses; mixed campaigns keep other channels and skip duplicate WhatsApp actions. Duplicate protection defaults to this campaign; --whatsapp-duplicate-scope on create/update can additionally block identical copy across all campaigns. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`. esp.pool_scope identifies the active workspace inventory used for routing and strict_deferred_leads estimates; these estimates omit sequence sender restrictions, readiness and daily limits. A zero estimate does not guarantee readiness. Review sequences preview before launch.")
|
|
1881
1934
|
.argument("<sequence>", "Sequence id or slug.")
|
|
1882
|
-
.option("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ row_values: { email }, lead_name }] } for cold email; { lead_profile_url, lead_name, row_values } or { lead_provider_id, lead_name, table_row_id, row_values } for LinkedIn/existing rows; CRM-
|
|
1883
|
-
.option("--from-table", "Enroll every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; re-run to continue). Exactly one of --leads-file or --from-
|
|
1935
|
+
.option("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ row_values: { email }, lead_name }] } for cold email; { lead_profile_url, lead_name, row_values } or { lead_provider_id, lead_name, table_row_id, row_values } for LinkedIn/existing rows; CRM-task leads in single/mixed journeys require table_row_id or a stable lead_provider_id alongside mapped row_values. A CRM association object id is not the enrollment identity. Exactly one of --leads-file, --from-table or --from-list.")
|
|
1936
|
+
.option("--from-table", "Enroll every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; re-run to continue). Exactly one of --leads-file, --from-table or --from-list.")
|
|
1937
|
+
.option("--from-list <idOrKey>", "Sending-domain Sequences: enroll up to 500 subscribed addresses of the Sequence's own subscription list per run, each filed as an email-only lead in its leads table (another list is refused: sequence_from_list_mismatch). Re-run with --from-list-after <from_list.next_after> while from_list.has_more.")
|
|
1938
|
+
.option("--from-list-after <email>", "With --from-list: continue after this address (the previous run's from_list.next_after).")
|
|
1884
1939
|
.option("--exclude-contacted", "Also skip leads any OTHER active sequence is already contacting (cross-campaign exclusion), for THIS enroll. Off by default; forces exclusion on even if the sequence's exclude_contacted default is off.")
|
|
1885
1940
|
.option("--no-exclude-contacted", "Force-INCLUDE cross-campaign leads for this enroll even when the sequence's exclude_contacted default is on (per-call override).")
|
|
1886
1941
|
.option("--suppress-list <ids>", "Per-call do-not-enroll lead provider ids dropped for this enroll only: a comma-separated list, or @<path> to a file of ids (comma/whitespace separated).")
|
|
@@ -1889,11 +1944,12 @@ export function registerSequencesFamily(program, handleAsyncAction) {
|
|
|
1889
1944
|
.action(async (sequence, options) => {
|
|
1890
1945
|
await handleAsyncAction("sequences enroll", options, () => {
|
|
1891
1946
|
const leadsPath = readOption(options.leadsFile);
|
|
1892
|
-
|
|
1893
|
-
|
|
1947
|
+
const fromList = readOption(options.fromList);
|
|
1948
|
+
if ([Boolean(options.fromTable), Boolean(leadsPath), Boolean(fromList)].filter(Boolean).length !== 1) {
|
|
1949
|
+
throw new Error("Provide exactly one of --leads-file, --from-table or --from-list.");
|
|
1894
1950
|
}
|
|
1895
|
-
if (
|
|
1896
|
-
throw new Error("
|
|
1951
|
+
if (options.fromListAfter !== undefined && !fromList) {
|
|
1952
|
+
throw new Error("--from-list-after needs --from-list.");
|
|
1897
1953
|
}
|
|
1898
1954
|
const suppressList = parseSuppressListOption(options.suppressList);
|
|
1899
1955
|
const sharedFlags = {
|
|
@@ -1909,6 +1965,13 @@ export function registerSequencesFamily(program, handleAsyncAction) {
|
|
|
1909
1965
|
body: { from_table: true, ...sharedFlags },
|
|
1910
1966
|
});
|
|
1911
1967
|
}
|
|
1968
|
+
if (fromList) {
|
|
1969
|
+
const after = readOption(options.fromListAfter);
|
|
1970
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enroll`, {
|
|
1971
|
+
method: "POST",
|
|
1972
|
+
body: { from_list: fromList, ...(after ? { from_list_after: after } : {}), ...sharedFlags },
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1912
1975
|
const parsed = readJsonFileValue(resolve(leadsPath), "--leads-file");
|
|
1913
1976
|
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enroll`, {
|
|
1914
1977
|
method: "POST",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ExtractedCommandActionHandler } from "../command-support.js";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
type ImportEntry = {
|
|
4
|
+
email: string;
|
|
5
|
+
person_id?: string;
|
|
6
|
+
};
|
|
7
|
+
/** Read --file as JSON (by extension or content) or as CSV with an email column. */
|
|
8
|
+
export declare function readSubscriptionImportFile(path: string): ImportEntry[];
|
|
9
|
+
export declare function registerSubscriptionsFamily(program: Command, handleAsyncAction: ExtractedCommandActionHandler): void;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { requestOxygen } from "../http-client.js";
|
|
2
|
+
import { isRecord, readOption } from "../util.js";
|
|
3
|
+
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
function evidenceBody(options) {
|
|
8
|
+
const noticeVersion = readOption(options.noticeVersion);
|
|
9
|
+
const noticeTextHash = readOption(options.noticeTextHash);
|
|
10
|
+
const formUrl = readOption(options.formUrl);
|
|
11
|
+
const basis = readOption(options.basis);
|
|
12
|
+
const evidence = {
|
|
13
|
+
...(noticeVersion ? { notice_version: noticeVersion } : {}),
|
|
14
|
+
...(noticeTextHash ? { notice_text_hash: noticeTextHash } : {}),
|
|
15
|
+
...(formUrl ? { form_url: formUrl } : {}),
|
|
16
|
+
...(basis ? { basis } : {}),
|
|
17
|
+
};
|
|
18
|
+
return Object.keys(evidence).length > 0 ? evidence : undefined;
|
|
19
|
+
}
|
|
20
|
+
function requireSubscribeEvidence(options, status) {
|
|
21
|
+
if (status !== "subscribed")
|
|
22
|
+
return;
|
|
23
|
+
const notice = readOption(options.noticeVersion) && readOption(options.noticeTextHash);
|
|
24
|
+
if (!notice && !readOption(options.basis)) {
|
|
25
|
+
throw new OxygenError("consent_evidence_required", "Subscribing needs consent evidence: --notice-version with --notice-text-hash, or --basis naming where consent came from.", { details: { field: "evidence" }, exitCode: 2 });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Split one CSV line, honouring double-quoted fields with "" escapes. */
|
|
29
|
+
function splitCsvLine(line) {
|
|
30
|
+
const cells = [];
|
|
31
|
+
let cell = "";
|
|
32
|
+
let quoted = false;
|
|
33
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
34
|
+
const char = line[index];
|
|
35
|
+
if (quoted) {
|
|
36
|
+
if (char === "\"" && line[index + 1] === "\"") {
|
|
37
|
+
cell += "\"";
|
|
38
|
+
index += 1;
|
|
39
|
+
}
|
|
40
|
+
else if (char === "\"")
|
|
41
|
+
quoted = false;
|
|
42
|
+
else
|
|
43
|
+
cell += char;
|
|
44
|
+
}
|
|
45
|
+
else if (char === "\"")
|
|
46
|
+
quoted = true;
|
|
47
|
+
else if (char === ",") {
|
|
48
|
+
cells.push(cell.trim());
|
|
49
|
+
cell = "";
|
|
50
|
+
}
|
|
51
|
+
else
|
|
52
|
+
cell += char;
|
|
53
|
+
}
|
|
54
|
+
cells.push(cell.trim());
|
|
55
|
+
return cells;
|
|
56
|
+
}
|
|
57
|
+
function csvEntries(text) {
|
|
58
|
+
const lines = text.replace(/^/, "").split(/\r?\n/).filter((line) => line.trim());
|
|
59
|
+
const header = splitCsvLine(lines[0] ?? "").map((cell) => cell.toLowerCase());
|
|
60
|
+
const emailIndex = header.findIndex((cell) => cell === "email" || cell === "email_address" || cell === "e-mail");
|
|
61
|
+
if (emailIndex < 0) {
|
|
62
|
+
throw new OxygenError("invalid_request", "The CSV needs an email column in its header row.", { exitCode: 2 });
|
|
63
|
+
}
|
|
64
|
+
const personIndex = header.findIndex((cell) => cell === "person_id");
|
|
65
|
+
return lines.slice(1).map((line) => {
|
|
66
|
+
const cells = splitCsvLine(line);
|
|
67
|
+
const personId = personIndex >= 0 ? cells[personIndex] : undefined;
|
|
68
|
+
return { email: cells[emailIndex] ?? "", ...(personId ? { person_id: personId } : {}) };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function jsonEntries(value) {
|
|
72
|
+
const list = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.entries) ? value.entries : null;
|
|
73
|
+
if (!list) {
|
|
74
|
+
throw new OxygenError("invalid_request", "The JSON file must be a list of emails or { email, person_id } objects, or { entries: [...] }.", { exitCode: 2 });
|
|
75
|
+
}
|
|
76
|
+
return list.map((entry) => {
|
|
77
|
+
if (typeof entry === "string")
|
|
78
|
+
return { email: entry };
|
|
79
|
+
if (isRecord(entry) && typeof entry.email === "string") {
|
|
80
|
+
return { email: entry.email, ...(typeof entry.person_id === "string" ? { person_id: entry.person_id } : {}) };
|
|
81
|
+
}
|
|
82
|
+
return { email: "" };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Read --file as JSON (by extension or content) or as CSV with an email column. */
|
|
86
|
+
export function readSubscriptionImportFile(path) {
|
|
87
|
+
const text = readFileSync(resolve(path), "utf8");
|
|
88
|
+
const trimmed = text.trim();
|
|
89
|
+
if (path.toLowerCase().endsWith(".json") || trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(trimmed);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new OxygenError("invalid_request", "--file is not valid JSON.", { exitCode: 2 });
|
|
96
|
+
}
|
|
97
|
+
return jsonEntries(parsed);
|
|
98
|
+
}
|
|
99
|
+
return csvEntries(text);
|
|
100
|
+
}
|
|
101
|
+
function requireFlag(value, flag) {
|
|
102
|
+
const read = readOption(value);
|
|
103
|
+
if (!read)
|
|
104
|
+
throw new OxygenError("invalid_request", `${flag} is required.`, { exitCode: 2 });
|
|
105
|
+
return read;
|
|
106
|
+
}
|
|
107
|
+
function runSubscriptionsSet(email, options) {
|
|
108
|
+
const list = requireFlag(options.list, "--list");
|
|
109
|
+
const status = requireFlag(options.status, "--status");
|
|
110
|
+
requireSubscribeEvidence(options, status);
|
|
111
|
+
const source = readOption(options.source);
|
|
112
|
+
const evidence = evidenceBody(options);
|
|
113
|
+
return requestOxygen("/api/cli/subscriptions", {
|
|
114
|
+
method: "POST",
|
|
115
|
+
body: {
|
|
116
|
+
list,
|
|
117
|
+
email,
|
|
118
|
+
status,
|
|
119
|
+
...(source ? { source } : {}),
|
|
120
|
+
...(evidence ? { evidence } : {}),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function runSubscriptionsImport(options) {
|
|
125
|
+
const list = requireFlag(options.list, "--list");
|
|
126
|
+
const source = requireFlag(options.source, "--source");
|
|
127
|
+
const status = readOption(options.status) ?? "subscribed";
|
|
128
|
+
requireSubscribeEvidence(options, status);
|
|
129
|
+
const entries = readSubscriptionImportFile(requireFlag(options.file, "--file"));
|
|
130
|
+
const evidence = evidenceBody(options);
|
|
131
|
+
return requestOxygen("/api/cli/subscriptions/import", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
body: {
|
|
134
|
+
list,
|
|
135
|
+
entries,
|
|
136
|
+
status,
|
|
137
|
+
source,
|
|
138
|
+
...(evidence ? { evidence } : {}),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function addEvidenceFlags(command) {
|
|
143
|
+
return command
|
|
144
|
+
.option("--notice-version <version>", "Version of the consent notice the person saw (with --notice-text-hash).")
|
|
145
|
+
.option("--notice-text-hash <sha256>", "sha256 hex of the exact consent notice text shown.")
|
|
146
|
+
.option("--form-url <url>", "Where the person gave consent.")
|
|
147
|
+
.option("--basis <text>", "Where consent came from, e.g. \"Webinar signup form, Oct 2026\".");
|
|
148
|
+
}
|
|
149
|
+
export function registerSubscriptionsFamily(program, handleAsyncAction) {
|
|
150
|
+
program.addCommand(new Command("subscriptions")
|
|
151
|
+
.description("Subscription lists and consent for bulk email: who agreed to hear from you (newsletter, product updates, onboarding). Sending-domain Sequences send only to subscribed addresses on their list, on top of do-not-contact. Every change records its consent evidence. 0 credits.")
|
|
152
|
+
.addCommand(new Command("lists")
|
|
153
|
+
.description("Subscription lists.")
|
|
154
|
+
.addCommand(new Command("list")
|
|
155
|
+
.description("List subscription lists with subscribed, unsubscribed, pending and cleaned counts.")
|
|
156
|
+
.option("--json", "Print a JSON envelope.")
|
|
157
|
+
.action(async (options) => {
|
|
158
|
+
await handleAsyncAction("subscriptions lists list", options, () => requestOxygen("/api/cli/subscriptions/lists"));
|
|
159
|
+
}))
|
|
160
|
+
.addCommand(new Command("create")
|
|
161
|
+
.description("Create a subscription list.")
|
|
162
|
+
.argument("<key>", "Stable key: lowercase letters, digits, - and _, such as newsletter.")
|
|
163
|
+
.requiredOption("--name <name>", "Display name.")
|
|
164
|
+
.requiredOption("--kind <kind>", "marketing, product_updates or onboarding.")
|
|
165
|
+
.option("--description <text>", "What subscribers get.")
|
|
166
|
+
.option("--json", "Print a JSON envelope.")
|
|
167
|
+
.action(async (key, options) => {
|
|
168
|
+
await handleAsyncAction("subscriptions lists create", options, () => {
|
|
169
|
+
const description = readOption(options.description);
|
|
170
|
+
return requestOxygen("/api/cli/subscriptions/lists", {
|
|
171
|
+
method: "POST",
|
|
172
|
+
body: { key, name: options.name, kind: options.kind, ...(description ? { description } : {}) },
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
})))
|
|
176
|
+
.addCommand(new Command("get")
|
|
177
|
+
.description("Show one address's status on every list it is on.")
|
|
178
|
+
.argument("<email>", "Email address.")
|
|
179
|
+
.option("--json", "Print a JSON envelope.")
|
|
180
|
+
.action(async (email, options) => {
|
|
181
|
+
await handleAsyncAction("subscriptions get", options, () => requestOxygen(`/api/cli/subscriptions?${new URLSearchParams({ email }).toString()}`));
|
|
182
|
+
}))
|
|
183
|
+
.addCommand(addEvidenceFlags(new Command("set")
|
|
184
|
+
.description("Set one address's status on one list and record the consent event. Subscribing needs evidence.")
|
|
185
|
+
.argument("<email>", "Email address.")
|
|
186
|
+
.requiredOption("--list <key-or-id>", "Subscription list key or id.")
|
|
187
|
+
.requiredOption("--status <status>", "subscribed, unsubscribed, pending or cleaned.")
|
|
188
|
+
.option("--source <source>", "Where this change came from (default api)."))
|
|
189
|
+
.option("--json", "Print a JSON envelope.")
|
|
190
|
+
.action(async (email, options) => {
|
|
191
|
+
await handleAsyncAction("subscriptions set", options, () => runSubscriptionsSet(email, options));
|
|
192
|
+
}))
|
|
193
|
+
.addCommand(addEvidenceFlags(new Command("import")
|
|
194
|
+
.description("Import up to 5,000 addresses onto one list under one consent evidence record. --file is JSON (emails or { email, person_id }) or CSV with an email column. Addresses cleaned by a complaint or hard bounce stay off; invalid rows are reported by index.")
|
|
195
|
+
.requiredOption("--list <key-or-id>", "Subscription list key or id.")
|
|
196
|
+
.requiredOption("--file <path>", "JSON or CSV file of addresses.")
|
|
197
|
+
.requiredOption("--source <source>", "Where these addresses came from, e.g. webinar_2026_10.")
|
|
198
|
+
.option("--status <status>", "Status to set (default subscribed)."))
|
|
199
|
+
.option("--json", "Print a JSON envelope.")
|
|
200
|
+
.action(async (options) => {
|
|
201
|
+
await handleAsyncAction("subscriptions import", options, () => runSubscriptionsImport(options));
|
|
202
|
+
})));
|
|
203
|
+
}
|
package/dist/commands/tags.js
CHANGED
|
@@ -36,7 +36,7 @@ function tagRegistryBody(options) {
|
|
|
36
36
|
export function registerTagsFamily(program, handleAsyncAction) {
|
|
37
37
|
program
|
|
38
38
|
.command("tags")
|
|
39
|
-
.description(`Workspace tags: one vocabulary across ${TAG_KINDS_PROSE}. Tags
|
|
39
|
+
.description(`Workspace tags: one vocabulary across ${TAG_KINDS_PROSE}. Tags preserve entered case (trimmed, deduped case-insensitively, max 50 per item). ATTACH with a carrier's own tag command (e.g. \`oxygen inbox tag\`, \`oxygen tables tag\`) or use \`oxygen tags attach <kind> <ref> --tags <labels>\` for Signal types, saved Dashboards, runtime Agents, and saved Ads searches. Then \`tags get <tag>\` returns every carrier with deep-links. CURATE the vocabulary here: \`tags create\` declares a tag (with a description, color, and pin) before anything carries it, \`tags update\` re-annotates one, and \`tags rename\`/\`merge\`/\`delete\` reshape it everywhere at once. Tags are free, inert metadata: attaching or removing one costs 0 credits, makes no provider call, and changes no sending, routing, or warm-up behaviour.`)
|
|
40
40
|
.addCommand(new Command("list")
|
|
41
41
|
.description("List every workspace tag with per-primitive counts, most-used first.")
|
|
42
42
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -56,9 +56,9 @@ export function registerTagsFamily(program, handleAsyncAction) {
|
|
|
56
56
|
: `/api/cli/tags/${encodeURIComponent(tag)}`));
|
|
57
57
|
}))
|
|
58
58
|
.addCommand(new Command("rename")
|
|
59
|
-
.description("Rename one tag EVERYWHERE it appears (every primitive, archived rows included). Preview-first: without --apply, prints per-kind would-change counts and mutates nothing. Idempotent — safe to re-run after a partial failure.")
|
|
59
|
+
.description("Rename one tag EVERYWHERE it appears (every primitive, archived rows included). Preview-first: without --apply, prints per-kind would-change counts and mutates nothing. Case-only renames are supported (e.g. event name capitalization), preserving the new spelling while matching case-insensitively. Idempotent — safe to re-run after a partial failure.")
|
|
60
60
|
.argument("<from>", "The current tag (case-insensitive).")
|
|
61
|
-
.argument("<to>", "The new tag (
|
|
61
|
+
.argument("<to>", "The new tag (case preserved; matching ignores case).")
|
|
62
62
|
.option("--apply", "Execute the rename. Without this flag, returns a preview of the counts only.")
|
|
63
63
|
.option("--json", "Print a JSON envelope.")
|
|
64
64
|
.action(async (from, to, options) => {
|
|
@@ -69,7 +69,7 @@ export function registerTagsFamily(program, handleAsyncAction) {
|
|
|
69
69
|
}))
|
|
70
70
|
.addCommand(new Command("create")
|
|
71
71
|
.description("Declare a tag before anything carries it, with an optional description, color, and pin. Idempotent: run it on a tag already in use to annotate it — carriers are never touched.")
|
|
72
|
-
.argument("<label>", "The tag (
|
|
72
|
+
.argument("<label>", "The tag (case and spaces preserved; matching ignores case).")
|
|
73
73
|
.option("--description <text>", "What this tag means in this workspace.")
|
|
74
74
|
.option("--color <color>", `Pill color. One of: ${TAG_COLORS.join(", ")}. Omit to derive it from the label.`)
|
|
75
75
|
.option("--pinned", "Pin it to the top of the tag list and the tag picker.")
|
|
@@ -9,6 +9,10 @@ import { requestOxygen } from "./http-client.js";
|
|
|
9
9
|
// Only newly registered groups use these purposes; manual owners retain their
|
|
10
10
|
// descriptions, aliases and children.
|
|
11
11
|
const GROUP_PURPOSES = {
|
|
12
|
+
chat: "Dev only: persistent workspace support threads and shared files.",
|
|
13
|
+
"chat threads": "Dev only: create and continue native support threads.",
|
|
14
|
+
"chat files": "Dev only: upload and download files in a native support thread.",
|
|
15
|
+
"chat admin": "Staff only. Dev native support channels, priority and customer significance.",
|
|
12
16
|
"copilot policy": "Set an attended Copilot session's approval policy.",
|
|
13
17
|
"context canonical": "Read the workspace's pinned canonical context asset.",
|
|
14
18
|
"directory logo": "Upload the public agency listing's logo.",
|
|
@@ -108,6 +112,19 @@ export function registerCustomerOperationCommands(program, handle) {
|
|
|
108
112
|
option.makeOptionMandatory();
|
|
109
113
|
}
|
|
110
114
|
}
|
|
115
|
+
// Commander validates mandatory leaf options before preAction/action hooks.
|
|
116
|
+
// Recover explicitly typed ancestor flags after reset, before leaf parsing.
|
|
117
|
+
parent.hook("preSubcommand", (_parent, selected) => {
|
|
118
|
+
if (selected !== command)
|
|
119
|
+
return;
|
|
120
|
+
const inherited = ancestorConsumedOptions(command, selected, command.opts());
|
|
121
|
+
for (const option of command.options) {
|
|
122
|
+
const key = option.attributeName();
|
|
123
|
+
if (command.getOptionValue(key) === undefined && inherited[key] !== undefined) {
|
|
124
|
+
command.setOptionValueWithSource(key, inherited[key], "cli");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
111
128
|
command.action((options, selected) => handle(spec.cli.path.join(" "), { ...options, json: selected.optsWithGlobals().json === true }, async () => {
|
|
112
129
|
let input;
|
|
113
130
|
try {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
function record(value) {
|
|
2
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3
|
+
}
|
|
4
|
+
function count(value) { return typeof value === "number" ? String(value) : "unknown"; }
|
|
5
|
+
function text(value) { return typeof value === "string" ? value : "unknown"; }
|
|
6
|
+
function counts(value) {
|
|
7
|
+
const entries = Object.entries(record(value));
|
|
8
|
+
return entries.length ? entries.map(([key, value]) => `${key} ${count(value)}`).join(" · ") : "none reported";
|
|
9
|
+
}
|
|
10
|
+
/** Fleet evidence precedes identity rows in both the native JSON and text views. */
|
|
11
|
+
export function formatMailboxFleetHealth(data) {
|
|
12
|
+
const coverage = record(data.health_coverage);
|
|
13
|
+
const signals = record(data.signals);
|
|
14
|
+
const warmup = record(data.warmup);
|
|
15
|
+
const summary = record(data.fleet_summary);
|
|
16
|
+
const score = typeof summary.score === "number" && Number.isFinite(summary.score) ? `${summary.score.toFixed(1)}/100` : "unmeasured";
|
|
17
|
+
const lines = [
|
|
18
|
+
`Fleet: ${count(coverage.total)} inboxes · ${text(data.fleet_state)}`,
|
|
19
|
+
...(Object.keys(summary).length ? [
|
|
20
|
+
`Recorded health: ${score} · ${count(summary.scored)}/${count(summary.total ?? coverage.total)} scored · ${text(summary.coverage)} coverage · ${text(summary.freshness)} freshness${typeof summary.unverified === "number" && summary.unverified > 0 ? ` · ${count(summary.unverified)} unverified` : ""}`,
|
|
21
|
+
] : []),
|
|
22
|
+
`Health coverage: ${text(coverage.state)} · ${count(coverage.externally_scored)} externally scored · ${count(coverage.unknown)} unknown`,
|
|
23
|
+
`Health bands: ${counts(data.counts)}`,
|
|
24
|
+
`Signals: ${signals.available === false ? "UNAVAILABLE (counts unread)" : signals.available === true ? "available" : "unknown"} · window ${text(signals.window)} · send source ${text(signals.sendSource)}`,
|
|
25
|
+
"Evidence scope: delivery notifications include all send sources; only coldSends7d counts sequence sends. Unattributed rejections do not establish that prospect mail was rejected or that a whole domain is blocked.",
|
|
26
|
+
`Delivery notifications: ${counts(signals.dsn7d)}${signals.dsnScanTruncated === true ? " (scan truncated; lower bounds)" : ""}`,
|
|
27
|
+
`Sequence sends: ${count(signals.coldSends7d)} · hard bounces ${count(signals.hardBounces7d)} · provider rejections ${count(signals.providerRejections7d)} · Google rejections ${count(signals.googleRejections7d)}`,
|
|
28
|
+
`Stored warm-up rail states: ${counts(warmup.byState)}`,
|
|
29
|
+
`Warm-up stop causes: ${counts(warmup.byCause)}`,
|
|
30
|
+
"Enrollment and next actions: oxygen mailboxes list --json (warmupTruth).",
|
|
31
|
+
"",
|
|
32
|
+
"Domains (worst first):",
|
|
33
|
+
];
|
|
34
|
+
const domains = Array.isArray(data.domains) ? data.domains.map(record) : [];
|
|
35
|
+
for (const domain of domains)
|
|
36
|
+
lines.push(` ${text(domain.domain)} · ${count(domain.mailboxes)} inboxes · provider rejections ${count(domain.providerRejections7d)} (Google ${count(domain.googleRejections7d)}) · hard bounces ${count(domain.hardBounces7d)} / ${count(domain.coldSends7d)} sequence sends · youngest warm-up day ${count(domain.warmupDayMin)}`);
|
|
37
|
+
if (!domains.length)
|
|
38
|
+
lines.push(" None reported.");
|
|
39
|
+
const recommendations = Array.isArray(data.recommendations)
|
|
40
|
+
? data.recommendations.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
41
|
+
: [];
|
|
42
|
+
if (recommendations.length) {
|
|
43
|
+
lines.push("", "Recommendations (directional):");
|
|
44
|
+
for (const recommendation of recommendations.slice(0, 16)) {
|
|
45
|
+
const message = recommendation.trim();
|
|
46
|
+
lines.push(` ${message.length > 1_000 ? `${message.slice(0, 1_000)}… (truncated; use --json)` : message}`);
|
|
47
|
+
}
|
|
48
|
+
if (recommendations.length > 16)
|
|
49
|
+
lines.push(` ${recommendations.length - 16} more recommendation(s); use --json for the complete response.`);
|
|
50
|
+
}
|
|
51
|
+
const mailboxes = Array.isArray(data.mailboxes) ? data.mailboxes.map(record) : null;
|
|
52
|
+
if (mailboxes) {
|
|
53
|
+
lines.push("", `Inbox roster (${mailboxes.length}):`);
|
|
54
|
+
for (const mailbox of mailboxes) {
|
|
55
|
+
const mailboxWarmup = record(mailbox.warmup);
|
|
56
|
+
const warnings = Array.isArray(mailbox.warnings) ? mailbox.warnings.join(", ") : "";
|
|
57
|
+
lines.push(` ${text(mailbox.emailAddress)} · ${text(mailbox.status)} · health ${text(mailbox.healthState)} (${text(mailbox.healthFreshness)}) · stored warm-up rail ${text(mailboxWarmup.state)}${typeof mailboxWarmup.pauseCode === "string" ? ` / ${mailboxWarmup.pauseCode}` : ""}${warnings ? ` · ${warnings}` : ""}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (data.view === "fleet")
|
|
61
|
+
lines.push("", "Inbox rows omitted by --view fleet; use --view summary for the complete roster.");
|
|
62
|
+
lines.push("", "Directional evidence; unavailable or unknown readings are not a health clearance.");
|
|
63
|
+
const link = data.web_url ?? data.deepLink;
|
|
64
|
+
if (typeof link === "string")
|
|
65
|
+
lines.push(link);
|
|
66
|
+
return `${lines.join("\n")}\n`;
|
|
67
|
+
}
|
|
68
|
+
export function formatMailboxRoster(data) {
|
|
69
|
+
const summary = record(data.summary);
|
|
70
|
+
const lines = [`Mailbox pool: ${count(data.count ?? summary.total)} inboxes`,
|
|
71
|
+
`Providers: ${counts(summary.by_provider)}`, `Status: ${counts(summary.by_status)}`,
|
|
72
|
+
`Transport: ${counts(summary.by_transport)}`, `Warm-up: ${counts(record(summary.warmup).by_truth_state)}`, ""];
|
|
73
|
+
const mailboxes = Array.isArray(data.mailboxes) ? data.mailboxes.map(record) : [];
|
|
74
|
+
for (const mailbox of mailboxes)
|
|
75
|
+
lines.push(` ${text(mailbox.emailAddress)} · ${text(mailbox.provider)} · ${text(mailbox.status)} · ${text(mailbox.transport)} · warm-up ${text(record(mailbox.warmupTruth).state ?? mailbox.warmupState)} · next ${text(record(record(mailbox.warmupTruth).next_action).code)}`);
|
|
76
|
+
lines.push("", "Inspect an inbox: oxygen mailboxes get <id-or-address> --json");
|
|
77
|
+
if (typeof data.web_url === "string")
|
|
78
|
+
lines.push(data.web_url);
|
|
79
|
+
return `${lines.join("\n")}\n`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { defaultApiUrl } from "./credentials.js";
|
|
2
|
+
import { isProdApiUrl } from "./runtime.js";
|
|
3
|
+
/** Offline help cannot inspect a server flag; native chat is discoverable only for a dev API host. */
|
|
4
|
+
export function nativeChatHostServed(env = process.env) {
|
|
5
|
+
return !isProdApiUrl(defaultApiUrl(env)) && !/^(0|false|off)$/i.test(env.OXYGEN_NATIVE_CHAT_ENABLED?.trim() ?? "");
|
|
6
|
+
}
|