@letterstory/cli 0.46.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 CHANGED
@@ -670,6 +670,50 @@ letterstory insights bank [--source phantoms|marketing|all] [--phantom <uuid>]..
670
670
  letterstory insights access [--period 14d|30d|90d] [--collection <uuid>]
671
671
  ```
672
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
+
673
717
  ## Anything else
674
718
 
675
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.46.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.
@@ -798,6 +799,22 @@ Prospects (who you write to):
798
799
  [--first-name] [--last-name] [--title] [--company] [--website] [--linkedin] [--school]
799
800
  [--city] [--country] [--years] [--notes] [--custom key=value]...
800
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
+
801
818
  Mail:
802
819
  mail connections Connected mailboxes, Google connect, Vercel key + mailing address status
803
820
  mail postal-address "<address>" Set the org's CAN-SPAM mailing address (email footers)
@@ -888,6 +905,7 @@ const CLIENT_COMMANDS = {
888
905
  sequences: cmdSequences,
889
906
  prospects: cmdProspects,
890
907
  mail: cmdMail,
908
+ mailers: cmdMailers,
891
909
  feedback: cmdFeedback,
892
910
  // Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
893
911
  admin: cmdAdmin,
@@ -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) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",