carlyemail 0.6.2 → 0.6.3

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 +1 -1
  2. package/carlyemail.js +125 -8
  3. package/package.json +1 -1
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.3";
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"],
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 = {};
@@ -434,14 +539,18 @@ define("delete", "Delete an inbox and its mail", "carlyemail delete support@carl
434
539
  define(
435
540
  "send",
436
541
  "Send an email",
437
- 'carlyemail send --from me@carlyemail.com --to you@example.com --subject Hi --text "Hello"',
542
+ 'carlyemail send --from me@carlyemail.com --to you@example.com --subject Hi --text "Hello" [--cc a,b] [--bcc a] [--attach file]',
438
543
  async (ctx) => {
439
544
  const from = required(ctx.flags, "from");
440
545
  const body = {
441
- to: required(ctx.flags, "to").split(",").map((s) => s.trim()),
546
+ to: addresses(ctx.flags, "to", { require: true }),
547
+ cc: addresses(ctx.flags, "cc"),
548
+ bcc: addresses(ctx.flags, "bcc"),
549
+ reply_to: addresses(ctx.flags, "reply-to"),
442
550
  subject: typeof ctx.flags.subject === "string" ? ctx.flags.subject : undefined,
443
551
  text: typeof ctx.flags.text === "string" ? ctx.flags.text : undefined,
444
552
  html: typeof ctx.flags.html === "string" ? ctx.flags.html : undefined,
553
+ attachments: attachments(ctx.flags),
445
554
  };
446
555
  const sent = await request(
447
556
  ctx.config,
@@ -613,7 +722,7 @@ define(
613
722
  const inbox = ctx.positional[0];
614
723
  if (!inbox) throw new UsageError("carlyemail draft <inbox> --to ... --text ...");
615
724
  const body = {
616
- to: required(ctx.flags, "to").split(",").map((s) => s.trim()),
725
+ to: addresses(ctx.flags, "to", { require: true }),
617
726
  subject: typeof ctx.flags.subject === "string" ? ctx.flags.subject : undefined,
618
727
  text: typeof ctx.flags.text === "string" ? ctx.flags.text : undefined,
619
728
  };
@@ -922,6 +1031,14 @@ export async function main(
922
1031
  return 1;
923
1032
  }
924
1033
 
1034
+ // Before the config is read, and so before anything can be sent.
1035
+ const refused = refusedFlag(name, flags);
1036
+ if (refused) {
1037
+ print(`${paint(31, "✗")} ${refused}`);
1038
+ print(dim(` ${command.usage}`));
1039
+ return 1;
1040
+ }
1041
+
925
1042
  const config = loadConfig(env, configFile);
926
1043
  try {
927
1044
  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.3",
4
4
  "description": "Real email inboxes your agent can send, receive and reply from. SDK and CLI.",
5
5
  "keywords": [
6
6
  "email",