carlyemail 0.2.0 → 0.4.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
@@ -1,13 +1,57 @@
1
1
  # carlyemail
2
2
 
3
- Real email inboxes your agent can send, receive and reply from.
3
+ Real email inboxes your agent can send, receive and reply from. One package,
4
+ two things: a typed client and a command line.
5
+
6
+ ## The client
4
7
 
5
8
  ```bash
6
- npx carlyemail signup --human-email you@example.com --username my-agent
9
+ npm install carlyemail
7
10
  ```
8
11
 
9
- That returns a working address and an API key. Confirm the emailed code and it
10
- can send:
12
+ ```javascript
13
+ import { CarlyEmail } from "carlyemail";
14
+
15
+ const carly = new CarlyEmail(); // reads CARLYEMAIL_API_KEY
16
+
17
+ const inbox = await carly.inboxes.create({ username: "hello" });
18
+
19
+ await carly.messages.send(inbox.email, {
20
+ to: ["you@example.com"],
21
+ subject: "Hello",
22
+ text: "From an agent.",
23
+ });
24
+ ```
25
+
26
+ Generated from the [OpenAPI spec](https://docs.carlyemail.com/openapi.json), so
27
+ it cannot describe an endpoint the API does not serve. One `fetch` call per
28
+ method and no dependencies, so it runs in Node, the browser, a worker and on the
29
+ edge with no build step. Types ship with it.
30
+
31
+ Errors carry the message, the fix and a documentation link:
32
+
33
+ ```javascript
34
+ import { CarlyEmailError } from "carlyemail";
35
+
36
+ try {
37
+ await carly.inboxes.create({ username: "hello" });
38
+ } catch (error) {
39
+ if (error instanceof CarlyEmailError) {
40
+ console.log(error.status, error.code, error.fix, error.docs);
41
+ }
42
+ }
43
+ ```
44
+
45
+ There is a Python client too: `pip install carlyemail`.
46
+
47
+ ## The command line
48
+
49
+ ```bash
50
+ npx carlyemail signup
51
+ ```
52
+
53
+ It asks for your email, offers an inbox name, and writes the key to
54
+ `~/.carlyemail/config.json`. Confirm the emailed code and it can send:
11
55
 
12
56
  ```bash
13
57
  npx carlyemail verify 123456
@@ -18,7 +62,14 @@ npx carlyemail send \
18
62
  --text "Sent by an agent with its own address."
19
63
  ```
20
64
 
21
- Install it properly if you use it often:
65
+ Load the saved key and its first inbox into the current shell before starting
66
+ LangChain, the Claude Agent SDK, Eve, Mastra, or another framework:
67
+
68
+ ```bash
69
+ eval "$(npx carlyemail env)"
70
+ ```
71
+
72
+ Install it if you use it often:
22
73
 
23
74
  ```bash
24
75
  npm install -g carlyemail
@@ -31,6 +82,7 @@ npm install -g carlyemail
31
82
  | `signup --human-email <e> --username <u>` | Create an account and an inbox |
32
83
  | `verify <code>` | Confirm the owner email |
33
84
  | `whoami` | Identity and scope of the current key |
85
+ | `env` | Print `CARLYEMAIL_API_KEY` and `CARLYEMAIL_INBOX` shell exports |
34
86
  | `inboxes` | List inboxes |
35
87
  | `create --username <u>` | Create an inbox |
36
88
  | `delete <inbox> --yes` | Delete an inbox and its mail |
@@ -82,10 +134,10 @@ rather than a login.
82
134
 
83
135
  ## Notes
84
136
 
85
- **Sign-up is idempotent by owner email, and rotates the key.** Running it again
86
- for an address that already has an account issues a new key and **revokes the
87
- previous one**. It is how you recover a lost key; it is not a way to add a
88
- second inbox. Use `create` for that.
137
+ **An owner email is not a login.** Running sign-up again for an existing address
138
+ returns `account_exists` and leaves every key untouched. Recover access with an
139
+ emailed code at https://console.carlyemail.com; use `create` from an
140
+ authenticated CLI session to add another inbox.
89
141
 
90
142
  **`delete` requires `--yes`.** It removes the inbox and every message in it,
91
143
  and there is no undo.
@@ -108,6 +160,22 @@ Node 18 or newer. No dependencies — this is one file that uses Node's built-in
108
160
  `fetch`, so `npx` never resolves a tree and never fails for a reason unrelated
109
161
  to the thing you asked for.
110
162
 
163
+ ## Inbound email in a Worker
164
+
165
+ The dependency-free webhook helper uses Web Crypto, so the same verified
166
+ `onEmail` callback runs in Cloudflare Workers, Node, and other edge runtimes:
167
+
168
+ ```js
169
+ import { createEmailHandler } from "carlyemail/webhooks";
170
+
171
+ const handleEmail = createEmailHandler({
172
+ secret: env.CARLYEMAIL_WEBHOOK_SECRET,
173
+ async onEmail(event) {
174
+ console.log(event.message);
175
+ },
176
+ });
177
+ ```
178
+
111
179
  ## Licence
112
180
 
113
181
  MIT — see `LICENSE`. That covers **this CLI only**. The CarlyEmail service it
package/carlyemail.js CHANGED
@@ -16,8 +16,9 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, realpath
16
16
  import { homedir } from "node:os";
17
17
  import { join } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
+ import { randomBytes } from "node:crypto";
19
20
 
20
- export const VERSION = "0.2.0";
21
+ export const VERSION = "0.4.0";
21
22
 
22
23
  const DEFAULT_API = "https://api.carlyemail.com";
23
24
  const CONFIG_DIR = join(homedir(), ".carlyemail");
@@ -39,6 +40,11 @@ export function maskKey(key) {
39
40
  return `${key.slice(0, 9)}${"·".repeat(8)}${key.slice(-4)}`;
40
41
  }
41
42
 
43
+ /** Quote one value for POSIX shells. `env` is designed to be used with eval. */
44
+ export function shellQuote(value) {
45
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
46
+ }
47
+
42
48
  // ---------------------------------------------------------------- config
43
49
 
44
50
  /**
@@ -108,7 +114,12 @@ class UsageError extends Error {}
108
114
 
109
115
  // ---------------------------------------------------------------- transport
110
116
 
111
- export async function request(config, method, path, { body, auth = true, fetchImpl = fetch } = {}) {
117
+ export async function request(
118
+ config,
119
+ method,
120
+ path,
121
+ { body, auth = true, raw = false, fetchImpl = fetch } = {}
122
+ ) {
112
123
  const headers = { accept: "application/json" };
113
124
  if (body !== undefined) headers["content-type"] = "application/json";
114
125
  if (auth) {
@@ -145,7 +156,9 @@ export async function request(config, method, path, { body, auth = true, fetchIm
145
156
  }
146
157
  }
147
158
  if (!response.ok) throw new ApiError(response.status, parsed);
148
- return parsed;
159
+ // The zone file is text/plain, not our envelope. Parsing it returns null,
160
+ // which is how it silently printed nothing.
161
+ return raw ? text : parsed;
149
162
  }
150
163
 
151
164
  // ---------------------------------------------------------------- arguments
@@ -211,13 +224,34 @@ function emit(ctx, payload, render) {
211
224
  render();
212
225
  }
213
226
 
227
+ /** A name to offer when somebody has no preference. Short enough to type again. */
228
+ function suggestUsername() {
229
+ return `agent-${randomBytes(2).toString("hex")}`;
230
+ }
231
+
214
232
  define(
215
233
  "signup",
216
234
  "Create an account and an inbox",
217
- "carlyemail signup --human-email you@example.com --username my-agent",
235
+ "carlyemail signup (or: --human-email you@example.com --username my-agent)",
218
236
  async (ctx) => {
219
- const human_email = required(ctx.flags, "human-email");
220
- const username = required(ctx.flags, "username");
237
+ // `signup` is the first command anybody runs, and refusing it with a usage
238
+ // error is a bad way to say hello. At a terminal, ask.
239
+ let human_email = ctx.flags["human-email"];
240
+ if (typeof human_email !== "string") {
241
+ const answer = await ctx.ask("Your email (for the confirmation code): ");
242
+ if (answer) human_email = answer;
243
+ }
244
+ if (!human_email) throw new UsageError("--human-email is required");
245
+
246
+ let username = ctx.flags.username;
247
+ if (typeof username !== "string") {
248
+ const suggested = suggestUsername();
249
+ const answer = await ctx.ask(`Inbox name [${suggested}]: `);
250
+ // null is "no terminal", which still has to fail. "" is a bare Enter,
251
+ // which means take the suggestion.
252
+ if (answer !== null) username = answer || suggested;
253
+ }
254
+ if (!username) throw new UsageError("--username is required");
221
255
 
222
256
  const out = await request(ctx.config, "POST", "/v0/agent/sign-up", {
223
257
  auth: false,
@@ -336,7 +370,11 @@ define("messages", "List messages in an inbox", "carlyemail messages me@carlyema
336
370
  for (const m of out.messages) {
337
371
  const when = (m.timestamp || "").slice(0, 16).replace("T", " ");
338
372
  ctx.print(`${dim(when)} ${bold(m.subject || "(no subject)")}`);
339
- ctx.print(`${" ".repeat(18)}${dim(`from ${m.from ?? m.from_address ?? "?"}`)}`);
373
+ // The id goes on screen because every other command takes one. Listing
374
+ // messages and then having to go to the API to find out what to call
375
+ // `read` or `reply` with was the gap people kept falling into.
376
+ const id = m.message_id ? ` ${m.message_id}` : "";
377
+ ctx.print(`${" ".repeat(18)}${dim(`from ${m.from ?? m.from_address ?? "?"}${id}`)}`);
340
378
  }
341
379
  });
342
380
  });
@@ -406,16 +444,39 @@ define("threads", "List conversations in an inbox", "carlyemail threads me@carly
406
444
  });
407
445
  });
408
446
 
409
- define("reply", "Reply to a message", 'carlyemail reply me@x.com "<id@host>" --text "On it"', async (ctx) => {
410
- const [inbox, id] = ctx.positional;
411
- if (!inbox || !id) throw new UsageError("carlyemail reply <inbox> <message-id> --text ...");
447
+ /** The newest message in an inbox that the inbox did not itself send. */
448
+ async function lastReceived(ctx, inbox) {
449
+ const out = await request(
450
+ ctx.config,
451
+ "GET",
452
+ `/v0/inboxes/${encodeURIComponent(inbox)}/messages?limit=10`
453
+ );
454
+ // Sent mail is stored in the inbox alongside what arrived, so the newest
455
+ // message is often the agent's own last reply. Replying to yourself is never
456
+ // what `--last` means.
457
+ const mine = inbox.toLowerCase();
458
+ const found = (out.messages || []).find(
459
+ (m) => !String(m.from ?? m.from_address ?? "").toLowerCase().includes(mine)
460
+ );
461
+ if (!found) throw new UsageError(`nothing to reply to in ${inbox}`);
462
+ return found.message_id;
463
+ }
464
+
465
+ define("reply", "Reply to a message", 'carlyemail reply me@x.com --last --text "On it"', async (ctx) => {
466
+ const [inbox, given] = ctx.positional;
467
+ if (!inbox) throw new UsageError("carlyemail reply <inbox> <message-id> --text ...");
468
+ if (!given && !ctx.flags.last) {
469
+ throw new UsageError("carlyemail reply <inbox> <message-id> --text ... (or --last)");
470
+ }
412
471
  const body = {
413
472
  text: typeof ctx.flags.text === "string" ? ctx.flags.text : undefined,
414
473
  html: typeof ctx.flags.html === "string" ? ctx.flags.html : undefined,
415
474
  };
475
+ // Checked before `--last` costs a request, and long before anything is sent.
416
476
  if (body.text === undefined && body.html === undefined) {
417
477
  throw new UsageError("--text or --html is required");
418
478
  }
479
+ const id = given || (await lastReceived(ctx, inbox));
419
480
  // reply-all is opt-in. Quietly copying everyone on the original thread is
420
481
  // the kind of default that sends an agent's message to people the caller
421
482
  // never saw.
@@ -481,6 +542,33 @@ define("send-draft", "Send a draft that was written earlier", "carlyemail send-d
481
542
  emit(ctx, sent, () => ctx.print(ok(`sent ${dim(sent.message_id ?? "")}`)));
482
543
  });
483
544
 
545
+ define(
546
+ "webhook",
547
+ "Create a webhook endpoint",
548
+ "carlyemail webhook https://you.example/mail --events message.received",
549
+ async (ctx) => {
550
+ const url = ctx.positional[0];
551
+ if (!url) throw new UsageError("carlyemail webhook <url> --events message.received");
552
+ const events =
553
+ typeof ctx.flags.events === "string"
554
+ ? ctx.flags.events.split(",").map((e) => e.trim()).filter(Boolean)
555
+ : ["message.received"];
556
+
557
+ const made = await request(ctx.config, "POST", "/v0/webhooks", {
558
+ body: { url, event_types: events },
559
+ });
560
+ emit(ctx, made, () => {
561
+ ctx.print(ok(`webhook ${dim(made.webhook_id ?? "")}`));
562
+ // Returned once and never again. Printing it without saying so is how
563
+ // people end up recreating the webhook to get a secret back.
564
+ if (made.secret) {
565
+ ctx.print(` secret ${made.secret}`);
566
+ ctx.print(dim(" shown once — store it, you need it to verify deliveries"));
567
+ }
568
+ });
569
+ }
570
+ );
571
+
484
572
  define("webhooks", "List webhook endpoints", "carlyemail webhooks", async (ctx) => {
485
573
  const out = await request(ctx.config, "GET", "/v0/webhooks");
486
574
  emit(ctx, out, () => {
@@ -495,6 +583,74 @@ define("webhooks", "List webhook endpoints", "carlyemail webhooks", async (ctx)
495
583
  });
496
584
  });
497
585
 
586
+ /** Print a domain's records with each one's status. */
587
+ function printRecords(ctx, domain) {
588
+ for (const r of domain.records || []) {
589
+ // The per-record status is the whole point: a stuck domain needs to say
590
+ // which record is wrong, not that something is.
591
+ const mark = r.status === "VALID" ? paint(32, "✓") : dim("·");
592
+ const priority = r.priority ? dim(` (priority ${r.priority})`) : "";
593
+ ctx.print(` ${mark} ${dim(r.type.padEnd(5))} ${r.name}`);
594
+ ctx.print(` ${dim("→")} ${r.value}${priority}`);
595
+ }
596
+ }
597
+
598
+ define(
599
+ "domain",
600
+ "Add a custom sending domain, or re-check one",
601
+ "carlyemail domain mail.yourcompany.com [--subdomains] [--check] [--zone]",
602
+ async (ctx) => {
603
+ const name = ctx.positional[0];
604
+ if (!name) throw new UsageError("carlyemail domain <name> [--check]");
605
+
606
+ /** `verify` and the zone file take an id; a person has a name. */
607
+ const findId = async () => {
608
+ const out = await request(ctx.config, "GET", "/v0/domains");
609
+ const found = (out.domains || []).find((d) => d.domain === name);
610
+ if (!found) throw new UsageError(`${name} is not on this account`);
611
+ return found.domain_id;
612
+ };
613
+
614
+ if (ctx.flags.zone) {
615
+ // Straight to stdout with nothing around it, so it can be redirected into
616
+ // a file and handed to a DNS provider's import button.
617
+ const id = await findId();
618
+ ctx.print(await request(ctx.config, "GET", `/v0/domains/${id}/zone-file`, { raw: true }));
619
+ return;
620
+ }
621
+
622
+ let domain = ctx.flags.check
623
+ ? await request(ctx.config, "POST", `/v0/domains/${await findId()}/verify`)
624
+ // `domain`, not `name`. The docs said `name` for months, which 422s.
625
+ : await request(ctx.config, "POST", "/v0/domains", {
626
+ body: { domain: name, subdomains_enabled: Boolean(ctx.flags.subdomains) },
627
+ });
628
+
629
+ // Create is idempotent and returns an existing domain unchanged. Make the
630
+ // flag useful there too: `domain example.com --subdomains` means enabled
631
+ // whether this is the first run or the fifth.
632
+ if (ctx.flags.subdomains && !domain.subdomains_enabled) {
633
+ domain = await request(ctx.config, "PATCH", `/v0/domains/${domain.domain_id}`, {
634
+ body: { subdomains_enabled: true },
635
+ });
636
+ }
637
+
638
+ emit(ctx, domain, () => {
639
+ const verified = domain.status === "VERIFIED";
640
+ ctx.print(`${verified ? paint(32, "✓") : dim("…")} ${bold(name)} ${dim(domain.status)}`);
641
+ if (verified) {
642
+ ctx.print(dim(` carlyemail create --username hello --domain ${name}`));
643
+ return;
644
+ }
645
+ ctx.print("");
646
+ printRecords(ctx, domain);
647
+ ctx.print("");
648
+ ctx.print(dim(` publish these, then: carlyemail domain ${name} --check`));
649
+ ctx.print(dim(` or take the file: carlyemail domain ${name} --zone > ${name}.zone`));
650
+ });
651
+ }
652
+ );
653
+
498
654
  define("domains", "List custom sending domains", "carlyemail domains", async (ctx) => {
499
655
  const out = await request(ctx.config, "GET", "/v0/domains");
500
656
  emit(ctx, out, () => {
@@ -566,6 +722,25 @@ define("mcp", "Print the MCP endpoint for Claude and other clients", "carlyemail
566
722
  ctx.print(dim("An API key works too: Authorization: Bearer <key>"));
567
723
  });
568
724
 
725
+ define(
726
+ "env",
727
+ "Export this account for an agent framework",
728
+ 'eval "$(carlyemail env)"',
729
+ async (ctx) => {
730
+ // Resolve the inbox through the active key instead of trusting saved CLI
731
+ // state. CARLYEMAIL_API_KEY may intentionally point at a different account,
732
+ // pod, or inbox than ~/.carlyemail/config.json.
733
+ const out = await request(ctx.config, "GET", "/v0/inboxes?limit=1&ascending=true");
734
+ const inbox = out?.inboxes?.[0]?.email;
735
+ if (!inbox) throw new UsageError("This key cannot see an inbox to export.");
736
+
737
+ // This command is the explicit escape hatch from the 0600 CLI config into
738
+ // a process environment. Normal commands continue to mask the credential.
739
+ ctx.print(`export CARLYEMAIL_API_KEY=${shellQuote(ctx.config.api_key)}`);
740
+ ctx.print(`export CARLYEMAIL_INBOX=${shellQuote(inbox)}`);
741
+ }
742
+ );
743
+
569
744
  // ---------------------------------------------------------------- help
570
745
 
571
746
  export function helpText() {
@@ -583,8 +758,9 @@ export function helpText() {
583
758
  lines.push(
584
759
  "",
585
760
  bold("Getting started"),
586
- " carlyemail signup --human-email you@example.com --username my-agent",
761
+ " carlyemail signup",
587
762
  " carlyemail verify 123456",
763
+ ' eval "$(carlyemail env)"' + dim(" # load the key and inbox into this shell"),
588
764
  " carlyemail send --from my-agent@agents.carlyemail.com \\",
589
765
  ' --to someone@example.com --subject Hi --text "Hello from an agent"',
590
766
  "",
@@ -603,7 +779,27 @@ export function helpText() {
603
779
 
604
780
  // ---------------------------------------------------------------- entry
605
781
 
606
- export async function main(argv, { env = process.env, print = console.log, configFile, configDir } = {}) {
782
+ /**
783
+ * Read one line from the terminal.
784
+ *
785
+ * Returns null when there is no terminal — a CI job or a piped script must get
786
+ * the usage error it can act on, not a prompt nobody will ever answer.
787
+ */
788
+ async function askTerminal(question) {
789
+ if (!process.stdin.isTTY) return null;
790
+ const { createInterface } = await import("node:readline/promises");
791
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
792
+ try {
793
+ return (await rl.question(question)).trim();
794
+ } finally {
795
+ rl.close();
796
+ }
797
+ }
798
+
799
+ export async function main(
800
+ argv,
801
+ { env = process.env, print = console.log, ask = askTerminal, configFile, configDir } = {}
802
+ ) {
607
803
  const { flags, positional } = parseArgs(argv);
608
804
  const name = positional.shift();
609
805
 
@@ -629,7 +825,7 @@ export async function main(argv, { env = process.env, print = console.log, confi
629
825
 
630
826
  const config = loadConfig(env, configFile);
631
827
  try {
632
- await command.run({ config, flags, positional, print, configFile, configDir });
828
+ await command.run({ config, flags, positional, print, ask, configFile, configDir });
633
829
  return 0;
634
830
  } catch (error) {
635
831
  if (error instanceof ApiError) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "carlyemail",
3
- "version": "0.2.0",
4
- "description": "Real email inboxes your agent can send, receive and reply from.",
3
+ "version": "0.4.0",
4
+ "description": "Real email inboxes your agent can send, receive and reply from. SDK and CLI.",
5
5
  "keywords": [
6
6
  "email",
7
7
  "agent",
@@ -24,9 +24,18 @@
24
24
  "bin": {
25
25
  "carlyemail": "./carlyemail.js"
26
26
  },
27
- "exports": "./carlyemail.js",
27
+ "types": "./sdk.d.ts",
28
+ "exports": {
29
+ ".": "./sdk.js",
30
+ "./webhooks": "./webhooks.js",
31
+ "./cli": "./carlyemail.js"
32
+ },
28
33
  "files": [
29
34
  "carlyemail.js",
35
+ "sdk.js",
36
+ "sdk.d.ts",
37
+ "webhooks.js",
38
+ "webhooks.d.ts",
30
39
  "README.md",
31
40
  "LICENSE"
32
41
  ],