@myapihq/cli 2.21.1 → 2.23.0

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.
@@ -3,7 +3,7 @@ import type { Flags } from '../helpers.js';
3
3
  import type { Exposes } from '../exposes.js';
4
4
  export declare const EXPOSES: Exposes;
5
5
  export declare const SCHEMA: FlagSchema;
6
- export declare const HELP = "Usage: myapi account <subcommand>\n\nSubcommands:\n api-keys Manage API keys \u00B7 list / create / revoke\n config Manage CLI defaults (org, funnel, domain) \u00B7 supports set-org / set-funnel / set-domain\n import-key Import an existing API key non-interactively\n install-skills DEPRECATED \u2014 use `myapi install-skills` instead\n keys Alias for api-keys\n link [email] Upgrade anonymous account to registered (or add a second session)\n Use myapi account setup to create a completely new account\n login Sign in via your browser \u2014 Google or email code (preview: --mock)\n mailing-address Get or set the account's mailing address (CAN-SPAM)\n registrant Manage stored WHOIS contact info for domain registration \u00B7 set / get / clear\n setup Configure your account\n switch [index] Switch active account by index or email\n whoami Show current account \u00B7 supports --json";
6
+ export declare const HELP = "Usage: myapi account <subcommand>\n\nSubcommands:\n api-keys Manage API keys \u00B7 list / create / revoke\n config Manage CLI defaults (org, funnel, domain) \u00B7 supports set-org / set-funnel / set-domain\n import-key Import an existing API key non-interactively\n install-skills DEPRECATED \u2014 use `myapi install-skills` instead\n keys Alias for api-keys\n link [email] Upgrade anonymous account to registered (or add a second session)\n Use myapi account setup to create a completely new account\n login Sign in via your browser \u2014 Google or email code (preview: --mock)\n mailing-address Get or set the account's mailing address (CAN-SPAM)\n registrant Manage stored WHOIS contact info for domain registration \u00B7 set / get / clear\n resume-sending Turn email sending back on after an automatic pause\n sending Whether this account can send email, and how close it is to the limits\n setup Configure your account\n switch [index] Switch active account by index or email\n whoami Show current account \u00B7 supports --json";
7
7
  export declare const INSTALL_SKILLS_HELP = "Usage: myapi install-skills\n\nInstalls the MyAPI skills pack for AI coding agents (Claude, Codex, Gemini, \u2026).\n\nThis command writes skill definition files to:\n ~/.agents/skills/myapi/\n\nAnd creates symlinks in the appropriate agent config directories:\n ~/.claude/ (Claude)\n ~/.gemini/ (Gemini)\n ~/.cursor/ (Cursor, if detected)\n\nThese files teach agents how to use the MyAPI CLI and API directly.\nRun this command again to update existing skills to the latest version.\n\nNote: `myapi account install-skills` is deprecated and will be removed in the next minor.\nUse `myapi install-skills` going forward.";
8
8
  export declare const SUBCOMMAND_USAGE: Record<string, string>;
9
9
  export declare function link(flags?: Flags, emailArg?: string): Promise<void>;
@@ -17,6 +17,8 @@ export const EXPOSES = [
17
17
  'POST /hq/account/verify-code',
18
18
  'GET /hq/billing/balance',
19
19
  'GET /hq/account/free-tier',
20
+ 'GET /hq/account/sending',
21
+ 'POST /hq/account/resume-sending',
20
22
  'GET /hq/account/mailing-address',
21
23
  'PATCH /hq/account/mailing-address',
22
24
  ];
@@ -54,6 +56,8 @@ Subcommands:
54
56
  login Sign in via your browser — Google or email code (preview: --mock)
55
57
  mailing-address Get or set the account's mailing address (CAN-SPAM)
56
58
  registrant Manage stored WHOIS contact info for domain registration · set / get / clear
59
+ resume-sending Turn email sending back on after an automatic pause
60
+ sending Whether this account can send email, and how close it is to the limits
57
61
  setup Configure your account
58
62
  switch [index] Switch active account by index or email
59
63
  whoami Show current account · supports --json`;
@@ -116,6 +120,23 @@ Subcommands:
116
120
  set Interactive prompt for all fields. Or pass --registrant-json
117
121
  '<json>' to set non-interactively (agent-friendly).`;
118
122
  export const SUBCOMMAND_USAGE = {
123
+ sending: `myapi account sending [--json]
124
+
125
+ Whether this account can send email, and how close it is to the limits.
126
+
127
+ The platform pauses sending when too much of the last 24 hours bounces.
128
+ The pause message is a photograph of the moment it happened — this is the
129
+ live picture, and the two can disagree: an account paused yesterday may be
130
+ well within the limits today and simply need resuming.
131
+
132
+ Under 100 sends in 24h the ratios are not judged, and the reply says so.`,
133
+ 'resume-sending': `myapi account resume-sending [--json]
134
+
135
+ Turn sending back on after an automatic pause.
136
+
137
+ Refused with STILL_OVER_THRESHOLD while the last 24 hours are still over
138
+ the limit — the numbers come back with the refusal, so you can see how far
139
+ off you are. Removing the addresses that bounce is what moves them.`,
119
140
  'mailing-address': `myapi account mailing-address Show current value (null if unset)
120
141
  myapi account mailing-address "<address>" Set the mailing address
121
142
 
@@ -410,6 +431,41 @@ async function mailingAddress(args, flags) {
410
431
  }
411
432
  success(`Mailing address set: ${res.mailing_address}`);
412
433
  }
434
+ // The pause is invisible until a send fails, so both verbs lead with the one
435
+ // fact that decides what to do next: can this account send right now.
436
+ async function sendingStatus(flags) {
437
+ const config = requireConfig();
438
+ const res = await hq.getSendingStatus(config.api_key);
439
+ if (flags.json) {
440
+ printJson(res);
441
+ return;
442
+ }
443
+ info(res.sending_paused ? 'Sending is PAUSED.' : 'Sending is on.');
444
+ info(`Last 24h: ${res.sends_24h} sent · ${res.bounces_24h} bounced · ${res.complaints_24h} complaints`);
445
+ info(`Limits: ${(res.bounce_threshold * 100).toFixed(1)}% bounces · ${(res.complaint_threshold * 100).toFixed(2)}% complaints`);
446
+ if (res.note)
447
+ info(res.note);
448
+ if (res.sending_paused) {
449
+ if (res.paused_reason_when_paused) {
450
+ // Labelled as history, because the live numbers above may disagree with
451
+ // it — and reading the frozen reason as current is the whole confusion.
452
+ info(`Paused because (at the time): ${res.paused_reason_when_paused}`);
453
+ }
454
+ info(res.can_resume_now
455
+ ? 'Within the limits now — run: myapi account resume-sending'
456
+ : 'Still over the limit. Remove the addresses that bounce, then resume.');
457
+ }
458
+ }
459
+ async function resumeSending(flags) {
460
+ const config = requireConfig();
461
+ const res = await hq.resumeSending(config.api_key);
462
+ if (flags.json) {
463
+ printJson(res);
464
+ return;
465
+ }
466
+ success(res.message);
467
+ info(`Last 24h: ${res.sends} sent · ${res.bounces} bounced`);
468
+ }
413
469
  export async function run(subcommand, args, flags) {
414
470
  if (!subcommand || (flags.help && !subcommand)) {
415
471
  info(HELP);
@@ -424,6 +480,8 @@ export async function run(subcommand, args, flags) {
424
480
  }
425
481
  switch (subcommand) {
426
482
  case 'mailing-address': return mailingAddress(args, flags);
483
+ case 'sending': return sendingStatus(flags);
484
+ case 'resume-sending': return resumeSending(flags);
427
485
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi account --help" for the list.`);
428
486
  }
429
487
  }
@@ -8,6 +8,7 @@ export declare const NAME_RE: RegExp;
8
8
  export declare const RESERVED_NAMES: Set<string>;
9
9
  export declare function _validateName(name: string): string | null;
10
10
  export declare function _parseEnv(raw: string): Record<string, string> | string;
11
+ export declare function splitEnvEntries(raw: string): string[];
11
12
  export declare function create(nameArg: string | undefined, flags: Flags): Promise<void>;
12
13
  export declare function list(flags: Flags): Promise<void>;
13
14
  export declare function get(id: string, flags: Flags): Promise<void>;
@@ -28,7 +28,7 @@ export const SCHEMA = {
28
28
  'min-instances': 'number',
29
29
  'max-instances': 'number',
30
30
  port: 'number',
31
- env: 'string',
31
+ env: 'list',
32
32
  unset: 'string',
33
33
  tail: 'number',
34
34
  scope: 'string',
@@ -60,15 +60,45 @@ export function _validateName(name) {
60
60
  // message string (pure form, for tests).
61
61
  export function _parseEnv(raw) {
62
62
  const env = {};
63
- for (const pair of raw.split(',').map(s => s.trim()).filter(Boolean)) {
63
+ for (const pair of splitEnvEntries(raw)) {
64
64
  const eq = pair.indexOf('=');
65
65
  if (eq < 1) {
66
- return `Invalid --env entry "${pair}". Use KEY=VALUE, comma-separated.`;
66
+ return `Invalid --env entry "${pair}". Use KEY=VALUE. ` +
67
+ `A value containing commas needs its own --env: --env A=1 --env "B=x,y".`;
67
68
  }
68
69
  env[pair.slice(0, eq)] = pair.slice(eq + 1);
69
70
  }
70
71
  return env;
71
72
  }
73
+ /* Split one --env occurrence into entries.
74
+ *
75
+ * A comma means "next variable" only when every segment looks like KEY=VALUE.
76
+ * Otherwise the commas belong to the value — the case that mattered. An origin
77
+ * list is ONE variable whose value contains commas, and splitting it produced
78
+ * `Invalid --env entry "https://dev.example.com"`: the command refused the exact
79
+ * shape it was added to let people set. Three of one customer's variables are
80
+ * lists like that, and the same defect at `create` is what sent them through
81
+ * three container generations in a week.
82
+ *
83
+ * The heuristic does not have to be perfect, because --env is now repeatable:
84
+ * given on its own, `--env "A=x?a=1,b=2"` is unambiguous and always right. The
85
+ * splitting stays so `--env A=1,B=2` keeps working for everyone already writing
86
+ * it that way.
87
+ */
88
+ export function splitEnvEntries(raw) {
89
+ const parts = raw.split(',').map(s => s.trim()).filter(Boolean);
90
+ // One entry after dropping empties: a plain pair, or a pair with a trailing
91
+ // comma. Returning the raw string here would fold that stray comma into the
92
+ // value — caught by an existing test, which is what it was for.
93
+ if (parts.length <= 1) {
94
+ return parts;
95
+ }
96
+ if (parts.every(p => p.indexOf('=') > 0)) {
97
+ return parts;
98
+ }
99
+ const whole = raw.trim();
100
+ return whole ? [whole] : [];
101
+ }
72
102
  function summarizeContainer(c) {
73
103
  return {
74
104
  id: c.id,
@@ -127,11 +157,20 @@ export async function create(nameArg, flags) {
127
157
  error(`--health-check must be a path starting with "/" — got "${hc}".`);
128
158
  payload.health_check = hc;
129
159
  }
130
- if (typeof flags.env === 'string') {
131
- const env = _parseEnv(flags.env);
132
- if (typeof env === 'string')
133
- error(env);
134
- payload.env = env;
160
+ // Repeatable, and create must agree with `container env` on what --env means.
161
+ // If they disagree, a variable settable at creation cannot be changed
162
+ // afterwards which is how a customer ended up recreating containers.
163
+ const createEnvArgs = Array.isArray(flags.env) ? flags.env
164
+ : typeof flags.env === 'string' ? [flags.env] : [];
165
+ if (createEnvArgs.length > 0) {
166
+ const merged = {};
167
+ for (const occurrence of createEnvArgs) {
168
+ const parsed = _parseEnv(occurrence);
169
+ if (typeof parsed === 'string')
170
+ error(parsed);
171
+ Object.assign(merged, parsed);
172
+ }
173
+ payload.env = merged;
135
174
  }
136
175
  const result = await sdkContainer.createContainer(config.api_key, orgId, payload);
137
176
  success(`Container created: ${result.container.id}`);
@@ -721,10 +760,15 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
721
760
  const config = requireConfig();
722
761
  const orgId = requireOrg(flags, config, 'myapi container env <id> --env K=V [--unset K2]');
723
762
  if (!id)
724
- error('Missing id.\nUsage: myapi container env <id> --env KEY=VALUE[,K2=V2] [--unset KEY3[,KEY4]]');
763
+ error('Missing id.\nUsage: myapi container env <id> --env KEY=VALUE [--env K2=V2] [--unset KEY3]\n' +
764
+ 'A value containing commas needs its own --env: --env "ORIGINS=http://a,https://b"');
725
765
  const env = {};
726
- if (typeof flags.env === 'string') {
727
- const parsed = _parseEnv(flags.env);
766
+ // Each occurrence parsed on its own, so a value containing commas can be
767
+ // given unambiguously as its own --env.
768
+ const envArgs = Array.isArray(flags.env) ? flags.env
769
+ : typeof flags.env === 'string' ? [flags.env] : [];
770
+ for (const occurrence of envArgs) {
771
+ const parsed = _parseEnv(occurrence);
728
772
  if (typeof parsed === 'string')
729
773
  error(parsed);
730
774
  Object.assign(env, parsed);
@@ -1,12 +1,16 @@
1
1
  // `myapi crm contacts <subcommand>` — the engaged-people half of CRM.
2
+ import * as fs from 'fs';
2
3
  import { crm } from '@myapihq/sdk';
3
4
  import { requireConfig } from '../../config.js';
4
5
  import { success, error, info, printTable, printJson } from '../../output.js';
5
6
  import { requireOrg, requireArg } from '../../helpers.js';
7
+ import { retryFunds } from '../../utils.js';
6
8
  import { pageLine, originFlag } from './pagination.js';
7
9
  export const EXPOSES = [
8
10
  'POST /crm/orgs/{org_id}/contacts',
9
11
  'POST /crm/orgs/{org_id}/contacts/promote',
12
+ 'POST /crm/orgs/{org_id}/contacts/promote-audience',
13
+ 'POST /crm/orgs/{org_id}/contacts/import',
10
14
  'POST /crm/orgs/{org_id}/contacts/search',
11
15
  'GET /crm/orgs/{org_id}/contacts/{id}',
12
16
  'PATCH /crm/orgs/{org_id}/contacts/{id}',
@@ -152,6 +156,71 @@ async function promote(personId, flags) {
152
156
  }
153
157
  success(`Promoted to ${c.id} (${c.email})`);
154
158
  }
159
+ // Walks every page rather than promoting the first 500 and stopping. A bulk
160
+ // import that quietly covers part of an audience is the same failure as a list
161
+ // that truncates: the caller gets a number that looks like an answer.
162
+ async function promoteAudience(audienceId, flags) {
163
+ const config = requireConfig();
164
+ const orgId = requireOrg(flags, config, 'myapi crm contacts promote-audience <audience_id> [--org <id>]');
165
+ requireArg(audienceId, 'audience_id', 'myapi crm contacts promote-audience <audience_id>');
166
+ const totals = { created: 0, matched: 0, skipped_no_email: 0, failed: 0, processed: 0 };
167
+ let offset = 0;
168
+ let audienceTotal = 0;
169
+ // A page at a time, resuming from the server's own next_offset — the same
170
+ // cursor discipline the paged list commands use.
171
+ for (let page = 0; page < 200; page++) {
172
+ const r = await retryFunds(() => crm.promoteAudience(config.api_key, orgId, audienceId, { offset }));
173
+ totals.created += r.created;
174
+ totals.matched += r.matched;
175
+ totals.skipped_no_email += r.skipped_no_email;
176
+ totals.failed += r.failed;
177
+ totals.processed += r.processed;
178
+ audienceTotal = r.total;
179
+ if (!r.has_more || r.processed === 0)
180
+ break;
181
+ offset = r.next_offset;
182
+ if (!flags.json)
183
+ info(`… ${totals.processed}/${r.total}`);
184
+ }
185
+ if (flags.json) {
186
+ printJson({ audience_id: audienceId, total: audienceTotal, ...totals });
187
+ return;
188
+ }
189
+ success(`${totals.created} new contact(s), ${totals.matched} already known`);
190
+ if (totals.skipped_no_email > 0) {
191
+ // Named rather than folded into a total: this is almost always why an
192
+ // audience of 400 becomes 260 contacts.
193
+ info(`${totals.skipped_no_email} skipped — the Goldfox row has no email address.`);
194
+ }
195
+ if (totals.failed > 0)
196
+ info(`${totals.failed} failed.`);
197
+ info(`Reach them from a campaign with: --crm-origin goldfox`);
198
+ }
199
+ async function importCsv(path, flags) {
200
+ const config = requireConfig();
201
+ const orgId = requireOrg(flags, config, 'myapi crm contacts import <file.csv> [--org <id>]');
202
+ requireArg(path, 'file.csv', 'myapi crm contacts import <file.csv>');
203
+ if (!fs.existsSync(path))
204
+ error(`No such file: ${path}`);
205
+ const csv = fs.readFileSync(path, 'utf-8');
206
+ const r = await crm.importContacts(config.api_key, orgId, csv, path.split('/').pop());
207
+ if (flags.json) {
208
+ printJson(r);
209
+ return;
210
+ }
211
+ success(`${r.created} new contact(s), ${r.matched} already known`);
212
+ if (r.skipped > 0) {
213
+ // Every skipped row, with its line number — a count on its own is what
214
+ // sends somebody back to the spreadsheet to work out which twenty.
215
+ info(`${r.skipped} row(s) skipped:`);
216
+ for (const row of r.skipped_rows.slice(0, 20)) {
217
+ info(` line ${row.line}: ${row.reason}${row.email ? ` (${row.email})` : ''}`);
218
+ }
219
+ if (r.skipped_rows.length > 20)
220
+ info(` … and ${r.skipped_rows.length - 20} more (--json for all)`);
221
+ }
222
+ info('Reach them from a campaign with: --crm-origin import');
223
+ }
155
224
  async function events(id, flags) {
156
225
  const config = requireConfig();
157
226
  const orgId = requireOrg(flags, config, 'myapi crm contacts events <id> [--kind <k>] [--limit N] [--cursor <c>] [--org <id>]');
@@ -211,8 +280,10 @@ Subcommands:
211
280
  delete <id> Soft-delete (events retained)
212
281
  events <id> Timeline of events on a contact (newest first)
213
282
  get <id> Fetch one contact (with embedded Goldfox enrichment, when available)
283
+ import <file.csv> Import contacts from CSV (header row; email column required)
214
284
  list Most-recently-engaged contacts (no filter)
215
285
  promote <goldfox_person_id> Promote a Goldfox lead → CRM contact (idempotent)
286
+ promote-audience <audience_id> Promote a whole saved people-audience (idempotent, resumable)
216
287
  restore <id> Undo a soft-delete
217
288
  search Filter by stage / source / email / engagement window
218
289
  update <id> Patch stage, names, company_id, custom JSON
@@ -237,6 +308,8 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
237
308
  case 'delete': return del(args[0], flags);
238
309
  case 'restore': return restore(args[0], flags);
239
310
  case 'promote': return promote(args[0], flags);
311
+ case 'promote-audience': return promoteAudience(args[0], flags);
312
+ case 'import': return importCsv(args[0], flags);
240
313
  case 'events': return events(args[0], flags);
241
314
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi crm contacts --help" for valid subcommands.`);
242
315
  }
@@ -54,6 +54,7 @@ Each namespace shares the same subcommands:
54
54
  delete <id> Soft delete (events retained)
55
55
  restore <id> Restore a soft-deleted row
56
56
  promote <goldfox_id> Promote a Goldfox lead into your CRM
57
+ promote-audience <id> Promote a saved people-audience in bulk (idempotent, resumable)
57
58
 
58
59
  Contacts also have:
59
60
  events <id> [--kind <k>] Inspect the contact's engagement timeline
@@ -0,0 +1,5 @@
1
+ import { type Flags } from '../../helpers.js';
2
+ import type { Exposes } from '../../exposes.js';
3
+ export declare const EXPOSES: Exposes;
4
+ export declare const HELP = "Usage: myapi email campaign <subcommand>\n\n create Create a draft (--template, --from, and a source)\n list Campaigns in this org\n get <id> One campaign\n update <id> Edit a DRAFT's name or source\n resolve <id> Freeze who it reaches; reports the count and cost, sends nothing\n start <id> Begin sending a resolved campaign\n pause <id> Stop after the message in flight\n resume <id> Continue a paused campaign\n cancel <id> End it for good; queued recipients are dropped\n recipients <id> Who it resolved to, and what happened to each\n stats <id> Counts by state, sent today, and the daily limit\n\nA campaign drains at its per-day limit rather than sending at once, so it is\ncommonly still active tomorrow \u2014 that is the design, not a stall.\n\nRecipients come from a SOURCE, not a list you upload:\n --crm-stage / --crm-origin / --crm-max-days filter CRM contacts\n --crm-audience <id> only people promoted from it\n --addresses a@x.com,b@y.com an explicit short list";
5
+ export declare function run(sub: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -0,0 +1,244 @@
1
+ import { email as sdkEmail } from '@myapihq/sdk';
2
+ import { requireConfig } from '../../config.js';
3
+ import { success, error, info, printTable, printJson } from '../../output.js';
4
+ import { retryFunds } from '../../utils.js';
5
+ import { requireOrg, requireArg } from '../../helpers.js';
6
+ export const EXPOSES = [
7
+ 'POST /email/orgs/{org_id}/campaigns',
8
+ 'GET /email/orgs/{org_id}/campaigns',
9
+ 'GET /email/orgs/{org_id}/campaigns/{campaign_id}',
10
+ 'PATCH /email/orgs/{org_id}/campaigns/{campaign_id}',
11
+ 'POST /email/orgs/{org_id}/campaigns/{campaign_id}/resolve',
12
+ 'POST /email/orgs/{org_id}/campaigns/{campaign_id}/start',
13
+ 'POST /email/orgs/{org_id}/campaigns/{campaign_id}/pause',
14
+ 'POST /email/orgs/{org_id}/campaigns/{campaign_id}/resume',
15
+ 'POST /email/orgs/{org_id}/campaigns/{campaign_id}/cancel',
16
+ 'GET /email/orgs/{org_id}/campaigns/{campaign_id}/recipients',
17
+ 'GET /email/orgs/{org_id}/campaigns/{campaign_id}/stats',
18
+ ];
19
+ export const HELP = `Usage: myapi email campaign <subcommand>
20
+
21
+ create Create a draft (--template, --from, and a source)
22
+ list Campaigns in this org
23
+ get <id> One campaign
24
+ update <id> Edit a DRAFT's name or source
25
+ resolve <id> Freeze who it reaches; reports the count and cost, sends nothing
26
+ start <id> Begin sending a resolved campaign
27
+ pause <id> Stop after the message in flight
28
+ resume <id> Continue a paused campaign
29
+ cancel <id> End it for good; queued recipients are dropped
30
+ recipients <id> Who it resolved to, and what happened to each
31
+ stats <id> Counts by state, sent today, and the daily limit
32
+
33
+ A campaign drains at its per-day limit rather than sending at once, so it is
34
+ commonly still active tomorrow — that is the design, not a stall.
35
+
36
+ Recipients come from a SOURCE, not a list you upload:
37
+ --crm-stage / --crm-origin / --crm-max-days filter CRM contacts
38
+ --crm-audience <id> only people promoted from it
39
+ --addresses a@x.com,b@y.com an explicit short list`;
40
+ // Building the source from flags is the one place the CLI has an opinion: a
41
+ // campaign names where its people come from, and mixing two sources in one
42
+ // command is a request nobody can mean.
43
+ function sourceFromFlags(flags) {
44
+ const addrs = typeof flags.addresses === 'string' && flags.addresses
45
+ ? flags.addresses.split(',').map(s => s.trim()).filter(Boolean)
46
+ : [];
47
+ const crm = {};
48
+ if (typeof flags['crm-stage'] === 'string' && flags['crm-stage'])
49
+ crm.stage = flags['crm-stage'];
50
+ if (typeof flags['crm-origin'] === 'string' && flags['crm-origin'])
51
+ crm.origin = flags['crm-origin'];
52
+ if (typeof flags['crm-company'] === 'string' && flags['crm-company'])
53
+ crm.company_id = flags['crm-company'];
54
+ // The people promoted from ONE saved audience, rather than every lead ever
55
+ // promoted — which is rarely the campaign anybody means.
56
+ if (typeof flags['crm-audience'] === 'string' && flags['crm-audience'])
57
+ crm.audience_id = flags['crm-audience'];
58
+ if (typeof flags['crm-max-days'] === 'number')
59
+ crm.max_last_engagement_days = flags['crm-max-days'];
60
+ if (typeof flags['crm-min-days'] === 'number')
61
+ crm.min_last_engagement_days = flags['crm-min-days'];
62
+ const hasCRM = Object.keys(crm).length > 0;
63
+ if (addrs.length && hasCRM) {
64
+ error('Pass either --addresses or the --crm-* filters, not both — a campaign draws from one source.');
65
+ }
66
+ if (addrs.length)
67
+ return { source_kind: 'list', source_ref: { addresses: addrs } };
68
+ if (hasCRM)
69
+ return { source_kind: 'crm_query', source_ref: crm };
70
+ // An unfiltered CRM query is every contact in the org. That is a legitimate
71
+ // campaign and a very easy accident, so it has to be asked for by name.
72
+ if (flags['all-contacts'])
73
+ return { source_kind: 'crm_query', source_ref: {} };
74
+ error('No source. Use --addresses <csv>, a --crm-* filter, or --all-contacts to mean every CRM contact.');
75
+ throw new Error('unreachable');
76
+ }
77
+ function fmtCents(c) {
78
+ if (c == null)
79
+ return '—';
80
+ return c < 100 ? `${c.toFixed(2)}¢` : `$${(c / 100).toFixed(2)}`;
81
+ }
82
+ async function create(flags) {
83
+ const config = requireConfig();
84
+ const orgId = requireOrg(flags, config, 'myapi email campaign create --name <n> --template <id> --from <addr> [--addresses <csv> | --crm-stage <s>]');
85
+ const name = typeof flags.name === 'string' ? flags.name : '';
86
+ const template = typeof flags.template === 'string' ? flags.template : '';
87
+ const from = typeof flags.from === 'string' ? flags.from : '';
88
+ if (!name || !template || !from) {
89
+ error('Missing required flags: --name, --template <template-id>, --from <mailbox address>.');
90
+ }
91
+ const src = sourceFromFlags(flags);
92
+ const c = await sdkEmail.createCampaign(config.api_key, orgId, {
93
+ name, template_id: template, from_address: from, ...src,
94
+ });
95
+ if (flags.json) {
96
+ printJson(c);
97
+ return;
98
+ }
99
+ success(`Draft campaign ${c.id}`);
100
+ info(`Next: myapi email campaign resolve ${c.id} # who it reaches and what it costs — sends nothing`);
101
+ }
102
+ async function list(flags) {
103
+ const config = requireConfig();
104
+ const orgId = requireOrg(flags, config, 'myapi email campaign list [--org <id>]');
105
+ const rows = await sdkEmail.listCampaigns(config.api_key, orgId);
106
+ if (flags.json) {
107
+ printJson(rows);
108
+ return;
109
+ }
110
+ printTable(rows.map(c => ({
111
+ id: c.id,
112
+ name: c.name,
113
+ status: c.status,
114
+ source: c.source_kind,
115
+ recipients: c.recipient_count ?? '—',
116
+ est: fmtCents(c.estimated_cost_cents),
117
+ })), { flags, empty: 'No campaigns yet. Create one with: myapi email campaign create --name ... --template ... --from ...' });
118
+ }
119
+ async function get(id, flags) {
120
+ const config = requireConfig();
121
+ const orgId = requireOrg(flags, config, 'myapi email campaign get <id>');
122
+ const c = await sdkEmail.getCampaign(config.api_key, orgId, id);
123
+ if (flags.json) {
124
+ printJson(c);
125
+ return;
126
+ }
127
+ info(`${c.name} [${c.status}]`);
128
+ info(`from ${c.from_address} · template ${c.template_id} · source ${c.source_kind}`);
129
+ info(`recipients ${c.recipient_count ?? '—'} · estimated ${fmtCents(c.estimated_cost_cents)}`);
130
+ if (c.resolved_at)
131
+ info(`resolved ${c.resolved_at}`);
132
+ }
133
+ async function update(id, flags) {
134
+ const config = requireConfig();
135
+ const orgId = requireOrg(flags, config, 'myapi email campaign update <id> [--name <n>] [--addresses <csv>]');
136
+ const patch = {};
137
+ if (typeof flags.name === 'string' && flags.name)
138
+ patch.name = flags.name;
139
+ const wantsSource = flags.addresses || flags['crm-stage'] || flags['crm-origin']
140
+ || flags['crm-company'] || flags['crm-audience'] || flags['crm-max-days']
141
+ || flags['crm-min-days'] || flags['all-contacts'];
142
+ if (wantsSource)
143
+ Object.assign(patch, sourceFromFlags(flags));
144
+ if (Object.keys(patch).length === 0)
145
+ error('Nothing to change. Pass --name or a source flag.');
146
+ const c = await sdkEmail.updateCampaign(config.api_key, orgId, id, patch);
147
+ if (flags.json) {
148
+ printJson(c);
149
+ return;
150
+ }
151
+ success(`Updated ${c.id}`);
152
+ }
153
+ async function resolve(id, flags) {
154
+ const config = requireConfig();
155
+ const orgId = requireOrg(flags, config, 'myapi email campaign resolve <id>');
156
+ const r = await sdkEmail.resolveCampaign(config.api_key, orgId, id);
157
+ if (flags.json) {
158
+ printJson(r);
159
+ return;
160
+ }
161
+ success(`${r.recipients} recipient(s) · estimated ${fmtCents(r.estimated_cost_cents)}`);
162
+ const excluded = Object.entries(r.excluded ?? {});
163
+ if (excluded.length) {
164
+ // Saying WHY the number shrank is the difference between a product and a
165
+ // black box — the source had more rows than this, and the caller is owed
166
+ // the reason.
167
+ info(`From ${r.source_rows} source row(s); excluded ${excluded.map(([k, n]) => `${n} ${k}`).join(', ')}.`);
168
+ }
169
+ info('Nothing has been sent. Start it with: myapi email campaign start ' + id);
170
+ }
171
+ async function transition(verb, id, flags) {
172
+ const config = requireConfig();
173
+ const orgId = requireOrg(flags, config, `myapi email campaign ${verb} <id>`);
174
+ const fn = {
175
+ start: sdkEmail.startCampaign,
176
+ pause: sdkEmail.pauseCampaign,
177
+ resume: sdkEmail.resumeCampaign,
178
+ cancel: sdkEmail.cancelCampaign,
179
+ }[verb];
180
+ // start is the billable one: it commits the account to the resolved cost.
181
+ const c = verb === 'start'
182
+ ? await retryFunds(() => fn(config.api_key, orgId, id))
183
+ : await fn(config.api_key, orgId, id);
184
+ if (flags.json) {
185
+ printJson(c);
186
+ return;
187
+ }
188
+ success(`${c.name} is now ${c.status}`);
189
+ if (verb === 'start') {
190
+ info(`It sends up to its daily limit and continues tomorrow — check with: myapi email campaign stats ${id}`);
191
+ }
192
+ }
193
+ async function recipients(id, flags) {
194
+ const config = requireConfig();
195
+ const orgId = requireOrg(flags, config, 'myapi email campaign recipients <id> [--state queued|sent|failed|excluded]');
196
+ const state = typeof flags.state === 'string' ? flags.state : undefined;
197
+ const rows = await sdkEmail.listCampaignRecipients(config.api_key, orgId, id, state);
198
+ if (flags.json) {
199
+ printJson(rows);
200
+ return;
201
+ }
202
+ printTable(rows.map(r => ({
203
+ address: r.address,
204
+ state: r.state,
205
+ reason: r.excluded_reason ?? r.error ?? '',
206
+ sent_at: r.sent_at ?? '',
207
+ })), { flags, empty: 'No recipients. Resolve the campaign first.' });
208
+ }
209
+ async function stats(id, flags) {
210
+ const config = requireConfig();
211
+ const orgId = requireOrg(flags, config, 'myapi email campaign stats <id>');
212
+ const s = await sdkEmail.getCampaignStats(config.api_key, orgId, id);
213
+ if (flags.json) {
214
+ printJson(s);
215
+ return;
216
+ }
217
+ info(`status ${s.status} · ${s.sent_today} sent today · limit ${s.per_day_limit}/day`);
218
+ const parts = Object.entries(s.by_state ?? {}).map(([k, n]) => `${n} ${k}`);
219
+ info(parts.length ? parts.join(' · ') : 'no recipients resolved yet');
220
+ if (s.status === 'paused_insufficient_funds') {
221
+ info('Paused because the account could not pay. Top up or enable auto-recharge, then: myapi email campaign resume ' + id);
222
+ }
223
+ }
224
+ export async function run(sub, args, flags) {
225
+ if (!sub || (flags.help && !sub)) {
226
+ info(HELP);
227
+ return;
228
+ }
229
+ const need = (usage) => requireArg(args[0], '<id>', usage);
230
+ switch (sub) {
231
+ case 'create': return create(flags);
232
+ case 'list': return list(flags);
233
+ case 'get': return get(need('myapi email campaign get <id>'), flags);
234
+ case 'update': return update(need('myapi email campaign update <id>'), flags);
235
+ case 'resolve': return resolve(need('myapi email campaign resolve <id>'), flags);
236
+ case 'start': return transition('start', need('myapi email campaign start <id>'), flags);
237
+ case 'pause': return transition('pause', need('myapi email campaign pause <id>'), flags);
238
+ case 'resume': return transition('resume', need('myapi email campaign resume <id>'), flags);
239
+ case 'cancel': return transition('cancel', need('myapi email campaign cancel <id>'), flags);
240
+ case 'recipients': return recipients(need('myapi email campaign recipients <id>'), flags);
241
+ case 'stats': return stats(need('myapi email campaign stats <id>'), flags);
242
+ default: error(`Unknown subcommand: email campaign ${sub}. Run "myapi email campaign --help".`);
243
+ }
244
+ }
@@ -11,7 +11,19 @@ import * as message from './message.js';
11
11
  import * as warmup from './warmup.js';
12
12
  import * as template from './template.js';
13
13
  import * as verify from './verify.js';
14
+ import * as campaign from './campaign.js';
14
15
  export const SCHEMA = {
16
+ // campaigns (`from` is declared below, shared with `message send`)
17
+ template: 'string',
18
+ addresses: 'string',
19
+ 'crm-stage': 'string',
20
+ 'crm-origin': 'string',
21
+ 'crm-company': 'string',
22
+ 'crm-audience': 'string',
23
+ 'crm-max-days': 'number',
24
+ 'crm-min-days': 'number',
25
+ 'all-contacts': 'boolean',
26
+ state: 'string',
15
27
  org: 'string',
16
28
  domain: 'string',
17
29
  username: 'string',
@@ -60,6 +72,7 @@ async function dispatchNamespace(ns, sub, restArgs, flags) {
60
72
  case 'warmup': return warmup.run(sub, restArgs, flags);
61
73
  case 'template': return template.run(sub, restArgs, flags);
62
74
  case 'verify': return verify.run(sub, restArgs, flags);
75
+ case 'campaign': return campaign.run(sub, restArgs, flags);
63
76
  default: error(`Unknown namespace: ${ns}. Run "myapi email --help" for the list.`);
64
77
  }
65
78
  }
@@ -68,6 +81,7 @@ export async function run(subcommand, args, flags) {
68
81
  info(`Usage: myapi email <namespace> <subcommand>
69
82
 
70
83
  Namespaces:
84
+ campaign Scheduled bulk send (org-scoped) — resolve, start, pause, stats; drains at a daily limit
71
85
  mailbox Mailboxes (account-scoped) — create, list, activate-sending
72
86
  message Send & read mail (account-scoped) — send, status, sent, inbox, outbox, get
73
87
  template Email templates (org-scoped) — generate, list, edit, send-test, delete
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { _parseEnv, splitEnvEntries } from './container.js';
3
+ // A container variable whose VALUE contains commas could not be set.
4
+ //
5
+ // `myapi container env <id> --env "ORIGINS=http://localhost:5173,https://dev.example.com"`
6
+ // was refused with `Invalid --env entry "https://dev.example.com"` — the parser
7
+ // split on every comma, so an origin list became two entries and the second had
8
+ // no `=`. Three of one customer's variables are lists like that, and the same
9
+ // defect at `create` is what sent them through three container generations in a
10
+ // week. The command added to free them from the HTTP API reproduced it.
11
+ describe('splitEnvEntries', () => {
12
+ it('keeps commas that belong to the value', () => {
13
+ expect(splitEnvEntries('ORIGINS=http://localhost:5173,https://dev.example.com'))
14
+ .toEqual(['ORIGINS=http://localhost:5173,https://dev.example.com']);
15
+ });
16
+ it('still splits several plain pairs, which is how people already write it', () => {
17
+ expect(splitEnvEntries('A=1,B=2')).toEqual(['A=1', 'B=2']);
18
+ });
19
+ it('treats a single pair as a single pair', () => {
20
+ expect(splitEnvEntries('A=1')).toEqual(['A=1']);
21
+ });
22
+ it('does not split when one segment is not a pair', () => {
23
+ // "A=1,B" is a value containing a comma, not a pair plus a malformed one.
24
+ expect(splitEnvEntries('A=1,B')).toEqual(['A=1,B']);
25
+ });
26
+ });
27
+ describe('_parseEnv', () => {
28
+ it('sets an origin list as one variable', () => {
29
+ const got = _parseEnv('ORIGINS=http://localhost:5173,https://dev.example.com');
30
+ expect(got).toEqual({ ORIGINS: 'http://localhost:5173,https://dev.example.com' });
31
+ });
32
+ it('keeps the multi-pair form working', () => {
33
+ expect(_parseEnv('A=1,B=2')).toEqual({ A: '1', B: '2' });
34
+ });
35
+ it('keeps = inside a value', () => {
36
+ expect(_parseEnv('URL=https://x?a=1')).toEqual({ URL: 'https://x?a=1' });
37
+ });
38
+ it('names the unambiguous form when it cannot parse', () => {
39
+ const got = _parseEnv('novalue');
40
+ expect(typeof got).toBe('string');
41
+ // The refusal must say how to succeed, not only that this failed.
42
+ expect(got).toContain('--env');
43
+ });
44
+ });
@@ -35,7 +35,7 @@ export const COMMANDS = [
35
35
  // command → subcommands, for `myapi <command> <TAB>`. Mirrors each
36
36
  // command's dispatcher; commands absent here take no subcommand.
37
37
  export const SUBCOMMANDS = {
38
- account: ['setup', 'import-key', 'whoami', 'login', 'link', 'switch', 'install-skills', 'config', 'registrant', 'api-keys', 'keys', 'mailing-address'],
38
+ account: ['setup', 'import-key', 'whoami', 'login', 'link', 'switch', 'install-skills', 'config', 'registrant', 'api-keys', 'keys', 'mailing-address', 'sending', 'resume-sending'],
39
39
  // The end-user auth product. Operator/account commands live under `account`.
40
40
  auth: ['tenant', 'client', 'usage', 'domain'],
41
41
  org: ['create', 'delete', 'get', 'import', 'list', 'sync-brand', 'update'],
@@ -22,6 +22,7 @@ const COMMAND_MODULES = [
22
22
  './commands/email/template.js',
23
23
  './commands/email/warmup.js',
24
24
  './commands/email/verify.js',
25
+ './commands/email/campaign.js',
25
26
  './commands/funnel.js',
26
27
  './commands/image.js',
27
28
  './commands/keys.js',
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ // Flags that mean different things in different commands must not be typed by
2
+ // whichever command happens to be spread last.
3
+ //
4
+ // COMBINED_SCHEMA merges every command's SCHEMA to find the command name. A
5
+ // merge resolves duplicate keys by declaration order, silently: `ttl` is
6
+ // `number` in domain and llm and `string` in storage, and because storage
7
+ // merges last, EVERY --ttl in the CLI parsed as a string. `domain records
8
+ // create --ttl 3600` sent "3600", and `llm cache create --ttl 600` dropped the
9
+ // value on a `typeof === 'number'` check — a flag documented in --help, parsed
10
+ // without complaint, and discarded before the request.
11
+ //
12
+ // The dispatcher now re-parses under the command's own schema. This file has
13
+ // two jobs: prove that per-command typing holds, and list the collisions so a
14
+ // new one is a visible decision rather than an accident.
15
+ //
16
+ // It lives in src/ rather than test/smoke/ because it reads the schemas
17
+ // directly and never drives the built binary. Filed under smoke originally, it
18
+ // ran only in the push gate — so `npm test`, the command anyone reaches for,
19
+ // could not catch a new collision. It caught one in CI instead, which is later
20
+ // and more expensive than it needed to be.
21
+ import { describe, it, expect } from 'vitest';
22
+ import { readdirSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
+ import { parseFlags } from './flags.js';
26
+ import * as domainCmd from './commands/domain.js';
27
+ import * as storageCmd from './commands/storage.js';
28
+ import * as llmCmd from './commands/llm.js';
29
+ import * as fnCmd from './commands/fn.js';
30
+ import * as containerCmd from './commands/container.js';
31
+ describe('per-command flag typing', () => {
32
+ it('types --ttl by the command, not by merge order', () => {
33
+ expect(parseFlags(['--ttl', '3600'], domainCmd.SCHEMA).flags.ttl).toBe(3600);
34
+ expect(parseFlags(['--ttl', '600'], llmCmd.SCHEMA).flags.ttl).toBe(600);
35
+ // storage's --ttl is a duration string ("1h"), which is why it is declared
36
+ // 'string' — the collision is legitimate, the silent resolution was not.
37
+ expect(parseFlags(['--ttl', '1h'], storageCmd.SCHEMA).flags.ttl).toBe('1h');
38
+ });
39
+ it('keeps repeated --scope accumulating for fn', () => {
40
+ // fn.ts documents that `--scope email --scope storage` accumulates. Under
41
+ // the merged schema container's 'string' won and this THREW instead.
42
+ expect(parseFlags(['--scope', 'email', '--scope', 'storage'], fnCmd.SCHEMA).flags.scope)
43
+ .toBe('email,storage');
44
+ expect(parseFlags(['--scope', 'all'], containerCmd.SCHEMA).flags.scope).toBe('all');
45
+ });
46
+ it('a merge still loses — which is why the dispatcher must not rely on one', () => {
47
+ // Pinning the old behaviour so the reason for the two-pass parse stays
48
+ // legible: this is what every command used to get.
49
+ const merged = { ...domainCmd.SCHEMA, ...storageCmd.SCHEMA };
50
+ expect(parseFlags(['--ttl', '3600'], merged).flags.ttl).toBe('3600');
51
+ const merged2 = { ...fnCmd.SCHEMA, ...containerCmd.SCHEMA };
52
+ expect(() => parseFlags(['--scope', 'a', '--scope', 'b'], merged2)).toThrow(/more than once/);
53
+ });
54
+ });
55
+ describe('collision inventory', () => {
56
+ it('every flag declared with two different types is listed here', async () => {
57
+ // Read every command module and collect flag → types. Plain fs rather than
58
+ // import.meta.glob: the tests are type-checked by tsc, which does not know
59
+ // Vite's glob helper.
60
+ const dir = fileURLToPath(new URL('./commands/', import.meta.url));
61
+ const files = readdirSync(dir)
62
+ // Some command modules have a colocated *.test.ts; importing one from
63
+ // inside a test makes vitest refuse the nested suite.
64
+ .filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts'));
65
+ const types = new Map();
66
+ let schemasSeen = 0;
67
+ for (const file of files) {
68
+ const mod = await import(pathToFileURL(join(dir, file)).href);
69
+ const schema = mod.SCHEMA;
70
+ if (!schema || typeof schema !== 'object')
71
+ continue;
72
+ schemasSeen++;
73
+ const name = file.replace(/\.ts$/, '');
74
+ for (const [flag, type] of Object.entries(schema)) {
75
+ if (!types.has(flag))
76
+ types.set(flag, new Map());
77
+ const byType = types.get(flag);
78
+ if (!byType.has(type))
79
+ byType.set(type, []);
80
+ byType.get(type).push(name);
81
+ }
82
+ }
83
+ // The scan must have found the command modules; an empty glob would make
84
+ // "no collisions" a statement about nothing.
85
+ expect(schemasSeen).toBeGreaterThan(20);
86
+ const collisions = [...types.entries()]
87
+ .filter(([, byType]) => byType.size > 1)
88
+ .map(([flag, byType]) => `${flag}: ` + [...byType.entries()]
89
+ .map(([t, cmds]) => `${t} (${cmds.sort().join(', ')})`).sort().join(' vs '))
90
+ .sort();
91
+ // Known and deliberate. Each is safe ONLY because the dispatcher types
92
+ // flags per command — adding one here means confirming that still holds.
93
+ expect(collisions).toEqual([
94
+ // fn accumulates repeated --scope into a list; container's --scope is a
95
+ // single value ("all"). Under the old merge, container won and
96
+ // `fn create --scope email --scope storage` threw "given more than once"
97
+ // — refusing the exact form fn.ts documents as supported.
98
+ // container's --env is repeatable so a value containing commas can be
99
+ // given on its own (`--env "ORIGINS=http://a,https://b"`), which is the
100
+ // shape an origin list actually has and which the single-string form
101
+ // refused. funnel's --env names a deploy channel (dev|prod) and is one
102
+ // value. Safe for the same reason as the others: the dispatcher types
103
+ // flags per command, so funnel never sees container's 'list'.
104
+ 'env: list (container) vs string (funnel)',
105
+ 'scope: list (fn) vs string (container)',
106
+ // domain's --ttl is DNS seconds and llm's is cache seconds; storage's is
107
+ // a duration string ("1h"). Under the old merge, storage won and both
108
+ // numeric ones silently became strings.
109
+ 'ttl: number (domain, llm) vs string (storage)',
110
+ ]);
111
+ });
112
+ });
package/dist/index.js CHANGED
@@ -411,6 +411,8 @@ async function dispatchAccount(subcommand, restArgs, flags) {
411
411
  }
412
412
  switch (subcommand) {
413
413
  case 'mailing-address': return accountCmd.run('mailing-address', restArgs, flags);
414
+ case 'sending': return accountCmd.run('sending', restArgs, flags);
415
+ case 'resume-sending': return accountCmd.run('resume-sending', restArgs, flags);
414
416
  case 'setup': return setupCmd.setup(flags);
415
417
  case 'import-key': return setupCmd.importKey(restArgs[0], flags);
416
418
  case 'whoami': return accountCmd.whoami(flags);
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: my-api-hq
3
- version: 1.2.2
3
+ version: 1.3.0
4
4
  description: >
5
5
  Auth, organizations, and billing hub. Start here to get an api_key and org_id — every other service depends on both.
6
6
  triggers: [api key, account, organization, org, billing, balance, topup, credits, setup, defaults, brand, sync brand, doctor, health check, is my org healthy]
7
- checksum: sha256-e7f0a16a130603ca5664cf5c268658bffd85230fe89dc82a4fda715ff81135b8
7
+ checksum: sha256-fd266da15b05dee12ce1e683612b76e0f5554247f255a9c8db9be006a403a8ff
8
8
  ---
9
9
 
10
10
  # MyApiHQ
@@ -26,7 +26,7 @@ An anonymous account can upgrade at any time via `myapi account link <email>`
26
26
 
27
27
  ### Health check
28
28
 
29
- `myapi doctor` runs an org-wide consistency check across every slot (funnels, webhooks, domains, containers, workflows, emails, payments) and layers on customer-perspective DNS/HTTP probes from your machine. It returns per-section findings (`✓` pass / `⚠` warning / `✗` critical) with remediation hints; add `--json` for machine output. The exit code is non-zero **only** on customer-actionable criticals — platform-side issues the MyAPI team is already handling are surfaced with an `ℹ` marker but don't fail the run. Run it to self-check before building (is the org set up?) and after (did everything wire up?).
29
+ `myapi doctor` runs an org-wide consistency check across every slot and layers on customer-perspective DNS/HTTP probes from your machine. It returns per-section findings (`✓` pass / `⚠` warning / `✗` critical) with remediation hints; add `--json` for machine output. The exit code is non-zero **only** on customer-actionable criticals — platform-side issues the MyAPI team is already handling are surfaced with an `ℹ` marker but don't fail the run. Run it to self-check before building (is the org set up?) and after (did everything wire up?).
30
30
  <!-- llm:end -->
31
31
 
32
32
  ## Commands
@@ -53,6 +53,7 @@ An anonymous account can upgrade at any time via `myapi account link <email>`
53
53
  | `myapi billing usage [--period month|30d]` | Spend rolled up by service (month or trailing 30d) |
54
54
  | `myapi billing spend-cap [<amount> | clear] [--period month|day]` | Set/show/clear the account-level spend ceiling |
55
55
  | `myapi billing auto-recharge [show \| set \| disable]` | Keep the wallet funded — off-session refill when balance drops below a threshold, capped monthly |
56
+ | `myapi account sending` / `myapi account resume-sending` | Is sending paused (bounces), and turn it on |
56
57
  | `myapi account mailing-address ["<address>"]` | Get or set the account's CAN-SPAM mailing address (required for email send) |
57
58
  | `myapi config set-org <id>` / `set-funnel <id>` / `set-domain <name>` | Set CLI defaults |
58
59
  | `myapi install-skills` | Install agent skills into ~/.claude/, ~/.gemini/, ~/.cursor/ |
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: my-crm-api
3
- version: 1.0.2
3
+ version: 1.2.0
4
4
  description: >
5
5
  The canonical store of engaged contacts + companies for an org. Auto-ingests from inbound webhooks via a configurable dot-path. Fixed lifecycle_stage enum (cold | warm | qualified | customer | churned). Append-only event timeline with reserved kinds. Soft delete + restore. Promote-from-Goldfox closes the discovery → engagement loop.
6
6
  triggers: [crm, contact, company, lead, engagement, pipeline, lifecycle, qualified, customer, webhook ingest, promote]
7
- checksum: sha256-ac33bd091d64089bf1d43b20431eff5361e018406bb5046f3c27fd628f7082fb
7
+ checksum: sha256-fab860f3f7592640ab15b4f91670cba8217330617d7c9e96dd8e9e5f76abb657
8
8
  ---
9
9
 
10
10
  # MyCRMAPI
@@ -21,7 +21,7 @@ The store that closes the funnel. Without CRM the loop ends nowhere: discover pe
21
21
  - **mycrmapi** — engaged contacts + companies, *private to your org*, with engagement history
22
22
 
23
23
  A Goldfox row becomes a CRM contact when:
24
- 1. You explicitly **promote** it (`myapi crm contacts promote <goldfox_person_id>`)
24
+ 1. You **promote** it (`myapi crm contacts promote <goldfox_person_id>`)
25
25
  2. *Or* a downstream service receives engagement for that email and auto-upserts the contact
26
26
 
27
27
  ### Lifecycle stages (fixed enum — same for contacts and companies)
@@ -48,22 +48,21 @@ email_sent | email_opened | email_clicked | email_replied
48
48
  pixel_visit | webhook_received | payment
49
49
  ```
50
50
 
51
- Agents cannot write events directly — the closed enum is intentional. For custom state use **mydatabaseapi** (KV) keyed on the contact id; the curated timeline stays authoritative.
51
+ Agents cannot write events directly — the enum is closed on purpose. For custom state use **mydatabaseapi** keyed on the contact id.
52
52
 
53
53
  **Engagement kinds bump `last_engagement_at`**: email_*, pixel_visit, webhook_received. Admin kinds (created, promoted, stage_changed) don't — promoting a lead isn't engagement.
54
54
 
55
55
  ### Auto-ingest
56
56
 
57
- Today (v1):
58
- - **Webhook**: set per endpoint via `crm_email_path`, a JSON dot-path. Default `"email"` ingests `{"email":"x@y.com"}`. For Stripe, set `data.object.customer_email`; for GitHub, `sender.email`. Empty string disables ingest.
57
+ - **Webhook** (live): set per endpoint via `crm_email_path`, a JSON dot-path. Default `"email"` ingests `{"email":"x@y.com"}`. For Stripe, set `data.object.customer_email`; for GitHub, `sender.email`. Empty string disables ingest.
59
58
 
60
- Coming next (backend wiring in progress):
59
+ Coming next:
61
60
  - **Email**: every `myapi email message send` writes `email_sent`; opens/clicks fire `email_opened`/`email_clicked`
62
61
  - **Pixel**: `identify` calls with an email write `pixel_visit`
63
62
 
64
- If a contact doesn't exist for the matched email, it's auto-created with `source=` matching the originating service. The contact's company is auto-linked by email domain (creates the company on first sight).
63
+ An unknown email auto-creates the contact with `source=` the originating service, and links its company by email domain.
65
64
 
66
- **Missing lead? Check `myapi webhook deliveries` before concluding it never arrived.** Raw payloads are always stored, so the delivery is there even when the contact isn't.
65
+ **Missing lead? Check `myapi webhook deliveries` first.** Raw payloads are always stored, so the delivery is there even when the contact isn't.
67
66
 
68
67
  ### Soft delete + restore
69
68
 
@@ -71,11 +70,11 @@ If a contact doesn't exist for the matched email, it's auto-created with `source
71
70
 
72
71
  ### Goldfox enrichment (deferred)
73
72
 
74
- A contact promoted from Goldfox carries a `goldfox_person_id`; the embedded `goldfox_person` is null today, and Goldfox-only fields are not searchable.
73
+ A promoted contact carries a `goldfox_person_id`; the embedded `goldfox_person` is null and Goldfox-only fields are not searchable.
75
74
 
76
75
  ### Search filter — re-engagement semantics
77
76
 
78
- `--max-last-engagement-days N` returns contacts last engaged *more than* N days ago, and intentionally **includes contacts with no engagement at all** (promoted-but-never-emailed Goldfox leads) the natural targets of a re-engagement campaign. `--min-last-engagement-days N` is its complement: engaged *within* N days. `--company-id <id>` narrows to one company. To separate "never tried" from "tried and went cold," layer `--origin goldfox`.
77
+ `--max-last-engagement-days N` returns contacts last engaged *more than* N days ago and intentionally **includes contacts never engaged at all** promoted-but-never-emailed leads are the point of a re-engagement campaign. `--min-last-engagement-days N` is its complement; `--company-id` narrows to one company. Layer `--origin goldfox` to separate "never tried" from "went cold".
79
78
 
80
79
  ### Failure modes
81
80
 
@@ -99,6 +98,8 @@ A contact promoted from Goldfox carries a `goldfox_person_id`; the embedded `gol
99
98
  | `myapi crm contacts delete <id>` | Soft delete (events retained) |
100
99
  | `myapi crm contacts restore <id>` | Restore a soft-deleted contact |
101
100
  | `myapi crm contacts promote <goldfox_person_id>` | Idempotent Goldfox → CRM promote |
101
+ | `myapi crm contacts promote-audience <audience_id>` | Bulk-promote a saved people-audience; idempotent, resumable |
102
+ | `myapi crm contacts import <file.csv>` | CSV import; header row, `email` column required. Existing addresses are matched, never overwritten |
102
103
  | `myapi crm contacts events <id> [--kind ...]` | Timeline (newest first), filter by kind |
103
104
 
104
105
  ### Companies
@@ -114,10 +115,10 @@ All commands accept `--org <id>` (or set default: `myapi config set-org <id>`) a
114
115
  ## Examples
115
116
  <!-- llm:start -->
116
117
  ```bash
117
- # Discover → promote → engage workflow
118
- myapi people search --keyword saas --has-c-level --country US --limit 5 --json \
119
- | jq -r '.people[].id' \
120
- | while read pid; do myapi crm contacts promote "$pid"; done
118
+ # Discover → promote → engage. Bulk resumes and cannot double-create.
119
+ myapi audience list # saved Goldfox filters
120
+ myapi crm contacts promote-audience <audience_id> # whole audience
121
+ myapi crm contacts promote <goldfox_person_id> # or one lead
121
122
 
122
123
  # Find everyone in 'qualified' for a follow-up email
123
124
  myapi crm contacts search --stage qualified --json | jq -r '.contacts[].email'
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: my-email-api
3
- version: 1.0.1
3
+ version: 1.2.0
4
4
  description: >
5
5
  Send transactional and bulk email from your own domain. Create mailboxes, send/receive messages, generate AI templates, and manage warmup.
6
6
  triggers: [email, mailbox, send email, transactional email, template, warmup, inbox, outbox, ses, sender reputation]
7
- checksum: sha256-18f539fc12dd9e9e8f2abecf518aa1ee70741f85cc26ba0a6a941b9c29d537d8
7
+ checksum: sha256-897cf3dbc843ca06d02aecf298151e8635890852a5db8ab4035ee59df8db0aa9
8
8
  ---
9
9
 
10
10
  # MyEmailAPI
@@ -28,6 +28,7 @@ A registered domain via **mydomainapi** is the prerequisite — mailboxes need a
28
28
  | `email message` | `send`, `status`, `sent`, `inbox`, `outbox`, `get` | Transactional send + read |
29
29
  | `email warmup` | `start`, `stats`, `pause`, `resume`, `stop` | IP/domain warmup for sending reputation |
30
30
  | `email template` | `generate`, `list`, `get`, `preview`, `edit`, `send-test`, `delete` | AI-generated HTML templates |
31
+ | `email campaign` | `create`, `list`, `get`, `update`, `resolve`, `start`, `pause`, `resume`, `cancel`, `recipients`, `stats` | Scheduled bulk send that drains at a daily limit |
31
32
  <!-- generated:end -->
32
33
 
33
34
  ## Examples
@@ -72,10 +73,55 @@ myapi email warmup stats --address hello@yourdomain.com
72
73
  | `--per-day` | `warmup start` | Cap the daily volume the ramp climbs to |
73
74
  | `--emails`, `--quick` | `verify bulk` | Addresses inline rather than on stdin; skip the catch-all probe |
74
75
 
76
+ | `--addresses <csv>` | `campaign create` | An explicit short recipient list |
77
+ | `--crm-stage`, `--crm-origin`, `--crm-company` | `campaign create` | Draw recipients from CRM instead |
78
+ | `--crm-audience <id>` | `campaign create` | Only the people promoted from that saved audience |
79
+ | `--crm-max-days`, `--crm-min-days` | `campaign create` | Engaged more than / within N days ago |
80
+ | `--all-contacts` | `campaign create` | Every CRM contact — the unfiltered query, asked for by name |
81
+ | `--state` | `campaign recipients` | Filter by `queued`, `sent`, `failed` or `excluded` |
82
+
75
83
  Forwarding keeps the original: `set-forwarding <user@domain> <forward-to@domain>`
76
84
  copies every inbound message to an external address and leaves it in the mailbox.
77
85
  `clear-forwarding <user@domain>` stops it.
78
86
 
87
+ ## Campaigns — a scheduled send, not a bulk one
88
+
89
+ A campaign is scheduling on top of `message send`. It picks a **source**, freezes
90
+ it, then drains at a daily limit.
91
+
92
+ ```bash
93
+ # Draw from CRM: qualified contacts not engaged in 30+ days
94
+ myapi email campaign create --name "Q3 re-engage" \
95
+ --template <template-id> --from you@yourdomain.com \
96
+ --crm-stage qualified --crm-max-days 30
97
+
98
+ myapi email campaign resolve <id> # who it reaches + what it costs. SENDS NOTHING.
99
+ myapi email campaign start <id> # begins; consent to the cost
100
+ myapi email campaign stats <id> # counts by state, sent today, daily limit
101
+ ```
102
+
103
+ Four things that decide how you use it:
104
+
105
+ - **`resolve` before `start`, always.** Resolve freezes the recipient set and
106
+ reports the count and the estimated cost without sending. It is the only way
107
+ to see what a start commits to, and `start` refuses an unresolved campaign.
108
+ - **It does NOT send all at once.** A campaign spends `per_day_limit` per day,
109
+ spread across the day, and continues tomorrow. A campaign still `active` the
110
+ next morning is working, not stuck — bursting a list is the surest way to get
111
+ an account's sending paused.
112
+ - **Recipients come from a source, not an upload.** `--crm-*` filters CRM
113
+ contacts (and sends land on their timeline); `--addresses` is for a short
114
+ ad-hoc list. There is no file upload: import into CRM first, and suppression,
115
+ unsubscribes and history then apply to those people like everyone else.
116
+ - **Exclusions are reported, not hidden.** Resolve returns counts by reason —
117
+ suppressed, duplicate, invalid — so a set smaller than the source is
118
+ explainable. Suppression is checked AGAIN at send time, so someone who
119
+ unsubscribes mid-campaign is dropped rather than mailed.
120
+
121
+ `pause` stops after the message in flight; `resume` continues; `cancel` ends it
122
+ and drops what was queued. Status `paused_insufficient_funds` means the account
123
+ could not pay — top up or enable auto-recharge, then `resume`.
124
+
79
125
  ## Notes
80
126
 
81
127
  - A mailbox is uniquely identified by its address (`username@domain`).
@@ -111,6 +157,7 @@ reply { "success": true, "data": …, "error": null, "meta": {…} }
111
157
 
112
158
  - **Per-slot host** — do not assume one host serves every slot.
113
159
  - **Account-scoped, not org-scoped** — no `{org_id}` segment; the key identifies the account.
160
+ - **Lists page** — one page is not the whole list; check `meta.has_more`.
114
161
  <!-- http:end -->
115
162
 
116
163
  Run `myapi email --help` or `myapi email <namespace> --help` for full flag reference.
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  Synchronous single-address email verification — syntax + DNS + Microsoft GetCredentialType probe. Returns a verdict in <1s for ~50% of inputs; the rest get verdict='unknown' with smtp_recommended=true. The pre-send quality gate for any outbound campaign.
6
6
  triggers: [email verify, email validation, deliverability, smtp, syntax check, dns mx, microsoft, mx lookup, bounce prevention]
7
- checksum: sha256-c8ae836c355066eee8a12753f818d3a90c35b99983d74913c8fc76edcc99668f
7
+ checksum: sha256-0a654a6674bb40863d9bbffb3e6862e10f7e7603695e995416779f0a6b312865
8
8
  ---
9
9
 
10
10
  # MyEmailVerifyAPI
@@ -103,6 +103,7 @@ reply { "success": true, "data": …, "error": null, "meta": {…} }
103
103
 
104
104
  - **Per-slot host** — do not assume one host serves every slot.
105
105
  - **Org id goes in the PATH** — there is no `X-Org-Id` header.
106
+ - **Lists page** — one page is not the whole list; check `meta.has_more`.
106
107
  <!-- http:end -->
107
108
 
108
109
  Run `myapi email verify --help` for inline reference.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.21.1",
4
+ "version": "2.23.0",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -46,7 +46,7 @@
46
46
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
47
47
  },
48
48
  "dependencies": {
49
- "@myapihq/sdk": "^2.21.1"
49
+ "@myapihq/sdk": "^2.23.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",