@myapihq/cli 2.22.0 → 2.23.1
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/dist/commands/account.d.ts +1 -1
- package/dist/commands/account.js +4 -2
- package/dist/commands/container.d.ts +1 -0
- package/dist/commands/container.js +55 -11
- package/dist/commands/crm/contacts.js +73 -0
- package/dist/commands/crm/index.js +1 -0
- package/dist/commands/email/campaign-source-flags.test.d.ts +1 -0
- package/dist/commands/email/campaign-source-flags.test.js +52 -0
- package/dist/commands/email/campaign.d.ts +2 -1
- package/dist/commands/email/campaign.js +21 -2
- package/dist/commands/email/index.js +1 -0
- package/dist/commands/env-commas.test.d.ts +1 -0
- package/dist/commands/env-commas.test.js +44 -0
- package/dist/errors.js +25 -0
- package/dist/errors.test.js +42 -0
- package/dist/flag-schema-collisions.test.d.ts +1 -0
- package/dist/flag-schema-collisions.test.js +112 -0
- package/dist/skills/my-crm-api/SKILL.md +16 -15
- package/dist/skills/my-email-api/SKILL.md +3 -2
- package/package.json +2 -2
|
@@ -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 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";
|
|
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 (account or org)\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>;
|
package/dist/commands/account.js
CHANGED
|
@@ -56,7 +56,7 @@ Subcommands:
|
|
|
56
56
|
login Sign in via your browser — Google or email code (preview: --mock)
|
|
57
57
|
mailing-address Get or set the account's mailing address (CAN-SPAM)
|
|
58
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
|
|
59
|
+
resume-sending Turn email sending back on after an automatic pause (account or org)
|
|
60
60
|
sending Whether this account can send email, and how close it is to the limits
|
|
61
61
|
setup Configure your account
|
|
62
62
|
switch [index] Switch active account by index or email
|
|
@@ -132,7 +132,9 @@ export const SUBCOMMAND_USAGE = {
|
|
|
132
132
|
Under 100 sends in 24h the ratios are not judged, and the reply says so.`,
|
|
133
133
|
'resume-sending': `myapi account resume-sending [--json]
|
|
134
134
|
|
|
135
|
-
Turn sending back on after an automatic pause
|
|
135
|
+
Turn sending back on after an automatic pause — whether the pause is on the
|
|
136
|
+
account or on one of its orgs. It sits under account because one call lifts
|
|
137
|
+
both; an org-scoped twin would be a second name for the same endpoint.
|
|
136
138
|
|
|
137
139
|
Refused with STILL_OVER_THRESHOLD while the last 24 hours are still over
|
|
138
140
|
the limit — the numbers come back with the refusal, so you can see how far
|
|
@@ -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: '
|
|
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
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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[
|
|
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
|
-
|
|
727
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { SOURCE_FLAGS } from './campaign.js';
|
|
5
|
+
import { SCHEMA } from './index.js';
|
|
6
|
+
// `campaign update` decides whether the recipient source was touched by looking
|
|
7
|
+
// at a list of flags. sourceFromFlags reads its own. When those two lists
|
|
8
|
+
// disagree, the losing flag is parsed, printed in --help, documented in the
|
|
9
|
+
// skill — and silently discarded: `campaign update --crm-audience <id>`
|
|
10
|
+
// answered "Nothing to change" and made no call at all.
|
|
11
|
+
//
|
|
12
|
+
// They now share SOURCE_FLAGS. These tests keep that true by deriving the
|
|
13
|
+
// answer from the code rather than restating it: a hand-copied list in a test
|
|
14
|
+
// drifts exactly the way the thing it is testing drifted.
|
|
15
|
+
const src = readFileSync(fileURLToPath(new URL('./campaign.ts', import.meta.url)), 'utf-8');
|
|
16
|
+
/** Flag names read inside sourceFromFlags — the flags that actually build a source. */
|
|
17
|
+
function flagsReadWhenBuildingASource() {
|
|
18
|
+
const start = src.indexOf('function sourceFromFlags');
|
|
19
|
+
expect(start, 'sourceFromFlags not found — this test is reading the wrong file').toBeGreaterThan(-1);
|
|
20
|
+
const end = src.indexOf('\nfunction ', start + 10);
|
|
21
|
+
const body = src.slice(start, end === -1 ? undefined : end);
|
|
22
|
+
const names = new Set();
|
|
23
|
+
for (const m of body.matchAll(/flags\['([^']+)'\]/g))
|
|
24
|
+
names.add(m[1]);
|
|
25
|
+
for (const m of body.matchAll(/flags\.([a-zA-Z][\w]*)/g))
|
|
26
|
+
names.add(m[1]);
|
|
27
|
+
return [...names];
|
|
28
|
+
}
|
|
29
|
+
describe('campaign source flags', () => {
|
|
30
|
+
it('every flag sourceFromFlags reads is one update() watches', () => {
|
|
31
|
+
const read = flagsReadWhenBuildingASource();
|
|
32
|
+
// The scan must find something; an empty list would make this pass while
|
|
33
|
+
// measuring nothing.
|
|
34
|
+
expect(read.length).toBeGreaterThanOrEqual(7);
|
|
35
|
+
const watched = new Set(SOURCE_FLAGS);
|
|
36
|
+
const unwatched = read.filter(f => !watched.has(f));
|
|
37
|
+
expect(unwatched, 'these build a source but update() would ignore them').toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
it('every watched flag is declared on the email command', () => {
|
|
40
|
+
// A flag update() watches but the parser does not know is always
|
|
41
|
+
// undefined, so it silently never triggers.
|
|
42
|
+
const undeclared = SOURCE_FLAGS.filter(f => !(f in SCHEMA));
|
|
43
|
+
expect(undeclared, 'watched but not in the email SCHEMA').toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
it('every watched flag actually builds part of a source', () => {
|
|
46
|
+
// The other direction: an entry nobody reads makes `update` call the API
|
|
47
|
+
// with a source that did not change.
|
|
48
|
+
const read = new Set(flagsReadWhenBuildingASource());
|
|
49
|
+
const unread = SOURCE_FLAGS.filter(f => !read.has(f));
|
|
50
|
+
expect(unread, 'watched by update() but never read when building a source').toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Flags } from '../../helpers.js';
|
|
2
2
|
import type { Exposes } from '../../exposes.js';
|
|
3
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 --addresses a@x.com,b@y.com an explicit short list";
|
|
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 const SOURCE_FLAGS: readonly ["addresses", "crm-stage", "crm-origin", "crm-company", "crm-audience", "crm-max-days", "crm-min-days", "all-contacts"];
|
|
5
6
|
export declare function run(sub: string | undefined, args: string[], flags: Flags): Promise<void>;
|
|
@@ -35,7 +35,23 @@ commonly still active tomorrow — that is the design, not a stall.
|
|
|
35
35
|
|
|
36
36
|
Recipients come from a SOURCE, not a list you upload:
|
|
37
37
|
--crm-stage / --crm-origin / --crm-max-days filter CRM contacts
|
|
38
|
+
--crm-audience <id> only people promoted from it
|
|
38
39
|
--addresses a@x.com,b@y.com an explicit short list`;
|
|
40
|
+
// Every flag that names part of a recipient source. Declared once because two
|
|
41
|
+
// places need it and they must agree: sourceFromFlags READS them, and `update`
|
|
42
|
+
// decides from them whether the source was touched at all. When those two
|
|
43
|
+
// drifted, `campaign update --crm-audience <id>` answered "Nothing to change"
|
|
44
|
+
// and made no call — the flag was parsed, documented, and then ignored.
|
|
45
|
+
export const SOURCE_FLAGS = [
|
|
46
|
+
'addresses',
|
|
47
|
+
'crm-stage',
|
|
48
|
+
'crm-origin',
|
|
49
|
+
'crm-company',
|
|
50
|
+
'crm-audience',
|
|
51
|
+
'crm-max-days',
|
|
52
|
+
'crm-min-days',
|
|
53
|
+
'all-contacts',
|
|
54
|
+
];
|
|
39
55
|
// Building the source from flags is the one place the CLI has an opinion: a
|
|
40
56
|
// campaign names where its people come from, and mixing two sources in one
|
|
41
57
|
// command is a request nobody can mean.
|
|
@@ -50,6 +66,10 @@ function sourceFromFlags(flags) {
|
|
|
50
66
|
crm.origin = flags['crm-origin'];
|
|
51
67
|
if (typeof flags['crm-company'] === 'string' && flags['crm-company'])
|
|
52
68
|
crm.company_id = flags['crm-company'];
|
|
69
|
+
// The people promoted from ONE saved audience, rather than every lead ever
|
|
70
|
+
// promoted — which is rarely the campaign anybody means.
|
|
71
|
+
if (typeof flags['crm-audience'] === 'string' && flags['crm-audience'])
|
|
72
|
+
crm.audience_id = flags['crm-audience'];
|
|
53
73
|
if (typeof flags['crm-max-days'] === 'number')
|
|
54
74
|
crm.max_last_engagement_days = flags['crm-max-days'];
|
|
55
75
|
if (typeof flags['crm-min-days'] === 'number')
|
|
@@ -131,8 +151,7 @@ async function update(id, flags) {
|
|
|
131
151
|
const patch = {};
|
|
132
152
|
if (typeof flags.name === 'string' && flags.name)
|
|
133
153
|
patch.name = flags.name;
|
|
134
|
-
const wantsSource =
|
|
135
|
-
|| flags['crm-company'] || flags['crm-max-days'] || flags['crm-min-days'] || flags['all-contacts'];
|
|
154
|
+
const wantsSource = SOURCE_FLAGS.some(f => flags[f] !== undefined);
|
|
136
155
|
if (wantsSource)
|
|
137
156
|
Object.assign(patch, sourceFromFlags(flags));
|
|
138
157
|
if (Object.keys(patch).length === 0)
|
|
@@ -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
|
+
});
|
package/dist/errors.js
CHANGED
|
@@ -100,6 +100,31 @@ export function friendlyError(err) {
|
|
|
100
100
|
if (err.code === 'REGISTRAR_RATE_LIMITED') {
|
|
101
101
|
return 'Domain registration is busy right now — please wait a few minutes before trying again (avoid automatic retries).';
|
|
102
102
|
}
|
|
103
|
+
// A paused account is told how to un-pause itself — and the backend says
|
|
104
|
+
// `POST /hq/account/resume-sending`, which is right for an API caller and
|
|
105
|
+
// wrong for this one. Rewrite the route, keep everything else.
|
|
106
|
+
//
|
|
107
|
+
// Rewriting rather than replacing the sentence: the figures in it are the
|
|
108
|
+
// ones that caused the pause and are per-account. A fixed string here would
|
|
109
|
+
// drop them, and they are the reason the message is worth reading.
|
|
110
|
+
//
|
|
111
|
+
// The backend added this instruction on 2026-08-22 (myapi-hq #159) after a
|
|
112
|
+
// customer waited six days on "contact support to review" while the resume
|
|
113
|
+
// endpoint already existed.
|
|
114
|
+
if (err.code === 'ACCOUNT_PAUSED' || err.code === 'ORG_PAUSED') {
|
|
115
|
+
const raw = (err.detail || ERROR_MESSAGES[err.code] || err.code).trim();
|
|
116
|
+
let out = raw.replace(/POST\s+\/hq\/account\/resume-sending/g, 'myapi account resume-sending');
|
|
117
|
+
// An older backend sends no instruction at all; add one rather than leaving
|
|
118
|
+
// the reader where that customer was.
|
|
119
|
+
if (!out.includes('resume-sending')) {
|
|
120
|
+
out += ' — fix the addresses that bounce, then: myapi account resume-sending';
|
|
121
|
+
}
|
|
122
|
+
// The figures in the message are frozen at the moment of the pause and do
|
|
123
|
+
// not move; `account sending` is the live picture, and reading the frozen
|
|
124
|
+
// ones as current is the whole confusion.
|
|
125
|
+
out += '\n Current numbers: myapi account sending';
|
|
126
|
+
return withOrgContext(out, err);
|
|
127
|
+
}
|
|
103
128
|
const base = ERROR_MESSAGES[err.code] || err.code;
|
|
104
129
|
// Generic fallback: append the backend's `message` when it adds info beyond
|
|
105
130
|
// the friendly mapping. Future per-service detail fields can be added here.
|
package/dist/errors.test.js
CHANGED
|
@@ -26,3 +26,45 @@ describe('friendlyError', () => {
|
|
|
26
26
|
expect(friendlyError(new MyApiError('SOME_NEW_CODE', 400))).toBe('SOME_NEW_CODE');
|
|
27
27
|
});
|
|
28
28
|
});
|
|
29
|
+
describe('paused sending points at the CLI, not an HTTP route', () => {
|
|
30
|
+
// Verbatim from the backend on 2026-08-22 (myapi-hq #159). The instruction is
|
|
31
|
+
// correct for an API caller and wrong for this one.
|
|
32
|
+
const ACCOUNT_TEXT = 'sending paused: bounce rate 7.83% > 4% (9 bounces / 115 sends) — the figures are from '
|
|
33
|
+
+ 'when the pause was set and do not change. Fix the addresses that bounce, then POST '
|
|
34
|
+
+ '/hq/account/resume-sending';
|
|
35
|
+
const ORG_TEXT = 'sending paused for this org: bounce rate 6.10% > 4% — fix the addresses that bounce, '
|
|
36
|
+
+ 'then POST /hq/account/resume-sending';
|
|
37
|
+
it('ACCOUNT_PAUSED names the command instead of the endpoint', () => {
|
|
38
|
+
const msg = friendlyError(new MyApiError('ACCOUNT_PAUSED', 403, ACCOUNT_TEXT));
|
|
39
|
+
expect(msg).toContain('myapi account resume-sending');
|
|
40
|
+
// The whole point: a CLI user must not be told to make an HTTP POST for
|
|
41
|
+
// something this CLI already does.
|
|
42
|
+
expect(msg).not.toContain('POST /hq/account/resume-sending');
|
|
43
|
+
});
|
|
44
|
+
it('keeps the figures and the fact that they are frozen', () => {
|
|
45
|
+
const msg = friendlyError(new MyApiError('ACCOUNT_PAUSED', 403, ACCOUNT_TEXT));
|
|
46
|
+
// These are why the message is worth reading, and a fixed replacement
|
|
47
|
+
// string would have dropped them.
|
|
48
|
+
expect(msg).toContain('7.83%');
|
|
49
|
+
expect(msg).toContain('9 bounces / 115 sends');
|
|
50
|
+
expect(msg).toMatch(/do not change/);
|
|
51
|
+
});
|
|
52
|
+
it('points at the live numbers, which the frozen ones are not', () => {
|
|
53
|
+
const msg = friendlyError(new MyApiError('ACCOUNT_PAUSED', 403, ACCOUNT_TEXT));
|
|
54
|
+
expect(msg).toContain('myapi account sending');
|
|
55
|
+
});
|
|
56
|
+
it('ORG_PAUSED gets the same treatment and keeps its scope', () => {
|
|
57
|
+
const msg = friendlyError(new MyApiError('ORG_PAUSED', 403, ORG_TEXT));
|
|
58
|
+
expect(msg).toContain('myapi account resume-sending');
|
|
59
|
+
expect(msg).not.toContain('POST /hq/account/resume-sending');
|
|
60
|
+
// The reader must still be able to tell WHICH pause they hit — one command
|
|
61
|
+
// lifts both, but the scope changes what they go and fix.
|
|
62
|
+
expect(msg).toContain('for this org');
|
|
63
|
+
});
|
|
64
|
+
it('adds the instruction when an older backend sends none', () => {
|
|
65
|
+
// Before #159 the message ended at "contact support to review", which is
|
|
66
|
+
// where a customer sat for six days.
|
|
67
|
+
const msg = friendlyError(new MyApiError('ACCOUNT_PAUSED', 403, 'sending paused: bounce rate too high — contact support to review'));
|
|
68
|
+
expect(msg).toContain('myapi account resume-sending');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: my-crm-api
|
|
3
|
-
version: 1.0
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
118
|
-
myapi
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
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-
|
|
7
|
+
checksum: sha256-897cf3dbc843ca06d02aecf298151e8635890852a5db8ab4035ee59df8db0aa9
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# MyEmailAPI
|
|
@@ -75,6 +75,7 @@ myapi email warmup stats --address hello@yourdomain.com
|
|
|
75
75
|
|
|
76
76
|
| `--addresses <csv>` | `campaign create` | An explicit short recipient list |
|
|
77
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 |
|
|
78
79
|
| `--crm-max-days`, `--crm-min-days` | `campaign create` | Engaged more than / within N days ago |
|
|
79
80
|
| `--all-contacts` | `campaign create` | Every CRM contact — the unfiltered query, asked for by name |
|
|
80
81
|
| `--state` | `campaign recipients` | Filter by `queued`, `sent`, `failed` or `excluded` |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myapihq/cli",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.23.1",
|
|
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.
|
|
49
|
+
"@myapihq/sdk": "^2.23.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^25.6.0",
|