carlyemail 0.2.0 → 0.3.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.
Files changed (5) hide show
  1. package/README.md +49 -5
  2. package/carlyemail.js +107 -11
  3. package/package.json +9 -3
  4. package/sdk.d.ts +777 -0
  5. package/sdk.js +791 -0
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
7
+
8
+ ```bash
9
+ npm install carlyemail
10
+ ```
11
+
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
4
48
 
5
49
  ```bash
6
- npx carlyemail signup --human-email you@example.com --username my-agent
50
+ npx carlyemail signup
7
51
  ```
8
52
 
9
- That returns a working address and an API key. Confirm the emailed code and it
10
- can send:
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,7 @@ 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
+ Install it if you use it often:
22
66
 
23
67
  ```bash
24
68
  npm install -g carlyemail
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.3.0";
21
22
 
22
23
  const DEFAULT_API = "https://api.carlyemail.com";
23
24
  const CONFIG_DIR = join(homedir(), ".carlyemail");
@@ -211,13 +212,34 @@ function emit(ctx, payload, render) {
211
212
  render();
212
213
  }
213
214
 
215
+ /** A name to offer when somebody has no preference. Short enough to type again. */
216
+ function suggestUsername() {
217
+ return `agent-${randomBytes(2).toString("hex")}`;
218
+ }
219
+
214
220
  define(
215
221
  "signup",
216
222
  "Create an account and an inbox",
217
- "carlyemail signup --human-email you@example.com --username my-agent",
223
+ "carlyemail signup (or: --human-email you@example.com --username my-agent)",
218
224
  async (ctx) => {
219
- const human_email = required(ctx.flags, "human-email");
220
- const username = required(ctx.flags, "username");
225
+ // `signup` is the first command anybody runs, and refusing it with a usage
226
+ // error is a bad way to say hello. At a terminal, ask.
227
+ let human_email = ctx.flags["human-email"];
228
+ if (typeof human_email !== "string") {
229
+ const answer = await ctx.ask("Your email (for the confirmation code): ");
230
+ if (answer) human_email = answer;
231
+ }
232
+ if (!human_email) throw new UsageError("--human-email is required");
233
+
234
+ let username = ctx.flags.username;
235
+ if (typeof username !== "string") {
236
+ const suggested = suggestUsername();
237
+ const answer = await ctx.ask(`Inbox name [${suggested}]: `);
238
+ // null is "no terminal", which still has to fail. "" is a bare Enter,
239
+ // which means take the suggestion.
240
+ if (answer !== null) username = answer || suggested;
241
+ }
242
+ if (!username) throw new UsageError("--username is required");
221
243
 
222
244
  const out = await request(ctx.config, "POST", "/v0/agent/sign-up", {
223
245
  auth: false,
@@ -336,7 +358,11 @@ define("messages", "List messages in an inbox", "carlyemail messages me@carlyema
336
358
  for (const m of out.messages) {
337
359
  const when = (m.timestamp || "").slice(0, 16).replace("T", " ");
338
360
  ctx.print(`${dim(when)} ${bold(m.subject || "(no subject)")}`);
339
- ctx.print(`${" ".repeat(18)}${dim(`from ${m.from ?? m.from_address ?? "?"}`)}`);
361
+ // The id goes on screen because every other command takes one. Listing
362
+ // messages and then having to go to the API to find out what to call
363
+ // `read` or `reply` with was the gap people kept falling into.
364
+ const id = m.message_id ? ` ${m.message_id}` : "";
365
+ ctx.print(`${" ".repeat(18)}${dim(`from ${m.from ?? m.from_address ?? "?"}${id}`)}`);
340
366
  }
341
367
  });
342
368
  });
@@ -406,16 +432,39 @@ define("threads", "List conversations in an inbox", "carlyemail threads me@carly
406
432
  });
407
433
  });
408
434
 
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 ...");
435
+ /** The newest message in an inbox that the inbox did not itself send. */
436
+ async function lastReceived(ctx, inbox) {
437
+ const out = await request(
438
+ ctx.config,
439
+ "GET",
440
+ `/v0/inboxes/${encodeURIComponent(inbox)}/messages?limit=10`
441
+ );
442
+ // Sent mail is stored in the inbox alongside what arrived, so the newest
443
+ // message is often the agent's own last reply. Replying to yourself is never
444
+ // what `--last` means.
445
+ const mine = inbox.toLowerCase();
446
+ const found = (out.messages || []).find(
447
+ (m) => !String(m.from ?? m.from_address ?? "").toLowerCase().includes(mine)
448
+ );
449
+ if (!found) throw new UsageError(`nothing to reply to in ${inbox}`);
450
+ return found.message_id;
451
+ }
452
+
453
+ define("reply", "Reply to a message", 'carlyemail reply me@x.com --last --text "On it"', async (ctx) => {
454
+ const [inbox, given] = ctx.positional;
455
+ if (!inbox) throw new UsageError("carlyemail reply <inbox> <message-id> --text ...");
456
+ if (!given && !ctx.flags.last) {
457
+ throw new UsageError("carlyemail reply <inbox> <message-id> --text ... (or --last)");
458
+ }
412
459
  const body = {
413
460
  text: typeof ctx.flags.text === "string" ? ctx.flags.text : undefined,
414
461
  html: typeof ctx.flags.html === "string" ? ctx.flags.html : undefined,
415
462
  };
463
+ // Checked before `--last` costs a request, and long before anything is sent.
416
464
  if (body.text === undefined && body.html === undefined) {
417
465
  throw new UsageError("--text or --html is required");
418
466
  }
467
+ const id = given || (await lastReceived(ctx, inbox));
419
468
  // reply-all is opt-in. Quietly copying everyone on the original thread is
420
469
  // the kind of default that sends an agent's message to people the caller
421
470
  // never saw.
@@ -481,6 +530,33 @@ define("send-draft", "Send a draft that was written earlier", "carlyemail send-d
481
530
  emit(ctx, sent, () => ctx.print(ok(`sent ${dim(sent.message_id ?? "")}`)));
482
531
  });
483
532
 
533
+ define(
534
+ "webhook",
535
+ "Create a webhook endpoint",
536
+ "carlyemail webhook https://you.example/mail --events message.received",
537
+ async (ctx) => {
538
+ const url = ctx.positional[0];
539
+ if (!url) throw new UsageError("carlyemail webhook <url> --events message.received");
540
+ const events =
541
+ typeof ctx.flags.events === "string"
542
+ ? ctx.flags.events.split(",").map((e) => e.trim()).filter(Boolean)
543
+ : ["message.received"];
544
+
545
+ const made = await request(ctx.config, "POST", "/v0/webhooks", {
546
+ body: { url, event_types: events },
547
+ });
548
+ emit(ctx, made, () => {
549
+ ctx.print(ok(`webhook ${dim(made.webhook_id ?? "")}`));
550
+ // Returned once and never again. Printing it without saying so is how
551
+ // people end up recreating the webhook to get a secret back.
552
+ if (made.secret) {
553
+ ctx.print(` secret ${made.secret}`);
554
+ ctx.print(dim(" shown once — store it, you need it to verify deliveries"));
555
+ }
556
+ });
557
+ }
558
+ );
559
+
484
560
  define("webhooks", "List webhook endpoints", "carlyemail webhooks", async (ctx) => {
485
561
  const out = await request(ctx.config, "GET", "/v0/webhooks");
486
562
  emit(ctx, out, () => {
@@ -583,7 +659,7 @@ export function helpText() {
583
659
  lines.push(
584
660
  "",
585
661
  bold("Getting started"),
586
- " carlyemail signup --human-email you@example.com --username my-agent",
662
+ " carlyemail signup",
587
663
  " carlyemail verify 123456",
588
664
  " carlyemail send --from my-agent@agents.carlyemail.com \\",
589
665
  ' --to someone@example.com --subject Hi --text "Hello from an agent"',
@@ -603,7 +679,27 @@ export function helpText() {
603
679
 
604
680
  // ---------------------------------------------------------------- entry
605
681
 
606
- export async function main(argv, { env = process.env, print = console.log, configFile, configDir } = {}) {
682
+ /**
683
+ * Read one line from the terminal.
684
+ *
685
+ * Returns null when there is no terminal — a CI job or a piped script must get
686
+ * the usage error it can act on, not a prompt nobody will ever answer.
687
+ */
688
+ async function askTerminal(question) {
689
+ if (!process.stdin.isTTY) return null;
690
+ const { createInterface } = await import("node:readline/promises");
691
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
692
+ try {
693
+ return (await rl.question(question)).trim();
694
+ } finally {
695
+ rl.close();
696
+ }
697
+ }
698
+
699
+ export async function main(
700
+ argv,
701
+ { env = process.env, print = console.log, ask = askTerminal, configFile, configDir } = {}
702
+ ) {
607
703
  const { flags, positional } = parseArgs(argv);
608
704
  const name = positional.shift();
609
705
 
@@ -629,7 +725,7 @@ export async function main(argv, { env = process.env, print = console.log, confi
629
725
 
630
726
  const config = loadConfig(env, configFile);
631
727
  try {
632
- await command.run({ config, flags, positional, print, configFile, configDir });
728
+ await command.run({ config, flags, positional, print, ask, configFile, configDir });
633
729
  return 0;
634
730
  } catch (error) {
635
731
  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.3.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,15 @@
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
+ "./cli": "./carlyemail.js"
31
+ },
28
32
  "files": [
29
33
  "carlyemail.js",
34
+ "sdk.js",
35
+ "sdk.d.ts",
30
36
  "README.md",
31
37
  "LICENSE"
32
38
  ],