posthorn 0.2.3 → 0.2.5

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.
Files changed (3) hide show
  1. package/README.md +32 -19
  2. package/dist/index.js +97 -26
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,22 +1,22 @@
1
1
  # Posthorn
2
2
 
3
- **Posthorn spins up cold-email infrastructure sending domains, mailboxes,
4
- SPF/DKIM/DMARC, and inbox warmup on accounts you own, drivable entirely by an
5
- AI agent.**
3
+ **Posthorn spins up cold-email infrastructure: sending domains, mailboxes,
4
+ SPF/DKIM/DMARC, and inbox warmup. It runs on accounts you own and is drivable
5
+ entirely by an AI agent.**
6
6
 
7
7
  It's a pure orchestration layer: you own your Cloudflare account, your Google
8
8
  Workspace, your domains, and your mailboxes. Posthorn automates the setup and
9
- warmup on top of accounts you control it never owns or bills for any of it.
9
+ warmup on top of accounts you control. It never owns or bills for any of it.
10
10
 
11
11
  ## Quick start with an AI agent (recommended)
12
12
 
13
13
  Posthorn is built to be driven by an agent (Claude Code, Codex, Cursor, etc.).
14
14
  Paste this into your agent:
15
15
 
16
- > I want to set up cold-email infrastructure sending domains, mailboxes, and
16
+ > I want to set up cold-email infrastructure: sending domains, mailboxes, and
17
17
  > inbox warmup. Use the Posthorn CLI to do it. First run `npm install -g posthorn`
18
18
  > then `posthorn guide`, read the guide, and walk me through the whole setup
19
- > step by step run the commands for me and tell me whenever I need to do
19
+ > step by step. Run the commands for me and tell me whenever I need to do
20
20
  > something in my browser.
21
21
 
22
22
  The agent reads `posthorn guide` (which prints this document) and orchestrates
@@ -50,7 +50,7 @@ never owns or bills for any of it.
50
50
  4. Domain → buy new OR connect existing OR managed DNS
51
51
  5. DKIM → browser step (see below)
52
52
  6. Mailboxes → `posthorn mailboxes create ...`
53
- 7. Verify account → `posthorn auth verify` (unlocks sending/warmup "verified" tier)
53
+ 7. Verify account → `posthorn auth verify` (unlocks sending/warmup, "verified" tier)
54
54
  8. Warmup → `posthorn warmup start <mailbox-id>`
55
55
 
56
56
  Dependencies: you CANNOT create a mailbox until the domain status is `ready`.
@@ -70,7 +70,7 @@ click-by-click instructions.
70
70
  Zone>Zone>Edit, Zone>DNS>Edit. Resources: Include All.
71
71
  - Then: `posthorn accounts cloudflare <token>`
72
72
  - For domain PURCHASES, the user also needs a payment method on their Cloudflare
73
- account (they pay Cloudflare directly Posthorn never bills for domains).
73
+ account (they pay Cloudflare directly; Posthorn never bills for domains).
74
74
 
75
75
  ### Google Workspace domain-wide delegation (one-time per Workspace org)
76
76
  - admin.google.com → Security → Access and data control → API controls →
@@ -79,7 +79,7 @@ click-by-click instructions.
79
79
  - OAuth scopes (paste all, comma-separated):
80
80
  `https://mail.google.com/,https://www.googleapis.com/auth/admin.directory.user,https://www.googleapis.com/auth/admin.directory.domain,https://www.googleapis.com/auth/siteverification`
81
81
  - Then ask for their admin email and run: `posthorn accounts workspace <admin-email>`
82
- - This replaces OAuth entirely no consent screen, no app verification, no test users.
82
+ - This replaces OAuth entirely: no consent screen, no app verification, no test users.
83
83
 
84
84
  ### Nameservers (only for "managed DNS" or moving a domain to Cloudflare)
85
85
  - The CLI prints 2 nameservers. The user sets them at their domain REGISTRAR
@@ -87,10 +87,10 @@ click-by-click instructions.
87
87
  - Propagation takes minutes to 24h. Check with `posthorn domains activate <id>`
88
88
  (managed) or by polling `posthorn domains get <id>`.
89
89
 
90
- ### DKIM (per domain Google has no DKIM API)
90
+ ### DKIM (per domain, Google has no DKIM API)
91
91
  If you have browser-automation tools, do it for the user:
92
92
  - Navigate to https://admin.google.com/ac/apps/gmail/authenticateemail
93
- (the URL may need /u/N/ for the right account slot verify the "Selected
93
+ (the URL may need /u/N/ for the right account slot, verify the "Selected
94
94
  domain" on the page matches).
95
95
  - If status is "Authenticating email with DKIM" → already done, skip.
96
96
  - Otherwise click GENERATE NEW RECORD → GENERATE (defaults: 2048-bit, "google"
@@ -106,11 +106,11 @@ PRINCIPLE: domain purchase ALWAYS happens on the user's own Cloudflare account
106
106
  and card. Posthorn never owns or bills for domains.
107
107
 
108
108
  1. NEW domain → requires the user's own Cloudflare account (+ payment method).
109
- - `posthorn domains check name1.com name2.io --cloudflare <id>` fast availability + price
110
- - `posthorn domains reputation <domain>` VET it before buying (Spamhaus
109
+ - `posthorn domains check name1.com name2.io --cloudflare <id>`, fast availability + price
110
+ - `posthorn domains reputation <domain>`, VET it before buying (Spamhaus
111
111
  blocklist/score + archive.org prior-use). Verdicts: clean / caution (used
112
112
  before) / avoid (blocklisted or poor reputation). Run this on the candidate
113
- before `buy` you can't tell a tainted domain from its name. (Slower + uses
113
+ before `buy`; you can't tell a tainted domain from its name. (Slower + uses
114
114
  a quota, so it's a separate explicit call, not part of `check`.)
115
115
  - `posthorn domains buy <domain> --cloudflare <id> --contact '{...}'`
116
116
 
@@ -124,7 +124,7 @@ and card. Posthorn never owns or bills for domains.
124
124
  - user sets them at their registrar
125
125
  - `posthorn domains activate <domain-id>` → checks propagation, starts DNS setup
126
126
  - Best for DEDICATED email-only domains. Don't use on a domain running a live
127
- website Posthorn becomes authoritative for ALL its DNS.
127
+ website; Posthorn becomes authoritative for ALL its DNS.
128
128
 
129
129
  After any path, poll `posthorn domains get <id>` until status is `ready`. DNS
130
130
  records (MX, SPF, DMARC) are configured automatically. Statuses progress:
@@ -134,7 +134,7 @@ workspace_verifying → workspace_verified → ready.
134
134
  ## Mailboxes & sending
135
135
 
136
136
  - Create: `posthorn mailboxes create <domain-id> --email john@dom.com --first John --last Smith`
137
- Returns a one-time password (Google Workspace login). Save it not shown again.
137
+ Returns a one-time password (Google Workspace login). Save it (not shown again).
138
138
  - Credentials for IMAP/SMTP are auto-provisioned via the service account
139
139
  (domain-wide delegation). Google mailboxes need no per-mailbox setup.
140
140
  - Send: `posthorn send <mailbox-id> --to x@y.com --subject "Hi" --body "..."`
@@ -148,18 +148,31 @@ Anonymous tier can do setup (accounts, domains). To send/warmup, verify:
148
148
  ## Warmup
149
149
 
150
150
  - `posthorn warmup start <mailbox-id>` → joins the shared warmup pool, ramps over ~30 days
151
+ - Volume and reply behavior are platform-managed (ramps from 2/day to 5/day;
152
+ peers reply to ~30% of warmup mail). Not configurable: the pool is shared,
153
+ so settings are tuned for everyone's deliverability.
151
154
  - `posthorn warmup stats <campaign-id>` → placement rate, reputation, daily breakdown
152
155
  - `posthorn warmup list` → all campaigns
153
- - Warmup never fully stops on its own pause with `posthorn warmup pause <id>`.
156
+ - Warmup never fully stops on its own, pause with `posthorn warmup pause <id>`.
157
+
158
+ ## Feedback (bugs, feature requests)
159
+
160
+ `posthorn feedback --type bug|feature|question|other "<message>"`
161
+
162
+ If you hit a bug, missing capability, or rough edge while driving this CLI,
163
+ report it, it goes straight to the Posthorn team. Agents: you are encouraged
164
+ to file feedback yourself when something doesn't work the way you expected
165
+ (include the command you ran and what you expected). Works on any tier, no
166
+ verification needed.
154
167
 
155
168
  ## Tips for agents
156
169
 
157
170
  - Read commands auto-detect output: when stdout isn't a TTY (i.e. you're an agent
158
- capturing output), they emit JSON automatically no flag needed. Humans in a
171
+ capturing output), they emit JSON automatically, no flag needed. Humans in a
159
172
  terminal get formatted text. Force either way with `--json` / `--pretty`.
160
173
  - Async steps (domain provisioning, nameserver propagation) need polling, not blocking.
161
174
  - Every command stores state locally (~/.config), so the user doesn't re-enter keys.
162
175
  - Translate everything into plain English for the user. Never show them raw JSON
163
- or ask them to run curl you run the CLI on their behalf.
176
+ or ask them to run curl, you run the CLI on their behalf.
164
177
  - When a step needs the user's browser (Cloudflare token, delegation, nameservers,
165
178
  DKIM), give exact click-by-click instructions and wait for confirmation.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import chalk6 from "chalk";
5
+ import chalk7 from "chalk";
6
6
 
7
7
  // src/commands/auth.ts
8
8
  import chalk from "chalk";
@@ -78,7 +78,7 @@ async function register(options) {
78
78
  console.log(` API Key: ${chalk.cyan(data.api_key)}`);
79
79
  console.log(` Tier: ${chalk.yellow(data.user.tier)}`);
80
80
  console.log();
81
- console.log(chalk.dim(" Save this API key \u2014 it won't be shown again."));
81
+ console.log(chalk.dim(" Save this API key, it won't be shown again."));
82
82
  console.log(chalk.dim(` Config stored at: ${getConfigPath()}`));
83
83
  } catch (err) {
84
84
  spinner.fail(`Could not reach ${apiUrl}`);
@@ -158,6 +158,21 @@ async function api(path, options = {}) {
158
158
  }
159
159
 
160
160
  // src/commands/accounts.ts
161
+ var SERVICE_ACCOUNT_CLIENT_ID = "110137377718772968374";
162
+ var DELEGATION_SCOPES = "https://mail.google.com/,https://www.googleapis.com/auth/admin.directory.user,https://www.googleapis.com/auth/admin.directory.domain,https://www.googleapis.com/auth/siteverification";
163
+ function printDelegationHelp(adminEmail) {
164
+ const domain = adminEmail.includes("@") ? adminEmail.split("@")[1] : "your-domain.com";
165
+ console.log();
166
+ console.log(chalk2.bold(" Set up domain-wide delegation first (one-time per Workspace org):"));
167
+ console.log(` 1. Go to ${chalk2.cyan("admin.google.com")} signed in as a ${chalk2.bold("super-admin")} of ${chalk2.bold(domain)}`);
168
+ console.log(" 2. Security \u2192 Access and data control \u2192 API controls \u2192 Domain-wide delegation \u2192 Add new");
169
+ console.log(` 3. Client ID: ${chalk2.bold(SERVICE_ACCOUNT_CLIENT_ID)}`);
170
+ console.log(" 4. OAuth scopes (paste all, comma-separated):");
171
+ console.log(` ${chalk2.green(DELEGATION_SCOPES)}`);
172
+ console.log(" 5. Authorize, then re-run this command.");
173
+ console.log(chalk2.dim(` The admin email you pass must be a super-admin of ${domain}'s own org.`));
174
+ console.log();
175
+ }
161
176
  async function connectCloudflare(token, options) {
162
177
  const spinner = ora2("Connecting Cloudflare...").start();
163
178
  const data = await api("/api/accounts", {
@@ -183,10 +198,21 @@ async function connectCloudflare(token, options) {
183
198
  }
184
199
  async function connectWorkspace(adminEmail) {
185
200
  const spinner = ora2("Connecting Google Workspace...").start();
186
- const data = await api("/api/accounts/workspace", {
187
- method: "POST",
188
- body: { adminEmail }
189
- });
201
+ let data;
202
+ try {
203
+ data = await api("/api/accounts/workspace", {
204
+ method: "POST",
205
+ body: { adminEmail }
206
+ });
207
+ } catch (err) {
208
+ spinner.fail("Could not connect Google Workspace.");
209
+ if (err instanceof ApiError) {
210
+ console.log(chalk2.red(` ${err.message}`));
211
+ }
212
+ printDelegationHelp(adminEmail);
213
+ process.exitCode = 1;
214
+ return;
215
+ }
190
216
  setConfig({
191
217
  workspaceConnected: true,
192
218
  workspaceAccountId: data.account.id
@@ -312,7 +338,7 @@ async function getDomain(domainId, options = {}) {
312
338
  function verdictText(rep) {
313
339
  switch (rep.verdict) {
314
340
  case "avoid":
315
- return chalk3.red(`\u26A0 ${rep.verdictLabel} \u2014 avoid`);
341
+ return chalk3.red(`\u26A0 ${rep.verdictLabel}, avoid`);
316
342
  case "caution":
317
343
  return chalk3.yellow(rep.verdictLabel);
318
344
  case "clean":
@@ -393,7 +419,7 @@ async function createMailbox(domainId, options) {
393
419
  console.log(` ${chalk4.bold("Email:")} ${data.credentials.email}`);
394
420
  console.log(` ${chalk4.bold("Password:")} ${chalk4.yellow(data.credentials.password)}`);
395
421
  console.log();
396
- console.log(chalk4.red(" Save this password now \u2014 it will not be shown again."));
422
+ console.log(chalk4.red(" Save this password now, it will not be shown again."));
397
423
  console.log(chalk4.dim(" Login at: https://mail.google.com"));
398
424
  }
399
425
  async function sendEmail(mailboxId, options) {
@@ -421,7 +447,7 @@ async function provisionCredentials(mailboxId, options) {
421
447
  body: { authType: options.type ?? "xoauth2" }
422
448
  });
423
449
  if (data.connectivity.send && data.connectivity.imap) {
424
- spinner.succeed(`Credentials provisioned \u2014 sending (${data.connectivity.sendMethod}) and IMAP connected!`);
450
+ spinner.succeed(`Credentials provisioned, sending (${data.connectivity.sendMethod}) and IMAP connected!`);
425
451
  } else {
426
452
  spinner.warn("Credentials provisioned but connectivity issues:");
427
453
  for (const err of data.connectivity.errors) {
@@ -435,14 +461,19 @@ import chalk5 from "chalk";
435
461
  import ora5 from "ora";
436
462
  async function startWarmup(mailboxId) {
437
463
  const spinner = ora5("Starting warmup...").start();
438
- const data = await api("/api/warmup/campaigns", {
439
- method: "POST",
440
- body: { mailboxId, autoStart: true }
441
- });
464
+ const data = await api(
465
+ "/api/warmup/campaigns",
466
+ {
467
+ method: "POST",
468
+ body: { mailboxId, autoStart: true }
469
+ }
470
+ );
471
+ const effective = data.campaign.config ?? {};
442
472
  spinner.succeed("Warmup started!");
443
473
  console.log(` Campaign ID: ${chalk5.dim(data.campaign.id)}`);
444
474
  console.log(` Status: ${chalk5.green("active")}`);
445
- console.log(chalk5.dim(" Emails will ramp up gradually over 30 days."));
475
+ console.log(` Daily cap: ${effective.daily_limit ?? 5}/day (ramps up from 2)`);
476
+ console.log(` Reply rate: ${Math.round((effective.reply_rate ?? 0.3) * 100)}%`);
446
477
  }
447
478
  async function pauseWarmup(campaignId) {
448
479
  await api(`/api/warmup/campaigns/${campaignId}`, {
@@ -493,25 +524,64 @@ async function listCampaigns() {
493
524
  }
494
525
  }
495
526
 
496
- // src/commands/guide.ts
527
+ // src/commands/feedback.ts
497
528
  import { readFileSync } from "fs";
498
529
  import { fileURLToPath } from "url";
499
530
  import { dirname, join } from "path";
500
- function guide() {
531
+ import chalk6 from "chalk";
532
+ function cliVersion() {
501
533
  try {
502
534
  const here = dirname(fileURLToPath(import.meta.url));
503
- const readme = readFileSync(join(here, "..", "README.md"), "utf8");
535
+ return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version;
536
+ } catch {
537
+ return "unknown";
538
+ }
539
+ }
540
+ async function sendFeedback(messageWords, options) {
541
+ const message = messageWords.join(" ").trim();
542
+ if (!message) {
543
+ console.error(chalk6.red("Feedback message is required."));
544
+ console.error(chalk6.dim(' Example: posthorn feedback --type feature "bulk domain checks would save me round trips"'));
545
+ process.exitCode = 1;
546
+ return;
547
+ }
548
+ const data = await api("/api/feedback", {
549
+ method: "POST",
550
+ body: {
551
+ message,
552
+ type: options.type ?? "other",
553
+ metadata: {
554
+ cliVersion: cliVersion(),
555
+ platform: process.platform,
556
+ agent: !process.stdout.isTTY
557
+ // non-TTY usually means an agent is driving
558
+ }
559
+ }
560
+ });
561
+ console.log(chalk6.green("Feedback recorded, thank you!"));
562
+ console.log(` id: ${chalk6.dim(data.feedback.id)}`);
563
+ console.log(` type: ${chalk6.dim(data.feedback.type)}`);
564
+ }
565
+
566
+ // src/commands/guide.ts
567
+ import { readFileSync as readFileSync2 } from "fs";
568
+ import { fileURLToPath as fileURLToPath2 } from "url";
569
+ import { dirname as dirname2, join as join2 } from "path";
570
+ function guide() {
571
+ try {
572
+ const here = dirname2(fileURLToPath2(import.meta.url));
573
+ const readme = readFileSync2(join2(here, "..", "README.md"), "utf8");
504
574
  console.log(readme);
505
575
  } catch {
506
576
  console.log(
507
- "Posthorn \u2014 see the full guide at https://www.npmjs.com/package/posthorn\nQuick start: posthorn auth register, then posthorn accounts cloudflare <token>, posthorn accounts workspace <admin-email>, posthorn domains connect <domain> --cloudflare <id>."
577
+ "Posthorn, see the full guide at https://www.npmjs.com/package/posthorn\nQuick start: posthorn auth register, then posthorn accounts cloudflare <token>, posthorn accounts workspace <admin-email>, posthorn domains connect <domain> --cloudflare <id>."
508
578
  );
509
579
  }
510
580
  }
511
581
 
512
582
  // src/index.ts
513
583
  var program = new Command();
514
- program.name("posthorn").description("Posthorn \u2014 domain setup, mailbox creation, and email warmup").version("0.1.0");
584
+ program.name("posthorn").description("Posthorn: domain setup, mailbox creation, and email warmup").version("0.2.5");
515
585
  program.addHelpText("after", `
516
586
  Agents: run 'posthorn guide' first for the full workflow playbook.
517
587
 
@@ -526,10 +596,10 @@ Typical flow:
526
596
  posthorn auth verify unlock sending + warmup
527
597
  posthorn warmup start <mailbox-id>
528
598
 
529
- Output: read commands auto-detect \u2014 agents (non-TTY) get JSON, humans get
599
+ Output: read commands auto-detect. Agents (non-TTY) get JSON, humans get
530
600
  formatted text. Force either with --json or --pretty.
531
601
  `);
532
- program.command("guide").description("Print the full agent playbook \u2014 workflow, external steps, and how to drive the CLI. Run this first.").action(guide);
602
+ program.command("guide").description("Print the full agent playbook: workflow, external steps, and how to drive the CLI. Run this first.").action(guide);
533
603
  var auth = program.command("auth").description("Account management");
534
604
  auth.command("register").description("Create a new account and get an API key").option("--url <url>", "API server URL", "https://api-production-08f2.up.railway.app").action(register);
535
605
  auth.command("status").description("Show current account info and setup progress").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(status);
@@ -542,7 +612,7 @@ var domains = program.command("domains").description("Domain management");
542
612
  domains.command("list").description("List all domains").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(listDomains);
543
613
  domains.command("get <domain-id>").description("Get domain details (poll this until status is 'ready')").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(getDomain);
544
614
  domains.command("check <domains...>").description("Check domain availability + pricing (fast)").requiredOption("--cloudflare <account-id>", "Cloudflare account ID").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(checkDomains);
545
- domains.command("reputation <domains...>").description("Vet a domain before buying \u2014 blocklist + reputation + prior-use history").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(domainReputation);
615
+ domains.command("reputation <domains...>").description("Vet a domain before buying, blocklist + reputation + prior-use history").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(domainReputation);
546
616
  domains.command("buy <domain>").description("Purchase a domain").requiredOption("--cloudflare <account-id>", "Cloudflare account ID").option("--contact <json>", "Registrant contact info as JSON").action(buyDomain);
547
617
  domains.command("connect <domain>").description("Connect an existing domain on your own Cloudflare account").requiredOption("--cloudflare <account-id>", "Cloudflare account ID").option("--workspace <account-id>", "Workspace account ID").action(connectDomain);
548
618
  domains.command("managed <domain>").description("Add an existing domain to Posthorn-managed DNS (returns nameservers to set at your registrar)").option("--workspace <account-id>", "Workspace account ID").action(managedDomain);
@@ -557,17 +627,18 @@ warmup.command("list").description("List warmup campaigns").action(listCampaigns
557
627
  warmup.command("start <mailbox-id>").description("Start warming a mailbox").action(startWarmup);
558
628
  warmup.command("pause <campaign-id>").description("Pause a warmup campaign").action(pauseWarmup);
559
629
  warmup.command("stats <campaign-id>").description("Show warmup stats and daily breakdown").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(warmupStats);
630
+ program.command("feedback <message...>").description("Send feedback to the Posthorn team: bug reports, feature requests, anything").option("-t, --type <type>", "bug | feature | question | other", "other").action(sendFeedback);
560
631
  program.hook("preAction", () => {
561
632
  });
562
633
  program.parseAsync(process.argv).catch((err) => {
563
634
  if (err.statusCode === 401) {
564
- console.log(chalk6.red("\n Authentication failed. Run: posthorn auth register\n"));
635
+ console.log(chalk7.red("\n Authentication failed. Run: posthorn auth register\n"));
565
636
  } else if (err.statusCode === 403) {
566
- console.log(chalk6.red("\n Account not verified. Run: posthorn auth verify\n"));
637
+ console.log(chalk7.red("\n Account not verified. Run: posthorn auth verify\n"));
567
638
  } else if (err.statusCode === 429) {
568
- console.log(chalk6.yellow("\n Rate limit exceeded. Try again in a minute.\n"));
639
+ console.log(chalk7.yellow("\n Rate limit exceeded. Try again in a minute.\n"));
569
640
  } else {
570
- console.error(chalk6.red(`
641
+ console.error(chalk7.red(`
571
642
  Error: ${err.message}
572
643
  `));
573
644
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "posthorn",
3
- "version": "0.2.3",
4
- "description": "Posthorn — domain setup, mailbox creation, and email warmup from the command line",
3
+ "version": "0.2.5",
4
+ "description": "Domain setup, mailbox creation, and email warmup from the command line",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "posthorn": "dist/index.js"