carlyemail 0.1.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 +58 -5
  2. package/carlyemail.js +279 -8
  3. package/package.json +23 -5
  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
@@ -36,6 +80,15 @@ npm install -g carlyemail
36
80
  | `delete <inbox> --yes` | Delete an inbox and its mail |
37
81
  | `send --from <i> --to <a> --subject <s> --text <t>` | Send an email |
38
82
  | `messages <inbox>` | List messages |
83
+ | `read <inbox> <id>` | Read one message in full |
84
+ | `search <inbox> <text>` | Search messages |
85
+ | `threads <inbox>` | List conversations |
86
+ | `reply <inbox> <id> --text <t>` | Reply to the sender (`--all` to copy everyone) |
87
+ | `drafts <inbox>` | List drafts |
88
+ | `draft <inbox> --to <a> --text <t>` | Write a draft without sending |
89
+ | `send-draft <inbox> <id>` | Send a draft written earlier |
90
+ | `webhooks` | List webhook endpoints |
91
+ | `domains` | List custom domains, with their DNS records |
39
92
  | `plan` | Current plan, with usage against every limit |
40
93
  | `upgrade <plan>` | A Stripe checkout link |
41
94
  | `billing` | Invoices, card changes, cancellation |
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.1.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,236 @@ 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}`)}`);
366
+ }
367
+ });
368
+ });
369
+
370
+ define("read", "Read one message in full", "carlyemail read me@carlyemail.com '<id@host>'", async (ctx) => {
371
+ const [inbox, id] = ctx.positional;
372
+ if (!inbox || !id) throw new UsageError("carlyemail read <inbox> <message-id>");
373
+ const m = await request(
374
+ ctx.config,
375
+ "GET",
376
+ `/v0/inboxes/${encodeURIComponent(inbox)}/messages/${encodeURIComponent(id)}`
377
+ );
378
+ emit(ctx, m, () => {
379
+ ctx.print(`${dim("from")} ${m.from ?? "?"}`);
380
+ ctx.print(`${dim("to")} ${(m.to || []).join(", ")}`);
381
+ ctx.print(`${dim("date")} ${(m.timestamp || "").replace("T", " ").slice(0, 19)}`);
382
+ ctx.print(`${dim("subject")} ${bold(m.subject || "(no subject)")}`);
383
+ if (m.labels?.length) ctx.print(`${dim("labels")} ${m.labels.join(", ")}`);
384
+ if (m.attachments?.length) {
385
+ ctx.print(`${dim("files")} ${m.attachments.map((a) => a.filename ?? a.attachment_id).join(", ")}`);
386
+ }
387
+ ctx.print("");
388
+ // `text` is the body as sent; `extracted_text` has the quoted reply chain
389
+ // stripped. Print the full one — a person reading a single message asked
390
+ // for the message, not our opinion of the interesting part of it.
391
+ ctx.print(m.text || m.preview || dim("(no text body)"));
392
+ });
393
+ });
394
+
395
+ define("search", "Search messages in an inbox", 'carlyemail search me@carlyemail.com "invoice"', async (ctx) => {
396
+ const [inbox, ...terms] = ctx.positional;
397
+ const query = terms.join(" ") || ctx.flags.query;
398
+ if (!inbox || !query) throw new UsageError('carlyemail search <inbox> "what to look for"');
399
+ const out = await request(
400
+ ctx.config,
401
+ "GET",
402
+ // `q`, not `query` — the parameter name comes from the OpenAPI spec, and
403
+ // guessing it produced a 422 that looked like a CLI bug.
404
+ `/v0/inboxes/${encodeURIComponent(inbox)}/messages/search?q=${encodeURIComponent(query)}`
405
+ );
406
+ emit(ctx, out, () => {
407
+ if (!out.messages?.length) {
408
+ ctx.print(dim(`nothing matching ${JSON.stringify(query)}`));
409
+ return;
410
+ }
411
+ for (const m of out.messages) {
412
+ ctx.print(`${dim((m.timestamp || "").slice(0, 10))} ${bold(m.subject || "(no subject)")}`);
413
+ ctx.print(`${" ".repeat(12)}${dim(m.message_id ?? "")}`);
414
+ }
415
+ });
416
+ });
417
+
418
+ define("threads", "List conversations in an inbox", "carlyemail threads me@carlyemail.com", async (ctx) => {
419
+ const inbox = ctx.positional[0];
420
+ if (!inbox) throw new UsageError("carlyemail threads <inbox>");
421
+ const out = await request(ctx.config, "GET", `/v0/inboxes/${encodeURIComponent(inbox)}/threads`);
422
+ emit(ctx, out, () => {
423
+ if (!out.threads?.length) {
424
+ ctx.print(dim("no conversations"));
425
+ return;
426
+ }
427
+ for (const t of out.threads) {
428
+ const count = t.message_count ? dim(` (${t.message_count})`) : "";
429
+ ctx.print(`${bold(t.subject || "(no subject)")}${count}`);
430
+ ctx.print(` ${dim(t.thread_id)} ${dim((t.senders || []).join(", "))}`);
431
+ }
432
+ });
433
+ });
434
+
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
+ }
459
+ const body = {
460
+ text: typeof ctx.flags.text === "string" ? ctx.flags.text : undefined,
461
+ html: typeof ctx.flags.html === "string" ? ctx.flags.html : undefined,
462
+ };
463
+ // Checked before `--last` costs a request, and long before anything is sent.
464
+ if (body.text === undefined && body.html === undefined) {
465
+ throw new UsageError("--text or --html is required");
466
+ }
467
+ const id = given || (await lastReceived(ctx, inbox));
468
+ // reply-all is opt-in. Quietly copying everyone on the original thread is
469
+ // the kind of default that sends an agent's message to people the caller
470
+ // never saw.
471
+ const route = ctx.flags["all"] ? "reply-all" : "reply";
472
+ const sent = await request(
473
+ ctx.config,
474
+ "POST",
475
+ `/v0/inboxes/${encodeURIComponent(inbox)}/messages/${encodeURIComponent(id)}/${route}`,
476
+ { body }
477
+ );
478
+ emit(ctx, sent, () => ctx.print(ok(`replied ${dim(sent.message_id ?? "")}`)));
479
+ });
480
+
481
+ define("drafts", "List drafts in an inbox", "carlyemail drafts me@carlyemail.com", async (ctx) => {
482
+ const inbox = ctx.positional[0];
483
+ if (!inbox) throw new UsageError("carlyemail drafts <inbox>");
484
+ const out = await request(ctx.config, "GET", `/v0/inboxes/${encodeURIComponent(inbox)}/drafts`);
485
+ emit(ctx, out, () => {
486
+ if (!out.drafts?.length) {
487
+ ctx.print(dim("no drafts"));
488
+ return;
489
+ }
490
+ for (const d of out.drafts) {
491
+ ctx.print(`${bold(d.subject || "(no subject)")} ${dim(`to ${(d.to || []).join(", ")}`)}`);
492
+ ctx.print(` ${dim(d.draft_id)}`);
493
+ }
494
+ });
495
+ });
496
+
497
+ define(
498
+ "draft",
499
+ "Write a draft without sending it",
500
+ 'carlyemail draft me@x.com --to you@y.com --subject Hi --text "..."',
501
+ async (ctx) => {
502
+ const inbox = ctx.positional[0];
503
+ if (!inbox) throw new UsageError("carlyemail draft <inbox> --to ... --text ...");
504
+ const body = {
505
+ to: required(ctx.flags, "to").split(",").map((s) => s.trim()),
506
+ subject: typeof ctx.flags.subject === "string" ? ctx.flags.subject : undefined,
507
+ text: typeof ctx.flags.text === "string" ? ctx.flags.text : undefined,
508
+ };
509
+ const d = await request(
510
+ ctx.config,
511
+ "POST",
512
+ `/v0/inboxes/${encodeURIComponent(inbox)}/drafts`,
513
+ { body }
514
+ );
515
+ emit(ctx, d, () => {
516
+ ctx.print(ok(`draft ${d.draft_id}`));
517
+ ctx.print(arrow(`send it with: carlyemail send-draft ${inbox} ${d.draft_id}`));
518
+ });
519
+ }
520
+ );
521
+
522
+ define("send-draft", "Send a draft that was written earlier", "carlyemail send-draft me@x.com dft_123", async (ctx) => {
523
+ const [inbox, id] = ctx.positional;
524
+ if (!inbox || !id) throw new UsageError("carlyemail send-draft <inbox> <draft-id>");
525
+ const sent = await request(
526
+ ctx.config,
527
+ "POST",
528
+ `/v0/inboxes/${encodeURIComponent(inbox)}/drafts/${encodeURIComponent(id)}/send`
529
+ );
530
+ emit(ctx, sent, () => ctx.print(ok(`sent ${dim(sent.message_id ?? "")}`)));
531
+ });
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
+
560
+ define("webhooks", "List webhook endpoints", "carlyemail webhooks", async (ctx) => {
561
+ const out = await request(ctx.config, "GET", "/v0/webhooks");
562
+ emit(ctx, out, () => {
563
+ if (!out.webhooks?.length) {
564
+ ctx.print(dim("no webhooks"));
565
+ return;
566
+ }
567
+ for (const w of out.webhooks) {
568
+ ctx.print(`${w.enabled === false ? dim("(disabled) ") : ""}${bold(w.url)}`);
569
+ ctx.print(` ${dim(w.webhook_id)} ${dim((w.event_types || []).join(", "))}`);
570
+ }
571
+ });
572
+ });
573
+
574
+ define("domains", "List custom sending domains", "carlyemail domains", async (ctx) => {
575
+ const out = await request(ctx.config, "GET", "/v0/domains");
576
+ emit(ctx, out, () => {
577
+ if (!out.domains?.length) {
578
+ ctx.print(dim("no custom domains — mail sends from carlyemail.com"));
579
+ return;
580
+ }
581
+ for (const d of out.domains) {
582
+ const mark = d.status === "VERIFIED" ? paint(32, "✓") : dim("…");
583
+ ctx.print(`${mark} ${bold(d.domain)} ${dim(d.status)}`);
584
+ // Unverified domains are the common case people get stuck on, and the
585
+ // records are the whole answer, so print them rather than a doc link.
586
+ if (d.status !== "VERIFIED") {
587
+ for (const r of d.records || []) {
588
+ ctx.print(` ${dim(r.type)} ${r.name} ${dim("→")} ${r.value}`);
589
+ }
590
+ }
340
591
  }
341
592
  });
342
593
  });
@@ -408,7 +659,7 @@ export function helpText() {
408
659
  lines.push(
409
660
  "",
410
661
  bold("Getting started"),
411
- " carlyemail signup --human-email you@example.com --username my-agent",
662
+ " carlyemail signup",
412
663
  " carlyemail verify 123456",
413
664
  " carlyemail send --from my-agent@agents.carlyemail.com \\",
414
665
  ' --to someone@example.com --subject Hi --text "Hello from an agent"',
@@ -428,7 +679,27 @@ export function helpText() {
428
679
 
429
680
  // ---------------------------------------------------------------- entry
430
681
 
431
- 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
+ ) {
432
703
  const { flags, positional } = parseArgs(argv);
433
704
  const name = positional.shift();
434
705
 
@@ -454,7 +725,7 @@ export async function main(argv, { env = process.env, print = console.log, confi
454
725
 
455
726
  const config = loadConfig(env, configFile);
456
727
  try {
457
- await command.run({ config, flags, positional, print, configFile, configDir });
728
+ await command.run({ config, flags, positional, print, ask, configFile, configDir });
458
729
  return 0;
459
730
  } catch (error) {
460
731
  if (error instanceof ApiError) {
package/package.json CHANGED
@@ -1,8 +1,16 @@
1
1
  {
2
2
  "name": "carlyemail",
3
- "version": "0.1.0",
4
- "description": "Real email inboxes your agent can send, receive and reply from.",
5
- "keywords": ["email", "agent", "ai", "inbox", "smtp", "mcp", "cli"],
3
+ "version": "0.3.0",
4
+ "description": "Real email inboxes your agent can send, receive and reply from. SDK and CLI.",
5
+ "keywords": [
6
+ "email",
7
+ "agent",
8
+ "ai",
9
+ "inbox",
10
+ "smtp",
11
+ "mcp",
12
+ "cli"
13
+ ],
6
14
  "homepage": "https://carlyemail.com",
7
15
  "bugs": "https://docs.carlyemail.com/support",
8
16
  "repository": {
@@ -16,8 +24,18 @@
16
24
  "bin": {
17
25
  "carlyemail": "./carlyemail.js"
18
26
  },
19
- "exports": "./carlyemail.js",
20
- "files": ["carlyemail.js", "README.md", "LICENSE"],
27
+ "types": "./sdk.d.ts",
28
+ "exports": {
29
+ ".": "./sdk.js",
30
+ "./cli": "./carlyemail.js"
31
+ },
32
+ "files": [
33
+ "carlyemail.js",
34
+ "sdk.js",
35
+ "sdk.d.ts",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
21
39
  "engines": {
22
40
  "node": ">=18"
23
41
  },