carlyemail 0.6.2 → 0.6.4

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
@@ -86,7 +86,7 @@ npm install -g carlyemail
86
86
  | `inboxes` | List inboxes |
87
87
  | `create --username <u>` | Create an inbox |
88
88
  | `delete <inbox> --yes` | Delete an inbox and its mail |
89
- | `send --from <i> --to <a> --subject <s> --text <t>` | Send an email |
89
+ | `send --from <i> --to <a> --subject <s> --text <t>` | Send an email (also `--cc`, `--bcc`, `--reply-to`, `--attach <file>`) |
90
90
  | `messages <inbox>` | List messages |
91
91
  | `read <inbox> <id>` | Read one message in full |
92
92
  | `search <inbox> <text>` | Search messages |
package/carlyemail.js CHANGED
@@ -14,11 +14,11 @@
14
14
 
15
15
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, realpathSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
- import { join } from "node:path";
17
+ import { basename, extname, join } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { randomBytes } from "node:crypto";
20
20
 
21
- export const VERSION = "0.6.2";
21
+ export const VERSION = "0.6.4";
22
22
 
23
23
  const DEFAULT_API = "https://api.carlyemail.com";
24
24
  const CONFIG_DIR = join(homedir(), ".carlyemail");
@@ -171,6 +171,11 @@ export async function request(
171
171
  export function parseArgs(argv) {
172
172
  const flags = {};
173
173
  const positional = [];
174
+ // A flag given twice keeps both values, as an array. Last-one-wins would
175
+ // silently drop the first `--attach`.
176
+ const set = (name, value) => {
177
+ flags[name] = Object.hasOwn(flags, name) ? [].concat(flags[name], value) : value;
178
+ };
174
179
  for (let i = 0; i < argv.length; i++) {
175
180
  const arg = argv[i];
176
181
  if (!arg.startsWith("--")) {
@@ -179,11 +184,11 @@ export function parseArgs(argv) {
179
184
  }
180
185
  const equals = arg.indexOf("=");
181
186
  if (equals !== -1) {
182
- flags[arg.slice(2, equals)] = arg.slice(equals + 1);
187
+ set(arg.slice(2, equals), arg.slice(equals + 1));
183
188
  } else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
184
- flags[arg.slice(2)] = argv[++i];
189
+ set(arg.slice(2), argv[++i]);
185
190
  } else {
186
- flags[arg.slice(2)] = true;
191
+ set(arg.slice(2), true);
187
192
  }
188
193
  }
189
194
  return { flags, positional };
@@ -197,6 +202,106 @@ function required(flags, name) {
197
202
  return value;
198
203
  }
199
204
 
205
+ /**
206
+ * An address flag. Commas and repeats both work, so `--cc a@x.com,b@y.com` and
207
+ * `--cc a@x.com --cc b@y.com` send the same message.
208
+ */
209
+ function addresses(flags, name, { require = false } = {}) {
210
+ const value = flags[name];
211
+ if (value === undefined) {
212
+ if (require) throw new UsageError(`--${name} is required`);
213
+ return undefined;
214
+ }
215
+ const given = [].concat(value);
216
+ if (given.some((v) => typeof v !== "string")) throw new UsageError(`--${name} needs an address`);
217
+ const list = given.flatMap((v) => v.split(",")).map((s) => s.trim()).filter(Boolean);
218
+ if (!list.length) throw new UsageError(`--${name} needs an address`);
219
+ return list;
220
+ }
221
+
222
+ /**
223
+ * Enough types that a PDF or a photo arrives as one. Anything not listed goes
224
+ * without a type and the API sends it as application/octet-stream.
225
+ */
226
+ const CONTENT_TYPES = {
227
+ ".pdf": "application/pdf",
228
+ ".png": "image/png",
229
+ ".jpg": "image/jpeg",
230
+ ".jpeg": "image/jpeg",
231
+ ".gif": "image/gif",
232
+ ".webp": "image/webp",
233
+ ".svg": "image/svg+xml",
234
+ ".txt": "text/plain",
235
+ ".md": "text/markdown",
236
+ ".csv": "text/csv",
237
+ ".html": "text/html",
238
+ ".json": "application/json",
239
+ ".ics": "text/calendar",
240
+ ".zip": "application/zip",
241
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
242
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
243
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
244
+ };
245
+
246
+ /** Every `--attach <file>`, read and base64-encoded the way the API takes it. */
247
+ function attachments(flags) {
248
+ if (flags.attach === undefined) return undefined;
249
+ return [].concat(flags.attach).map((path) => {
250
+ if (typeof path !== "string" || !path) throw new UsageError("--attach needs a file");
251
+ let content;
252
+ try {
253
+ content = readFileSync(path);
254
+ } catch (error) {
255
+ throw new UsageError(`cannot read ${path} (${error.code ?? error.message})`);
256
+ }
257
+ return {
258
+ filename: basename(path),
259
+ content_type: CONTENT_TYPES[extname(path).toLowerCase()],
260
+ content: content.toString("base64"),
261
+ };
262
+ });
263
+ }
264
+
265
+ /**
266
+ * The flags each command reads, beyond the global --json, --help and --version.
267
+ *
268
+ * Anything else is refused before a request is made. `send` used to accept
269
+ * `--cc` and `--attach` and ignore them, so the message went out without its
270
+ * copy or its file and the command still printed "sent". A test checks this
271
+ * table against the flags each command's code actually reads.
272
+ */
273
+ const FLAGS = {
274
+ signup: ["human-email", "username", "display-name"],
275
+ signin: ["human-email"],
276
+ verify: ["code"],
277
+ create: ["username", "domain", "display-name"],
278
+ delete: ["yes", "force"],
279
+ send: ["from", "to", "cc", "bcc", "reply-to", "subject", "text", "html", "attach"],
280
+ search: ["query"],
281
+ reply: ["last", "text", "html", "all"],
282
+ draft: ["to", "subject", "text"],
283
+ webhook: ["events"],
284
+ domain: ["zone", "check", "subdomains"],
285
+ upgrade: ["plan"],
286
+ };
287
+ const GLOBAL_FLAGS = ["json", "help", "version"];
288
+ const REPEATABLE = new Set(["to", "cc", "bcc", "reply-to", "attach"]);
289
+
290
+ /** Why these flags cannot be run with this command, or null when they can. */
291
+ export function refusedFlag(commandName, flags) {
292
+ const own = FLAGS[commandName] ?? [];
293
+ for (const [name, value] of Object.entries(flags)) {
294
+ if (!GLOBAL_FLAGS.includes(name) && !own.includes(name)) {
295
+ const takes = own.length ? own.map((f) => `--${f}`).join(", ") : "no options";
296
+ return `${commandName} has no --${name} option (it takes ${takes})`;
297
+ }
298
+ if (Array.isArray(value) && !REPEATABLE.has(name)) {
299
+ return `--${name} was given more than once`;
300
+ }
301
+ }
302
+ return null;
303
+ }
304
+
200
305
  // ---------------------------------------------------------------- commands
201
306
 
202
307
  const commands = {};
@@ -419,7 +524,7 @@ define(
419
524
  }
420
525
  );
421
526
 
422
- define("delete", "Delete an inbox and its mail", "carlyemail delete support@carlyemail.com", async (ctx) => {
527
+ define("delete", "Delete an inbox and its mail", "carlyemail delete support@carlyemail.com --yes", async (ctx) => {
423
528
  const inbox = ctx.positional[0];
424
529
  if (!inbox) throw new UsageError("which inbox? carlyemail delete <inbox>");
425
530
  if (!ctx.flags.yes) {
@@ -427,21 +532,37 @@ define("delete", "Delete an inbox and its mail", "carlyemail delete support@carl
427
532
  `this deletes ${inbox} and every message in it. Re-run with --yes to confirm.`
428
533
  );
429
534
  }
430
- await request(ctx.config, "DELETE", `/v0/inboxes/${encodeURIComponent(inbox)}`);
535
+ // An inbox that sent mail or whose key was used in the last 30 days is
536
+ // refused with `inbox_in_use`, and the API's fix names its own parameter.
537
+ // Here the answer is a flag, so say that instead.
538
+ const force = ctx.flags.force === true;
539
+ const path = `/v0/inboxes/${encodeURIComponent(inbox)}${force ? "?force=true" : ""}`;
540
+ try {
541
+ await request(ctx.config, "DELETE", path);
542
+ } catch (error) {
543
+ if (error instanceof ApiError && error.code === "inbox_in_use") {
544
+ error.fix = "Check nothing still sends from it. To delete it anyway, re-run with --force.";
545
+ }
546
+ throw error;
547
+ }
431
548
  ctx.print(ok(`deleted ${inbox}`));
432
549
  });
433
550
 
434
551
  define(
435
552
  "send",
436
553
  "Send an email",
437
- 'carlyemail send --from me@carlyemail.com --to you@example.com --subject Hi --text "Hello"',
554
+ 'carlyemail send --from me@carlyemail.com --to you@example.com --subject Hi --text "Hello" [--cc a,b] [--bcc a] [--attach file]',
438
555
  async (ctx) => {
439
556
  const from = required(ctx.flags, "from");
440
557
  const body = {
441
- to: required(ctx.flags, "to").split(",").map((s) => s.trim()),
558
+ to: addresses(ctx.flags, "to", { require: true }),
559
+ cc: addresses(ctx.flags, "cc"),
560
+ bcc: addresses(ctx.flags, "bcc"),
561
+ reply_to: addresses(ctx.flags, "reply-to"),
442
562
  subject: typeof ctx.flags.subject === "string" ? ctx.flags.subject : undefined,
443
563
  text: typeof ctx.flags.text === "string" ? ctx.flags.text : undefined,
444
564
  html: typeof ctx.flags.html === "string" ? ctx.flags.html : undefined,
565
+ attachments: attachments(ctx.flags),
445
566
  };
446
567
  const sent = await request(
447
568
  ctx.config,
@@ -613,7 +734,7 @@ define(
613
734
  const inbox = ctx.positional[0];
614
735
  if (!inbox) throw new UsageError("carlyemail draft <inbox> --to ... --text ...");
615
736
  const body = {
616
- to: required(ctx.flags, "to").split(",").map((s) => s.trim()),
737
+ to: addresses(ctx.flags, "to", { require: true }),
617
738
  subject: typeof ctx.flags.subject === "string" ? ctx.flags.subject : undefined,
618
739
  text: typeof ctx.flags.text === "string" ? ctx.flags.text : undefined,
619
740
  };
@@ -922,6 +1043,14 @@ export async function main(
922
1043
  return 1;
923
1044
  }
924
1045
 
1046
+ // Before the config is read, and so before anything can be sent.
1047
+ const refused = refusedFlag(name, flags);
1048
+ if (refused) {
1049
+ print(`${paint(31, "✗")} ${refused}`);
1050
+ print(dim(` ${command.usage}`));
1051
+ return 1;
1052
+ }
1053
+
925
1054
  const config = loadConfig(env, configFile);
926
1055
  try {
927
1056
  await command.run({ config, flags, positional, print, ask, configFile, configDir });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carlyemail",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Real email inboxes your agent can send, receive and reply from. SDK and CLI.",
5
5
  "keywords": [
6
6
  "email",
package/sdk.d.ts CHANGED
@@ -576,6 +576,7 @@ export interface UpdateInboxRequest {
576
576
 
577
577
  export interface UpdateInboxWebhookRequest {
578
578
  event_types?: Array<string> | null;
579
+ enabled?: boolean | null;
579
580
  }
580
581
 
581
582
  export interface UpdateMessageRequest {
@@ -605,6 +606,7 @@ export interface UpdateWebhookHeadersRequest {
605
606
 
606
607
  export interface UpdateWebhookRequest {
607
608
  event_types?: Array<string> | null;
609
+ enabled?: boolean | null;
608
610
  add_inbox_ids?: Array<string> | null;
609
611
  remove_inbox_ids?: Array<string> | null;
610
612
  add_pod_ids?: Array<string> | null;
@@ -652,6 +654,9 @@ export interface WebhookOut {
652
654
  inbox_ids?: Array<string>;
653
655
  secret?: string | null;
654
656
  enabled?: boolean;
657
+ disabled_reason?: string | null;
658
+ disabled_at?: string | null;
659
+ failing_since?: string | null;
655
660
  client_id?: string | null;
656
661
  updated_at: string;
657
662
  created_at: string;
@@ -697,7 +702,7 @@ export declare class Inboxes {
697
702
  create(body: CreateInboxRequest): Promise<InboxOut>;
698
703
  get(inboxId: string): Promise<InboxOut>;
699
704
  update(inboxId: string, body: UpdateInboxRequest): Promise<InboxOut>;
700
- delete(inboxId: string): Promise<void>;
705
+ delete(inboxId: string, query?: { force?: boolean }): Promise<void>;
701
706
  }
702
707
 
703
708
  export declare class Messages {
@@ -794,7 +799,7 @@ export declare class Pods {
794
799
  createInbox(podId: string, body: CreateInboxRequest): Promise<InboxOut>;
795
800
  getInbox(inboxId: string, podId: string): Promise<InboxOut>;
796
801
  updateInbox(inboxId: string, podId: string, body: UpdateInboxRequest): Promise<InboxOut>;
797
- deleteInbox(inboxId: string, podId: string): Promise<void>;
802
+ deleteInbox(inboxId: string, podId: string, query?: { force?: boolean }): Promise<void>;
798
803
  listThreads(podId: string, query?: { limit?: number | null; pageToken?: string | null; labels?: Array<string> | null; before?: string | null; after?: string | null; ascending?: boolean; includeSpam?: boolean; includeBlocked?: boolean; includeUnauthenticated?: boolean; includeTrash?: boolean; senders?: Array<string> | null; recipients?: Array<string> | null; subject?: string | null }): Promise<ListThreadsResponse>;
799
804
  searchThreads(podId: string, query?: { q?: string; limit?: number | null; pageToken?: string | null; before?: string | null; after?: string | null }): Promise<ListThreadsResponse>;
800
805
  getThread(threadId: string, podId: string): Promise<ThreadOut>;
package/sdk.js CHANGED
@@ -163,8 +163,8 @@ class Inboxes {
163
163
  }
164
164
 
165
165
  /** Delete Inbox */
166
- delete(inboxId) {
167
- return this.$.request("DELETE", `/v0/inboxes/${encode(inboxId)}`);
166
+ delete(inboxId, query = {}) {
167
+ return this.$.request("DELETE", `/v0/inboxes/${encode(inboxId)}`, { query: { "force": query.force } });
168
168
  }
169
169
  }
170
170
 
@@ -567,8 +567,8 @@ class Pods {
567
567
  }
568
568
 
569
569
  /** Delete Inbox */
570
- deleteInbox(inboxId, podId) {
571
- return this.$.request("DELETE", `/v0/pods/${encode(podId)}/inboxes/${encode(inboxId)}`);
570
+ deleteInbox(inboxId, podId, query = {}) {
571
+ return this.$.request("DELETE", `/v0/pods/${encode(podId)}/inboxes/${encode(inboxId)}`, { query: { "force": query.force } });
572
572
  }
573
573
 
574
574
  /** List Threads */