@letterstory/cli 0.45.0 → 0.47.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.
- package/README.md +52 -1
- package/lib/cli.mjs +29 -4
- package/lib/commands/admin.mjs +180 -2
- package/lib/commands/outbound.mjs +137 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -386,6 +386,8 @@ letterstory admin crm accounts --stage demo_scheduled # with likelihood
|
|
|
386
386
|
letterstory admin crm score <account-id> # recompute; prints the factors
|
|
387
387
|
letterstory admin crm slack-channels [--all] # Watchdog channels + the account each is linked to
|
|
388
388
|
letterstory admin crm link-slack <account-id> C0123ABC # the CRM's watch-only Slack link (`none` unlinks)
|
|
389
|
+
letterstory admin crm import leads.csv --dry-run # bulk-import leads: what would be created/matched
|
|
390
|
+
letterstory admin crm import leads.csv --tag "YC W26" # …and for real (dedupes by domain, then company name)
|
|
389
391
|
|
|
390
392
|
# One-click onboarding of a won client (key needs crm:write + fleet:onboard + org:create)
|
|
391
393
|
letterstory admin crm onboard <account-id> --domain acme.com \
|
|
@@ -393,11 +395,16 @@ letterstory admin crm onboard <account-id> --domain acme.com \
|
|
|
393
395
|
--slack jane@acme.com --invite jane@acme.com:admin --invite bob@acme.com
|
|
394
396
|
letterstory admin crm onboarding <account-id> # step-by-step progress (sites refreshed)
|
|
395
397
|
letterstory admin crm retry-onboarding <onboarding-id> # re-run only the failed steps
|
|
398
|
+
letterstory admin crm onboard-plan <account-id> # what the calls say: sites, Slack/app people, paid?
|
|
399
|
+
letterstory admin crm payment-link <account-id> # Stripe Payment Link at the deal amount (billing:write)
|
|
396
400
|
```
|
|
397
401
|
|
|
398
402
|
`onboard` runs org → link → phantom sites → Slack channel → checklist → app invites →
|
|
399
403
|
stage in one call. Each site is a real (not test) fleet build; `--site ""` lets the engine
|
|
400
|
-
infer its beat from the client's homepage.
|
|
404
|
+
infer its beat from the client's homepage. `onboard-plan` reads the account's recorded calls
|
|
405
|
+
(newest first, cached per set of calls; `--refresh` re-reads) and says how many sites, what each
|
|
406
|
+
is about, who to invite, and whether they've paid. `payment-link` stamps the account's org on
|
|
407
|
+
the Stripe subscription, so paying through it binds and marks the account paid by itself.
|
|
401
408
|
|
|
402
409
|
Every trigger — Cal.com bookings, Circleback recordings, Letterhome dinner RSVPs, RB2B
|
|
403
410
|
visits, and what an agent records here — goes through one intake (`ingestSignal`,
|
|
@@ -663,6 +670,50 @@ letterstory insights bank [--source phantoms|marketing|all] [--phantom <uuid>]..
|
|
|
663
670
|
letterstory insights access [--period 14d|30d|90d] [--collection <uuid>]
|
|
664
671
|
```
|
|
665
672
|
|
|
673
|
+
## Outbound email — mailers, sequences, prospects
|
|
674
|
+
|
|
675
|
+
The cold-email stack: sending **mailers** (real Google mailboxes on domains we own), **sequences**
|
|
676
|
+
(multi-step flows sent from them, with a review queue) and **prospects** (who you write to). Writes
|
|
677
|
+
here can SEND email or spend money, so they refuse unknown flags instead of ignoring them. Scopes:
|
|
678
|
+
`mailer:read` / `mailer:manage` / `mailer:provision`, `sequence:read` / `sequence:write`,
|
|
679
|
+
`prospect:read` / `prospect:write` (the write scopes are admin-only keys).
|
|
680
|
+
|
|
681
|
+
```bash
|
|
682
|
+
# Mailers
|
|
683
|
+
letterstory mailers list [--domain acme.com] # status + daily send limit
|
|
684
|
+
letterstory mailers domain <owned-domain-id> # provisioning stage, DNS, mailers on it
|
|
685
|
+
letterstory mailers messages <mailer-id> [--folder inbox|sent|all] [--limit 25] # envelopes
|
|
686
|
+
letterstory mailers message <mailer-id> <uid> --folder inbox # one message, plain-text body
|
|
687
|
+
letterstory mailers forward <mailer-id> --to me@acme.com # or --clear
|
|
688
|
+
letterstory mailers warmup start|pause <mailer-id> # 10 days to ready
|
|
689
|
+
letterstory mailers limit <mailer-id> 40 # 0..500; refused while warming
|
|
690
|
+
letterstory mailers retire <mailer-id> # disable; history kept
|
|
691
|
+
letterstory mailers retire <mailer-id> --release-mailbox --yes # also delete the hosted mailbox (irreversible)
|
|
692
|
+
letterstory mailers suppressions [list] [--reason bounce] [--limit 100]
|
|
693
|
+
letterstory mailers suppressions add person@x.com --reason manual --note "asked to stop"
|
|
694
|
+
letterstory mailers suppressions remove person@x.com # only 'manual' entries; opt-outs stay
|
|
695
|
+
|
|
696
|
+
# Launching buys a domain (up to $30) and the mailboxes, so it is a tool call, not a command:
|
|
697
|
+
letterstory call launch_mailers --args '{"domain":"acme-mail.com","mailers":[{"local_part":"hello"}],"consent_attested":true,"mail_from_postal_address":"…"}'
|
|
698
|
+
|
|
699
|
+
# Org-level mail settings
|
|
700
|
+
letterstory mail connections # connected mailboxes, key + postal address status
|
|
701
|
+
letterstory mail postal-address "123 Main St, Portland, OR 97201" # CAN-SPAM footer
|
|
702
|
+
|
|
703
|
+
# Sequences (approving or enrolling SENDS)
|
|
704
|
+
letterstory sequences list | get <id> | create | update <id> | pause <id> | resume <id>
|
|
705
|
+
letterstory sequences enroll <id> --list <list-id> # or --email a@x.com ...
|
|
706
|
+
letterstory sequences review list | approve <item-id> | skip <item-id> | edit <item-id> --subject "…"
|
|
707
|
+
letterstory sequences links create --days 2 # no-login phone review link, shown once
|
|
708
|
+
|
|
709
|
+
# Prospects
|
|
710
|
+
letterstory prospects lists | ls --list <list-id> | get <id> | import --csv-file leads.csv --list-name "Q4"
|
|
711
|
+
letterstory prospects source --description "heads of growth at seed-stage fintech" --list-name "Fintech"
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
Connecting a bring-your-own Gmail mailbox needs a browser (Google consent), so it is done in the
|
|
715
|
+
app; `letterstory mail connections` shows what is connected.
|
|
716
|
+
|
|
666
717
|
## Anything else
|
|
667
718
|
|
|
668
719
|
Every Letterstory tool is reachable, not just the ones with a dedicated command group:
|
package/lib/cli.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
cmdSequences,
|
|
46
46
|
cmdProspects,
|
|
47
47
|
cmdMail,
|
|
48
|
+
cmdMailers,
|
|
48
49
|
cmdAdmin,
|
|
49
50
|
cmdFeedback,
|
|
50
51
|
cmdOfferings,
|
|
@@ -52,7 +53,7 @@ import {
|
|
|
52
53
|
} from "./commands.mjs";
|
|
53
54
|
|
|
54
55
|
// Keep in sync with cli/package.json.
|
|
55
|
-
export const VERSION = "0.
|
|
56
|
+
export const VERSION = "0.47.0";
|
|
56
57
|
|
|
57
58
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
58
59
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -62,6 +63,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
62
63
|
"confirm-missing-target",
|
|
63
64
|
"no-wait",
|
|
64
65
|
"no-checklist",
|
|
66
|
+
"refresh",
|
|
65
67
|
"accept",
|
|
66
68
|
"help",
|
|
67
69
|
"version",
|
|
@@ -557,7 +559,8 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
557
559
|
session (a trial per arm), linked to its
|
|
558
560
|
ledger entry. Written by \`npm run lab --
|
|
559
561
|
publish\` in the kernels repo.
|
|
560
|
-
admin crm
|
|
562
|
+
admin crm import <file.csv> [--source <name>] [--tag "…"] [--dry-run]
|
|
563
|
+
| signals [--status new] [--intent qualified] [--source rb2b] [--account <id>]
|
|
561
564
|
| record --title "…" [--source bookface] [--kind other] [--intent qualified]
|
|
562
565
|
[--name …] [--email …] [--company …] [--domain …] [--linkedin …] [--detail …]
|
|
563
566
|
| signal <id> qualify|dismiss|link [--account <id>]
|
|
@@ -569,7 +572,10 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
569
572
|
| onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N]
|
|
570
573
|
[--slack a@acme.com,b@acme.com] [--invite a@acme.com[:admin]]… [--no-checklist]
|
|
571
574
|
| onboarding <account-id> | retry-onboarding <onboarding-id>
|
|
572
|
-
|
|
575
|
+
| onboard-plan <account-id> [--refresh]
|
|
576
|
+
| payment-link <account-id> [--amount-cents N] [--description "…"]
|
|
577
|
+
The staff CRM's pipeline: bulk-importing a
|
|
578
|
+
CSV of leads, the inbound signal
|
|
573
579
|
feed, recording a signal with no webhook
|
|
574
580
|
(Bookface, a tweet like), qualifying or
|
|
575
581
|
dismissing one, and the explained score.
|
|
@@ -585,7 +591,9 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
585
591
|
only what failed.
|
|
586
592
|
Needs crm:read (reads) / crm:write (writes);
|
|
587
593
|
onboard + retry also need fleet:onboard and
|
|
588
|
-
org:create.
|
|
594
|
+
org:create. onboard-plan reads the calls
|
|
595
|
+
(sites, who to invite, paid or not);
|
|
596
|
+
payment-link needs billing:write.
|
|
589
597
|
admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
|
|
590
598
|
the pipeline as deployed, levers, measures,
|
|
591
599
|
goals, related history.
|
|
@@ -791,6 +799,22 @@ Prospects (who you write to):
|
|
|
791
799
|
[--first-name] [--last-name] [--title] [--company] [--website] [--linkedin] [--school]
|
|
792
800
|
[--city] [--country] [--years] [--notes] [--custom key=value]...
|
|
793
801
|
|
|
802
|
+
Mailers (the sending mailboxes; launching one buys a domain, so use: call launch_mailers):
|
|
803
|
+
mailers list [--domain <apex>] Your mailers + status + daily send limit
|
|
804
|
+
mailers domain <owned-domain-id> Provisioning stage, DNS state, mailers on a domain
|
|
805
|
+
mailers messages <mailer-id> [--folder all|inbox|sent] [--limit <n>]
|
|
806
|
+
Recent mail (envelopes)
|
|
807
|
+
mailers message <mailer-id> <uid> --folder inbox|sent
|
|
808
|
+
One message in full (plain-text body)
|
|
809
|
+
mailers forward <mailer-id> (--to <email> | --clear) Copy inbound mail to an address
|
|
810
|
+
mailers warmup start|pause <mailer-id> Start / pause warming (10 days to ready)
|
|
811
|
+
mailers limit <mailer-id> <per-day> Daily send limit, 0..500 (not while warming)
|
|
812
|
+
mailers retire <mailer-id> [--release-mailbox --yes] Disable a mailer; --release-mailbox also deletes
|
|
813
|
+
the hosted mailbox (stops billing, irreversible)
|
|
814
|
+
mailers suppressions [list] [--reason <r>] [--limit <n>]
|
|
815
|
+
mailers suppressions add <address> [--reason unsubscribe|bounce|complaint|manual] [--note <t>]
|
|
816
|
+
mailers suppressions remove <address> Only 'manual' entries can be removed
|
|
817
|
+
|
|
794
818
|
Mail:
|
|
795
819
|
mail connections Connected mailboxes, Google connect, Vercel key + mailing address status
|
|
796
820
|
mail postal-address "<address>" Set the org's CAN-SPAM mailing address (email footers)
|
|
@@ -881,6 +905,7 @@ const CLIENT_COMMANDS = {
|
|
|
881
905
|
sequences: cmdSequences,
|
|
882
906
|
prospects: cmdProspects,
|
|
883
907
|
mail: cmdMail,
|
|
908
|
+
mailers: cmdMailers,
|
|
884
909
|
feedback: cmdFeedback,
|
|
885
910
|
// Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
|
|
886
911
|
admin: cmdAdmin,
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
|
|
8
8
|
// admin tuning-context [--q "<question>"] (tuning:experiment)
|
|
9
9
|
// admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
|
|
10
|
-
// admin crm signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding … (crm:read / crm:write)
|
|
10
|
+
// admin crm import|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
|
|
11
11
|
// admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
|
|
12
12
|
// [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
|
|
13
13
|
// [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
|
|
@@ -425,11 +425,135 @@ function accountLine(a) {
|
|
|
425
425
|
return `${a.id} [${a.stage}] ${score} ${a.name}${a.domain ? ` (${a.domain})` : ""}${a.isYc ? " · YC" : ""}`;
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
/* ------------------------------------------------------ CRM lead import (CSV) */
|
|
429
|
+
|
|
430
|
+
/** RFC 4180-ish: quoted fields, doubled quotes, CRLF/LF, a BOM. Returns rows of cells. */
|
|
431
|
+
export function parseCsvText(text) {
|
|
432
|
+
const src = text.replace(/^/, "");
|
|
433
|
+
const rows = [];
|
|
434
|
+
let row = [];
|
|
435
|
+
let cell = "";
|
|
436
|
+
let quoted = false;
|
|
437
|
+
for (let i = 0; i < src.length; i++) {
|
|
438
|
+
const c = src[i];
|
|
439
|
+
if (quoted) {
|
|
440
|
+
if (c === '"') {
|
|
441
|
+
if (src[i + 1] === '"') {
|
|
442
|
+
cell += '"';
|
|
443
|
+
i++;
|
|
444
|
+
} else quoted = false;
|
|
445
|
+
} else cell += c;
|
|
446
|
+
} else if (c === '"') quoted = true;
|
|
447
|
+
else if (c === ",") {
|
|
448
|
+
row.push(cell);
|
|
449
|
+
cell = "";
|
|
450
|
+
} else if (c === "\n" || c === "\r") {
|
|
451
|
+
if (c === "\r" && src[i + 1] === "\n") i++;
|
|
452
|
+
row.push(cell);
|
|
453
|
+
rows.push(row);
|
|
454
|
+
row = [];
|
|
455
|
+
cell = "";
|
|
456
|
+
} else cell += c;
|
|
457
|
+
}
|
|
458
|
+
if (cell !== "" || row.length > 0) {
|
|
459
|
+
row.push(cell);
|
|
460
|
+
rows.push(row);
|
|
461
|
+
}
|
|
462
|
+
return rows.filter((r) => r.some((c) => c.trim() !== ""));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Spreadsheet header → CrmImportRow field (headers compared lower-cased, letters and digits only). */
|
|
466
|
+
const CRM_IMPORT_HEADERS = {
|
|
467
|
+
company: ["company", "companyname", "organization", "organisation", "account", "accountname"],
|
|
468
|
+
domain: ["domain", "companydomain"],
|
|
469
|
+
website: ["website", "url", "companywebsite", "companyurl", "site"],
|
|
470
|
+
name: ["name", "fullname", "contact", "contactname", "person"],
|
|
471
|
+
firstName: ["firstname", "first", "givenname"],
|
|
472
|
+
lastName: ["lastname", "last", "surname", "familyname"],
|
|
473
|
+
email: ["email", "emailaddress", "workemail", "businessemail"],
|
|
474
|
+
title: ["title", "jobtitle", "position", "role"],
|
|
475
|
+
linkedin: ["linkedin", "linkedinurl", "linkedinprofile"],
|
|
476
|
+
phone: ["phone", "phonenumber", "mobile"],
|
|
477
|
+
notes: ["notes", "note", "comments", "description"],
|
|
478
|
+
yc: ["yc", "isyc", "ycombinator"],
|
|
479
|
+
batch: ["batch", "ycbatch"],
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/** A CSV's text → the CrmImportRow[] the import route takes (unknown columns are ignored). */
|
|
483
|
+
export function csvToImportRows(text) {
|
|
484
|
+
const [header, ...body] = parseCsvText(text);
|
|
485
|
+
if (!header) return { rows: [], mapped: [] };
|
|
486
|
+
const key = (h) => h.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
487
|
+
const columns = header.map((h) => {
|
|
488
|
+
const k = key(h);
|
|
489
|
+
return Object.keys(CRM_IMPORT_HEADERS).find((f) => CRM_IMPORT_HEADERS[f].includes(k)) ?? null;
|
|
490
|
+
});
|
|
491
|
+
const rows = body.map((cells) => {
|
|
492
|
+
const row = {};
|
|
493
|
+
columns.forEach((field, i) => {
|
|
494
|
+
const v = (cells[i] ?? "").trim();
|
|
495
|
+
if (field && v && row[field] === undefined) row[field] = v;
|
|
496
|
+
});
|
|
497
|
+
return row;
|
|
498
|
+
});
|
|
499
|
+
return { rows, mapped: [...new Set(columns.filter(Boolean))] };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The route's cap (CRM_IMPORT_MAX_ROWS in src/types/crm.ts). */
|
|
503
|
+
const CRM_IMPORT_CHUNK = 2000;
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* `admin crm import <file.csv> [--source name] [--tag "…"] [--dry-run]` — the same importLeads
|
|
507
|
+
* the CRM's upload calls (POST /api/superadmin/crm/import), 2,000 rows per request.
|
|
508
|
+
*/
|
|
509
|
+
async function cmdAdminCrmImport(ctx, path) {
|
|
510
|
+
const { flags, io } = ctx;
|
|
511
|
+
if (!path) throw new CliError('Usage: admin crm import <file.csv> [--source name] [--tag "…"] [--dry-run]');
|
|
512
|
+
let text;
|
|
513
|
+
try {
|
|
514
|
+
text = readFileSync(path, "utf8");
|
|
515
|
+
} catch (err) {
|
|
516
|
+
throw new CliError(`Could not read ${path}: ${err.message}`);
|
|
517
|
+
}
|
|
518
|
+
const { rows, mapped } = csvToImportRows(text);
|
|
519
|
+
if (rows.length === 0) throw new CliError(`${path} has no data rows`);
|
|
520
|
+
if (!["company", "domain", "website", "email"].some((f) => mapped.includes(f))) {
|
|
521
|
+
throw new CliError("No company, domain, website or email column found in the header row");
|
|
522
|
+
}
|
|
523
|
+
const source = flagStr(flags.source) ?? path.split(/[\\/]/).pop();
|
|
524
|
+
const dryRun = flagBool(flags["dry-run"]);
|
|
525
|
+
const total = { created: 0, matched: 0, contactsAdded: 0, skipped: 0, rows: [] };
|
|
526
|
+
for (let offset = 0; offset < rows.length; offset += CRM_IMPORT_CHUNK) {
|
|
527
|
+
const r = await staffPost(
|
|
528
|
+
ctx,
|
|
529
|
+
`${CRM}/import`,
|
|
530
|
+
compact({ rows: rows.slice(offset, offset + CRM_IMPORT_CHUNK), source, dryRun, tag: flagStr(flags.tag) })
|
|
531
|
+
);
|
|
532
|
+
total.created += r.created;
|
|
533
|
+
total.matched += r.matched;
|
|
534
|
+
total.contactsAdded += r.contactsAdded;
|
|
535
|
+
total.skipped += r.skipped;
|
|
536
|
+
total.rows.push(...r.rows.map((x) => ({ ...x, index: x.index + offset })));
|
|
537
|
+
}
|
|
538
|
+
if (flagBool(flags.json)) return printResult(io, flags, total);
|
|
539
|
+
const errors = total.rows.filter((x) => x.status === "error").length;
|
|
540
|
+
io.log(
|
|
541
|
+
`${dryRun ? "Dry run — would import" : "Imported"} ${rows.length} rows from ${source} (columns: ${mapped.join(", ")}):\n` +
|
|
542
|
+
` ${total.created} accounts ${dryRun ? "to create" : "created"}, ${total.matched} matched, ` +
|
|
543
|
+
`${total.contactsAdded} contacts ${dryRun ? "to add" : "added"}, ${total.skipped} skipped${errors ? `, ${errors} errors` : ""}`
|
|
544
|
+
);
|
|
545
|
+
for (const x of total.rows.filter((y) => y.status === "skipped" || y.status === "error")) {
|
|
546
|
+
// +2: the header is line 1 and the index is 0-based.
|
|
547
|
+
io.log(` line ${x.index + 2}: ${x.status} — ${x.reason ?? ""}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
428
551
|
function onboardingLines(o) {
|
|
429
552
|
if (!o) return "(never onboarded from the CRM)";
|
|
430
553
|
const mark = { done: "✓", skipped: "–", failed: "✗", running: "…", pending: "·" };
|
|
431
554
|
return [
|
|
432
555
|
`${o.id} started ${o.startedAt.slice(0, 16).replace("T", " ")}${o.finishedAt ? " finished" : " in progress"}`,
|
|
556
|
+
...(o.billing ? [` ${billingLine(o.billing)}`] : []),
|
|
433
557
|
...o.steps.map(
|
|
434
558
|
(s) =>
|
|
435
559
|
` ${mark[s.status] ?? "?"} ${s.key.padEnd(9)} ${s.status}${s.progress ? ` (${s.progress.done}/${s.progress.total})` : ""}${s.detail ? ` ${s.detail}` : ""}${s.error ? `\n error: ${s.error}` : ""}`
|
|
@@ -437,6 +561,32 @@ function onboardingLines(o) {
|
|
|
437
561
|
].join("\n");
|
|
438
562
|
}
|
|
439
563
|
|
|
564
|
+
function dollars(cents) {
|
|
565
|
+
return `$${(cents / 100).toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function billingLine(b) {
|
|
569
|
+
const paid = b.paid
|
|
570
|
+
? `PAID${b.mrrCents ? ` (${dollars(b.mrrCents)}/mo)` : ""}${b.paidAt ? ` since ${b.paidAt.slice(0, 10)}` : ""}`
|
|
571
|
+
: "NOT PAID";
|
|
572
|
+
return `billing: ${paid}${b.paymentLinkUrl ? ` link: ${b.paymentLinkUrl}` : ""}`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function planLines(p) {
|
|
576
|
+
return [
|
|
577
|
+
`domain: ${p.domain ?? "(unknown)"}`,
|
|
578
|
+
`sites: ${p.sites.length}`,
|
|
579
|
+
...p.sites.map(
|
|
580
|
+
(s, i) => ` ${i + 1}. ${s.direction || "(infer from their homepage)"}${s.why ? ` — ${s.why}` : ""}`
|
|
581
|
+
),
|
|
582
|
+
`slack: ${p.slackEmails.join(", ") || "(nobody found)"}`,
|
|
583
|
+
`invites: ${p.invites.map((i) => `${i.email}:${i.role}`).join(", ") || "(none)"}`,
|
|
584
|
+
...(p.notes ? [`notes: ${p.notes}`] : []),
|
|
585
|
+
billingLine(p.billing),
|
|
586
|
+
`read: ${p.sources.map((s) => `${s.title ?? s.transcriptId} (${s.occurredAt.slice(0, 10)})`).join("; ") || "(no calls on file)"}`,
|
|
587
|
+
].join("\n");
|
|
588
|
+
}
|
|
589
|
+
|
|
440
590
|
/**
|
|
441
591
|
* `admin crm onboard` body from flags — the same CrmOnboardBodySchema the desk sends.
|
|
442
592
|
* --site "<direction>" per site (repeatable; "" = infer from the homepage) and/or
|
|
@@ -488,6 +638,8 @@ async function cmdAdminCrm(ctx) {
|
|
|
488
638
|
const json = flagBool(flags.json);
|
|
489
639
|
const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
|
|
490
640
|
switch (sub) {
|
|
641
|
+
case "import":
|
|
642
|
+
return cmdAdminCrmImport(ctx, id);
|
|
491
643
|
case "signals": {
|
|
492
644
|
const params = new URLSearchParams(
|
|
493
645
|
compact({
|
|
@@ -688,6 +840,32 @@ async function cmdAdminCrm(ctx) {
|
|
|
688
840
|
onboardingLines(x.onboarding)
|
|
689
841
|
);
|
|
690
842
|
}
|
|
843
|
+
case "onboard-plan": {
|
|
844
|
+
// admin crm onboard-plan <account-id> [--refresh] — what the calls say onboarding
|
|
845
|
+
// should set up (sites, Slack/app people, notes) + paid or not. --refresh re-reads.
|
|
846
|
+
if (!id) throw new CliError("Usage: admin crm onboard-plan <account-id> [--refresh]");
|
|
847
|
+
const q = flagBool(flags.refresh) ? "?refresh=1" : "";
|
|
848
|
+
return out(await staffRequest(ctx, "GET", `${CRM}/accounts/${id}/onboard/plan${q}`), (x) =>
|
|
849
|
+
planLines(x.plan)
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
case "payment-link": {
|
|
853
|
+
// admin crm payment-link <account-id> [--amount-cents N] [--description "…"] — a Stripe
|
|
854
|
+
// Payment Link for the monthly subscription (default: the deal amount). The key needs
|
|
855
|
+
// crm:write + billing:write.
|
|
856
|
+
if (!id)
|
|
857
|
+
throw new CliError('Usage: admin crm payment-link <account-id> [--amount-cents N] [--description "…"]');
|
|
858
|
+
const r = await staffPost(
|
|
859
|
+
ctx,
|
|
860
|
+
`${CRM}/accounts/${id}/payment-link`,
|
|
861
|
+
compact({ amountCents: flagNum(flags["amount-cents"]), description: flagStr(flags.description) })
|
|
862
|
+
);
|
|
863
|
+
return out(
|
|
864
|
+
r,
|
|
865
|
+
(x) =>
|
|
866
|
+
`${x.url} (${dollars(x.amountCents)}/month${x.orgId ? "" : " — no org yet: bind in Fleet → Billing"})`
|
|
867
|
+
);
|
|
868
|
+
}
|
|
691
869
|
case "retry-onboarding": {
|
|
692
870
|
// admin crm retry-onboarding <onboarding-id> — re-run the failed/pending steps.
|
|
693
871
|
if (!id) throw new CliError("Usage: admin crm retry-onboarding <onboarding-id>");
|
|
@@ -696,7 +874,7 @@ async function cmdAdminCrm(ctx) {
|
|
|
696
874
|
}
|
|
697
875
|
default:
|
|
698
876
|
throw new CliError(
|
|
699
|
-
"Usage: admin crm signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding … (see --help)"
|
|
877
|
+
"Usage: admin crm import|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
|
|
700
878
|
);
|
|
701
879
|
}
|
|
702
880
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `sequences`, `prospects` and `mail` — the outbound email stack, as thin wrappers
|
|
1
|
+
// `sequences`, `prospects`, `mailers` and `mail` — the outbound email stack, as thin wrappers
|
|
2
2
|
// over the MCP tools, which call the same lib functions the app's /api/sequences
|
|
3
3
|
// and /api/prospects routes do. Every call is built from the ARGS map below, so
|
|
4
4
|
// `scripts/parity-check.ts` can hold each command to its tool's input schema.
|
|
@@ -58,6 +58,22 @@ const mailerIds = {
|
|
|
58
58
|
},
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
// A required positional that must be a whole number (the server schema wants a number, not a string).
|
|
62
|
+
function intPositional(index, label) {
|
|
63
|
+
return {
|
|
64
|
+
value: (_flags, positionals) => {
|
|
65
|
+
const raw = positionals[index];
|
|
66
|
+
if (raw === undefined) throw new CliError(`Missing required <${label}>`);
|
|
67
|
+
const n = Number(raw);
|
|
68
|
+
if (!Number.isInteger(n)) throw new CliError(`<${label}> must be a whole number (got ${raw})`);
|
|
69
|
+
return n;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SUPPRESSION_REASONS = ["unsubscribe", "bounce", "complaint", "manual"];
|
|
75
|
+
const MAILER_FOLDERS = ["inbox", "sent"];
|
|
76
|
+
|
|
61
77
|
// How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
|
|
62
78
|
export const ARGS = {
|
|
63
79
|
list_sequences: {},
|
|
@@ -112,6 +128,9 @@ export const ARGS = {
|
|
|
112
128
|
get_prospect_list: {
|
|
113
129
|
list_id: { positional: 0, label: "list-id", required: true },
|
|
114
130
|
},
|
|
131
|
+
delete_prospect_list: {
|
|
132
|
+
list_id: { positional: 0, label: "list-id", required: true },
|
|
133
|
+
},
|
|
115
134
|
list_prospects: {
|
|
116
135
|
list_id: "list",
|
|
117
136
|
status: { flag: "status", enum: PROSPECT_STATUSES },
|
|
@@ -166,6 +185,59 @@ export const ARGS = {
|
|
|
166
185
|
notes: "notes",
|
|
167
186
|
custom: { value: readCustom },
|
|
168
187
|
},
|
|
188
|
+
list_mailers: {
|
|
189
|
+
domain: "domain",
|
|
190
|
+
},
|
|
191
|
+
get_mailer_domain_status: {
|
|
192
|
+
owned_domain_id: { positional: 0, label: "owned-domain-id", required: true },
|
|
193
|
+
},
|
|
194
|
+
list_mailer_messages: {
|
|
195
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
196
|
+
folder: { flag: "folder", enum: ["all", ...MAILER_FOLDERS] },
|
|
197
|
+
limit: { flag: "limit", type: "num" },
|
|
198
|
+
},
|
|
199
|
+
get_mailer_message: {
|
|
200
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
201
|
+
uid: intPositional(1, "uid"),
|
|
202
|
+
folder: { flag: "folder", required: true, enum: MAILER_FOLDERS },
|
|
203
|
+
},
|
|
204
|
+
set_mailer_forwarding: {
|
|
205
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
206
|
+
forward_to: {
|
|
207
|
+
value: (flags) => {
|
|
208
|
+
if (flags.clear === true) return null;
|
|
209
|
+
const to = flagStr(flags.to);
|
|
210
|
+
if (to === undefined) throw new CliError("Pass --to <email> (or --clear to stop forwarding)");
|
|
211
|
+
return to;
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
start_mailer_warmup: {
|
|
216
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
217
|
+
},
|
|
218
|
+
pause_mailer_warmup: {
|
|
219
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
220
|
+
},
|
|
221
|
+
set_mailer_daily_send_limit: {
|
|
222
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
223
|
+
daily_send_limit: intPositional(1, "per-day"),
|
|
224
|
+
},
|
|
225
|
+
retire_mailer: {
|
|
226
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
227
|
+
release_mailbox: { flag: "release-mailbox", type: "bool" },
|
|
228
|
+
},
|
|
229
|
+
add_email_suppression: {
|
|
230
|
+
address: { positional: 0, label: "address", required: true },
|
|
231
|
+
reason: { flag: "reason", enum: SUPPRESSION_REASONS },
|
|
232
|
+
note: "note",
|
|
233
|
+
},
|
|
234
|
+
list_email_suppressions: {
|
|
235
|
+
reason: { flag: "reason", enum: SUPPRESSION_REASONS },
|
|
236
|
+
limit: { flag: "limit", type: "num" },
|
|
237
|
+
},
|
|
238
|
+
remove_email_suppression: {
|
|
239
|
+
address: { positional: 0, label: "address", required: true },
|
|
240
|
+
},
|
|
169
241
|
get_mail_connections: {},
|
|
170
242
|
set_mail_postal_address: {
|
|
171
243
|
mail_postal_address: { positional: 0, label: "address", required: true },
|
|
@@ -180,6 +252,7 @@ const VALUE_FLAGS = {
|
|
|
180
252
|
update_review_item: ["body", "file"],
|
|
181
253
|
import_prospects_csv: ["csv", "csv-file"],
|
|
182
254
|
update_prospect: ["custom"],
|
|
255
|
+
set_mailer_forwarding: ["to", "clear"],
|
|
183
256
|
};
|
|
184
257
|
|
|
185
258
|
async function run(ctx, tool, message) {
|
|
@@ -280,7 +353,7 @@ async function sequencesLinks(ctx, sub) {
|
|
|
280
353
|
}
|
|
281
354
|
}
|
|
282
355
|
|
|
283
|
-
const PROSPECTS_USAGE = `Usage: prospects <lists|list|ls|get|create-list|import|source|enrich|update>`;
|
|
356
|
+
const PROSPECTS_USAGE = `Usage: prospects <lists|list|ls|get|create-list|delete-list|import|source|enrich|update>`;
|
|
284
357
|
|
|
285
358
|
export async function cmdProspects(ctx) {
|
|
286
359
|
const sub = ctx.positionals[0];
|
|
@@ -297,6 +370,8 @@ export async function cmdProspects(ctx) {
|
|
|
297
370
|
return run(rest, "get_prospect");
|
|
298
371
|
case "create-list":
|
|
299
372
|
return run(rest, "create_prospect_list", "List ready.");
|
|
373
|
+
case "delete-list":
|
|
374
|
+
return run(rest, "delete_prospect_list", "List deleted (its prospects stay).");
|
|
300
375
|
case "import":
|
|
301
376
|
return run(rest, "import_prospects_csv", "Imported.");
|
|
302
377
|
case "source":
|
|
@@ -314,6 +389,66 @@ export async function cmdProspects(ctx) {
|
|
|
314
389
|
}
|
|
315
390
|
}
|
|
316
391
|
|
|
392
|
+
const MAILERS_USAGE = `Usage: mailers <list|domain|messages|message|forward|warmup|limit|retire|suppressions>`;
|
|
393
|
+
|
|
394
|
+
export async function cmdMailers(ctx) {
|
|
395
|
+
const sub = ctx.positionals[0];
|
|
396
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
397
|
+
switch (sub) {
|
|
398
|
+
case "list":
|
|
399
|
+
case "ls":
|
|
400
|
+
return run(rest, "list_mailers");
|
|
401
|
+
case "domain":
|
|
402
|
+
return run(rest, "get_mailer_domain_status");
|
|
403
|
+
case "messages":
|
|
404
|
+
return run(rest, "list_mailer_messages");
|
|
405
|
+
case "message":
|
|
406
|
+
case "show":
|
|
407
|
+
return run(rest, "get_mailer_message");
|
|
408
|
+
case "forward":
|
|
409
|
+
return run(rest, "set_mailer_forwarding", "Forwarding updated.");
|
|
410
|
+
case "warmup": {
|
|
411
|
+
const action = rest.positionals[0];
|
|
412
|
+
const inner = { ...rest, positionals: rest.positionals.slice(1) };
|
|
413
|
+
if (action === "start") return run(inner, "start_mailer_warmup", "Warmup started.");
|
|
414
|
+
if (action === "pause") return run(inner, "pause_mailer_warmup", "Warmup paused.");
|
|
415
|
+
throw new CliError(`Unknown warmup action: ${action ?? "(none)"}. Try: start, pause`);
|
|
416
|
+
}
|
|
417
|
+
case "limit":
|
|
418
|
+
return run(rest, "set_mailer_daily_send_limit", "Daily send limit set.");
|
|
419
|
+
case "retire": {
|
|
420
|
+
if (rest.flags["release-mailbox"] && !rest.flags.yes) {
|
|
421
|
+
const id = flagStr(rest.positionals[0]) ?? "<mailer-id>";
|
|
422
|
+
ctx.io.error(
|
|
423
|
+
`--release-mailbox deletes the hosted mailbox at the provider and cannot be undone. Re-run with --yes to confirm: ${ctx.bin} mailers retire ${id} --release-mailbox --yes`
|
|
424
|
+
);
|
|
425
|
+
return 1;
|
|
426
|
+
}
|
|
427
|
+
return run(rest, "retire_mailer", "Mailer retired: nothing further sends from it.");
|
|
428
|
+
}
|
|
429
|
+
case "suppressions":
|
|
430
|
+
return mailersSuppressions(rest, rest.positionals[0]);
|
|
431
|
+
default:
|
|
432
|
+
throw new CliError(`Unknown mailers subcommand: ${sub ?? "(none)"}. ${MAILERS_USAGE}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function mailersSuppressions(ctx, sub) {
|
|
437
|
+
const inner = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
438
|
+
switch (sub) {
|
|
439
|
+
case "list":
|
|
440
|
+
case "ls":
|
|
441
|
+
case undefined:
|
|
442
|
+
return run(sub === undefined ? ctx : inner, "list_email_suppressions");
|
|
443
|
+
case "add":
|
|
444
|
+
return run(inner, "add_email_suppression", "Added to the do-not-send list.");
|
|
445
|
+
case "remove":
|
|
446
|
+
return run(inner, "remove_email_suppression", "Removed from the do-not-send list.");
|
|
447
|
+
default:
|
|
448
|
+
throw new CliError(`Unknown suppressions subcommand: ${sub}. Try: list, add, remove`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
317
452
|
export async function cmdMail(ctx) {
|
|
318
453
|
const sub = ctx.positionals[0];
|
|
319
454
|
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|