-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.mjs
More file actions
executable file
·922 lines (856 loc) · 45.8 KB
/
Copy pathcli.mjs
File metadata and controls
executable file
·922 lines (856 loc) · 45.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
#!/usr/bin/env node
// MailKite CLI — a wrangler-style terminal client for MailKite.
//
// It is a thin layer over the MailKite Node SDK (sdks/node): every network call
// goes through the `MailKite` class, so auth, base URL, and error handling stay
// in one place. Auth/token storage and the interactive flows are the CLI's own.
//
// Designed to be fully scriptable for AI agents: every command works from flags
// + env with no prompts (prompts only appear as a fallback on an interactive TTY),
// and every data command supports `--json` for machine-readable output.
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import readline from "node:readline";
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { MailKite, MailKiteError } from "mailkite";
// Read from the package manifest so `--version` never drifts from the published version.
// npm always ships package.json in the tarball (even though `files` doesn't list it), and it
// sits next to this script, so this resolves both in-repo and when installed.
const VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
const CONFIG_DIR = path.join(homedir(), ".mailkite");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
const DEFAULT_BASE_URL = "https://api.mailkite.dev";
// ---- tiny arg parser --------------------------------------------------------
// Supports: positionals, --flag, --key value, --key=value, -h/-v shortcuts.
function parseArgs(argv) {
const _ = [];
const flags = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "-h") { flags.help = true; continue; }
if (a === "-v") { flags.version = true; continue; }
if (a.startsWith("--")) {
const eq = a.indexOf("=");
if (eq !== -1) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) { flags[key] = true; }
else { flags[key] = next; i++; }
} else {
_.push(a);
}
}
return { _, flags };
}
// ---- output helpers ---------------------------------------------------------
const isTTY = process.stdout.isTTY;
const c = (code, s) => (isTTY ? `\x1b[${code}m${s}\x1b[0m` : s);
const bold = (s) => c("1", s);
const dim = (s) => c("2", s);
const green = (s) => c("32", s);
const red = (s) => c("31", s);
const cyan = (s) => c("36", s);
function out(data, flags, human) {
if (flags.json) { console.log(JSON.stringify(data, null, 2)); return; }
if (typeof human === "function") human(data);
else console.log(typeof data === "string" ? data : JSON.stringify(data, null, 2));
}
function die(msg, code = 1) {
console.error(red("✗ ") + msg);
process.exit(code);
}
// ---- config -----------------------------------------------------------------
function loadConfig() {
try { return JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch { return {}; }
}
function saveConfig(cfg) {
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
}
function resolveToken(flags) {
return (
flags.token ||
process.env.MAILKITE_API_KEY ||
process.env.MAILKITE_TOKEN ||
loadConfig().token ||
""
);
}
function resolveBaseUrl(flags) {
return flags["base-url"] || process.env.MAILKITE_BASE_URL || loadConfig().baseUrl || DEFAULT_BASE_URL;
}
// A client for endpoints that need auth. Errors clearly if no token is present.
function client(flags, { requireToken = true } = {}) {
const token = resolveToken(flags);
if (requireToken && !token) {
die("Not signed in. Run `mailkite login` (or set MAILKITE_API_KEY / pass --token).");
}
return new MailKite(token, resolveBaseUrl(flags));
}
// ---- interactive prompt (TTY fallback only) ---------------------------------
function prompt(question, { hidden = false } = {}) {
if (!process.stdin.isTTY) return Promise.resolve("");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
return new Promise((resolve) => {
if (hidden) {
const onData = (ch) => {
const s = ch.toString();
if (s === "\n" || s === "\r" || s === "") return;
process.stdout.write("*");
};
process.stdin.on("data", onData);
rl.question(question, (ans) => { process.stdin.off("data", onData); process.stdout.write("\n"); rl.close(); resolve(ans.trim()); });
} else {
rl.question(question, (ans) => { rl.close(); resolve(ans.trim()); });
}
});
}
async function need(value, label, promptText, opts) {
if (value) return value;
const v = await prompt(promptText, opts);
if (!v) die(`${label} is required (pass it as a flag for non-interactive use).`);
return v;
}
const toList = (v) => (Array.isArray(v) ? v : String(v).split(",").map((s) => s.trim()).filter(Boolean));
// Decode a JWT payload (no verification — just to show who you are locally).
function decodeJwt(token) {
try {
const [, payload] = token.split(".");
return JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
} catch { return null; }
}
// Concise per-provider DNS hint (full playbooks live in the AI skill).
const PROVIDER_HINTS = {
cloudflare: "Cloudflare: Dashboard → DNS → Records, or `npx wrangler` / API POST /zones/:zone/dns_records. Set proxy=DNS-only (grey cloud) for MX.",
godaddy: "GoDaddy: Domain Portfolio → DNS → Add, or API PATCH /v1/domains/:domain/records.",
namecheap: "Namecheap: Domain List → Manage → Advanced DNS → Add New Record.",
route53: "Route 53: Hosted zone → Create record, or `aws route53 change-resource-record-sets`.",
};
// ---- browser OAuth (the recommended sign-in, same flow the MCP uses) --------
// Authorization Code + PKCE against MailKite's OAuth 2.1 server. We register an
// ephemeral public client, bind a loopback port to catch the redirect (RFC 8252),
// open the browser, then exchange the code for an account JWT. The token is the
// same shape email/password login returns, so it works everywhere a token does —
// including admin endpoints when the account's email is in the admin allowlist.
function b64url(bytes) {
return Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function pkcePair() {
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return { verifier, challenge: b64url(new Uint8Array(digest)) };
}
function openBrowser(url) {
const cmd = process.platform === "darwin" ? "open"
: process.platform === "win32" ? "cmd"
: "xdg-open";
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); return true; }
catch { return false; }
}
async function browserLogin(flags) {
const base = resolveBaseUrl(flags);
// 1. Bind the loopback listener first so we can register its exact redirect_uri.
const server = createServer();
await new Promise((r, j) => { server.once("error", j); server.listen(0, "127.0.0.1", r); });
const port = server.address().port;
const redirectUri = `http://127.0.0.1:${port}/callback`;
// 2. Register an ephemeral public client (dynamic client registration).
const reg = await fetch(`${base}/oauth/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ client_name: "MailKite CLI", redirect_uris: [redirectUri] }),
});
if (!reg.ok) { server.close(); return die(`Client registration failed (${reg.status}): ${await reg.text()}`); }
const { client_id } = await reg.json();
// 3. Build the authorize URL with PKCE + CSRF state.
const { verifier, challenge } = await pkcePair();
const state = b64url(crypto.getRandomValues(new Uint8Array(16)));
const authUrl = `${base}/oauth/authorize?` + new URLSearchParams({
response_type: "code",
client_id,
redirect_uri: redirectUri,
code_challenge: challenge,
code_challenge_method: "S256",
scope: "mcp",
state,
});
// 4. Wait for the redirect, then open the browser.
const codePromise = new Promise((resolve, reject) => {
const timer = setTimeout(() => { server.close(); reject(new Error("timed out after 5 minutes")); }, 5 * 60 * 1000);
server.on("request", (req, res) => {
const u = new URL(req.url, "http://127.0.0.1");
if (u.pathname !== "/callback") { res.writeHead(404).end(); return; }
const reply = (ok, msg) => {
res.writeHead(200, { "content-type": "text/html" });
res.end(`<!doctype html><meta charset=utf-8><title>MailKite CLI</title>
<body style="font:16px system-ui;display:grid;place-items:center;height:90vh;margin:0">
<div style="text-align:center"><h2>${ok ? "✓ Signed in" : "✗ Sign-in failed"}</h2>
<p style="color:#666">${msg}</p></div></body>`);
};
const err = u.searchParams.get("error");
const code = u.searchParams.get("code");
if (err) { reply(false, err); clearTimeout(timer); server.close(); return reject(new Error(`authorization denied: ${err}`)); }
if (!code || u.searchParams.get("state") !== state) { reply(false, "state mismatch"); clearTimeout(timer); server.close(); return reject(new Error("state mismatch or missing code")); }
reply(true, "You can close this tab and return to the terminal.");
clearTimeout(timer); server.close(); resolve(code);
});
});
console.error(dim(`Opening your browser to sign in… if it doesn't open, visit:\n ${authUrl}`));
openBrowser(authUrl);
const code = await codePromise;
// 5. Exchange the code for tokens (PKCE proof).
const tok = await fetch(`${base}/oauth/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id,
code_verifier: verifier,
}),
});
if (!tok.ok) return die(`Token exchange failed (${tok.status}): ${await tok.text()}`);
return tok.json();
}
// =============================================================================
// Commands
// =============================================================================
const commands = {};
commands.login = async ({ flags }) => {
// Browser OAuth is the default and recommended path (and the way to get an admin
// session). Fall back to email/password only when --password or --email is given,
// or when there's no interactive terminal to hand off to a browser.
const wantsPassword = flags.password !== undefined || flags.email !== undefined;
if (!wantsPassword && (flags.browser || isTTY)) {
let res;
try { res = await browserLogin(flags); }
catch (e) { return die(`Browser sign-in failed: ${e.message}`); }
const claims = decodeJwt(res.access_token);
saveConfig({ ...loadConfig(), token: res.access_token, refreshToken: res.refresh_token, baseUrl: resolveBaseUrl(flags) });
out(res, flags, () => console.log(green("✓ ") + `Signed in${claims?.sub ? ` as ${bold(claims.sub)}` : ""}. Token saved to ${dim(CONFIG_FILE)}`));
return;
}
if (!wantsPassword && !isTTY) {
return die("No terminal for browser sign-in. Run `mailkite login --browser` in a shell, or pass --email/--password, or set MAILKITE_API_KEY.");
}
const email = await need(flags.email, "email", "Email: ");
const password = await need(flags.password, "password", "Password: ", { hidden: true });
const mk = new MailKite("", resolveBaseUrl(flags));
let res;
try { res = await mk.request("POST", "/api/auth/login", { email, password }); }
catch (e) { return die(e instanceof MailKiteError ? `Login failed (${e.status}): ${e.message}` : e.message); }
const cfg = loadConfig();
saveConfig({ ...cfg, token: res.token, baseUrl: resolveBaseUrl(flags) });
out(res, flags, () => console.log(green("✓ ") + `Signed in as ${bold(res.user.email)}. Token saved to ${dim(CONFIG_FILE)}`));
};
commands.signup = async ({ flags }) => {
const email = await need(flags.email, "email", "Email: ");
const password = await need(flags.password, "password", "Password: ", { hidden: true });
const mk = new MailKite("", resolveBaseUrl(flags));
let res;
try { res = await mk.request("POST", "/api/auth/signup", { email, password }); }
catch (e) { return die(e instanceof MailKiteError ? `Signup failed (${e.status}): ${e.message}` : e.message); }
saveConfig({ ...loadConfig(), token: res.token, baseUrl: resolveBaseUrl(flags) });
out(res, flags, () => console.log(green("✓ ") + `Account created for ${bold(res.user.email)}. Signed in.`));
};
commands.register = async ({ flags }) => {
// Password-less registration: email → account + API key. The account can't SEND until
// the email is verified (check with `mailkite whoami`). Existing emails get 409 — log in.
const email = await need(flags.email, "email", "Email: ");
const mk = new MailKite("", resolveBaseUrl(flags));
let res;
try {
res = await mk.request("POST", "/api/v1/provision", { email, channel: flags.channel || "cli", ...(flags.ref ? { ref: flags.ref } : {}) });
} catch (e) { return die(e instanceof MailKiteError ? `Register failed (${e.status}): ${e.message}` : e.message); }
saveConfig({ ...loadConfig(), apiKey: res.api_key, baseUrl: resolveBaseUrl(flags) });
out(res, flags, () =>
console.log(green("✓ ") + `Account created for ${bold(res.email)} — API key saved to ${dim(CONFIG_FILE)}.\n Check your inbox and verify the address before sending (status: ${bold("mailkite whoami")}).`));
};
commands.keys = async ({ _, flags }) => {
const sub = _[0] || "show";
const mk = client(flags);
if (sub === "show") return out(await mk.getApiKey(), flags, (r) => console.log(r.key));
if (sub === "rotate") return out(await mk.rotateApiKey(), flags, (r) => console.log(green("✓ ") + `New key (old one is dead): ${bold(r.key)}`));
if (sub === "scoped") {
const rows = await mk.listScopedKeys();
return out(rows, flags, (list) => {
if (!list.length) return console.log(dim("No scoped keys. Create one: mailkite keys create --domain-id <dom_…> [--name label]"));
for (const k of list) console.log(`${bold(k.domain)} ${dim(k.id)} ${k.name || dim("(unnamed)")} ${k.key}`);
});
}
if (sub === "create") {
const domainId = await need(flags["domain-id"] || _[1], "domain-id", "Domain id (dom_…): ");
const res = await mk.createScopedKey({ domainId, ...(flags.name ? { name: flags.name } : {}) });
return out(res, flags, (r) => console.log(green("✓ ") + `Scoped key for ${bold(r.domain)}: ${r.key}`));
}
if (sub === "rm") {
const keyId = await need(_[1] || flags.id, "id", "Key id (key_…): ");
return out(await mk.deleteScopedKey(keyId), flags, () => console.log(green("✓ ") + "Key revoked."));
}
return die(`Unknown keys subcommand: ${sub} (show | rotate | scoped | create | rm)`);
};
// App passwords — mailbox credentials: one domain, an address pattern within it, and the
// protocols it may use (imap, api). Docs: docs/architecture/app-passwords.md.
commands.passwords = async ({ _, flags }) => {
const sub = _[0] || "list";
const mk = client(flags);
if (sub === "list") {
const rows = await mk.listAppPasswords();
return out(rows, flags, (list) => {
if (!list.length) return console.log(dim('No app passwords. Create one: mailkite passwords create --domain acme.dev [--address "support-*"] [--protocols imap,api]'));
for (const p of list) {
const proto = (p.protocols || []).join("+") || dim("none");
console.log(`${bold(`${p.address}@${p.domain}`)} ${proto} ${dim(p.id)} ${p.label || dim("(unlabelled)")}${p.legacy ? dim(" (legacy)") : ""}`);
}
});
}
if (sub === "create") {
const domain = await need(flags.domain || _[1], "domain", "Domain (acme.dev): ");
const body = { domain, address: flags.address || "*", protocols: (flags.protocols || "imap").split(",") };
if (flags.label) body.label = flags.label;
const res = await mk.createAppPassword(body);
// The secret is shown once — say so, because there is no second chance to copy it.
return out(res, flags, (r) => console.log(green("✓ ") + `App password for ${bold(`${r.address}@${r.domain}`)}: ${bold(r.secret)}\n ${dim("Copy it now — it is never shown again.")}`));
}
if (sub === "rm") {
const pwId = await need(_[1] || flags.id, "id", "App password id (apw_…): ");
return out(await mk.deleteAppPassword(pwId), flags, () => console.log(green("✓ ") + "App password revoked."));
}
return die(`Unknown passwords subcommand: ${sub} (list | create | rm)`);
};
commands.usage = async ({ flags }) => {
const res = await client(flags).getUsage();
out(res, flags, (r) => {
const included = r.emails.included == null ? "unlimited" : r.emails.included;
console.log(`plan ${bold(r.plan)} emails ${bold(r.emails.used)}/${included} ai-actions ${r.aiActions.used}${r.overage.blocked ? " " + red("SENDING BLOCKED (fix payment)") : r.overage.over ? " over included" : ""}`);
});
};
commands.suppressions = async ({ _, flags }) => {
const sub = _[0] || "list";
const mk = client(flags);
if (sub === "list") {
const res = await mk.listSuppressions();
return out(res, flags, (r) => {
if (!r.suppressions.length) return console.log(dim("Suppression list is empty."));
for (const s of r.suppressions) console.log(`${bold(s.email)} ${s.reason} ${dim(new Date(s.created_at).toISOString().slice(0, 10))}`);
});
}
if (sub === "add") {
const email = await need(_[1] || flags.email, "email", "Email to suppress: ");
const res = await mk.addSuppression({ email, ...(flags.reason ? { reason: flags.reason } : {}), ...(flags.note ? { note: flags.note } : {}) });
return out(res, flags, (r) => console.log(green("✓ ") + `Suppressed ${bold(r.email)}.`));
}
if (sub === "rm") {
const email = await need(_[1] || flags.email, "email", "Email to unsuppress: ");
return out(await mk.removeSuppression(email), flags, () => console.log(green("✓ ") + "Removed."));
}
return die(`Unknown suppressions subcommand: ${sub} (list | add | rm)`);
};
commands.logout = async ({ flags }) => {
const cfg = loadConfig();
delete cfg.token;
saveConfig(cfg);
out({ ok: true }, flags, () => console.log(green("✓ ") + "Signed out (token removed)."));
};
commands.whoami = async ({ flags }) => {
const token = resolveToken(flags);
if (!token) return die("Not signed in.");
const claims = decodeJwt(token);
// Local credential facts + the account behind it (me() — email, plan, verification;
// sending is blocked until the email is verified, so surface that here).
const me = await client(flags).me().catch(() => null);
const info = {
userId: claims?.sub ?? null,
email: me?.email ?? null,
plan: me?.plan ?? null,
emailVerified: me?.emailVerified ?? null,
baseUrl: resolveBaseUrl(flags),
tokenExpires: claims?.exp ? new Date(claims.exp * 1000).toISOString() : null,
source: flags.token ? "flag" : process.env.MAILKITE_API_KEY ? "MAILKITE_API_KEY" : process.env.MAILKITE_TOKEN ? "MAILKITE_TOKEN" : "config",
};
out(info, flags, (i) => {
console.log(`${bold("user")} ${i.email ?? i.userId ?? dim("unknown")}${i.plan ? ` ${dim("plan:")} ${i.plan}` : ""}${i.emailVerified === false ? " " + red("email unverified — sending blocked") : ""}`);
console.log(`${bold("api")} ${i.baseUrl}`);
console.log(`${bold("expires")} ${i.tokenExpires ?? dim("n/a")} ${dim("(" + i.source + ")")}`);
});
};
commands.send = async ({ flags }) => {
const templateId = flags["template-id"] || flags.template;
const from = await need(flags.from, "from", "From (you@your-verified-domain): ");
const to = await need(flags.to, "to", "To: ");
// A template supplies the subject/body, so don't prompt for them when one is given.
const subject = templateId ? flags.subject : await need(flags.subject, "subject", "Subject: ");
let html = flags.html;
let text = flags.text;
if (flags.file) {
const content = readFileSync(flags.file, "utf8");
if (flags.file.endsWith(".html") || flags.file.endsWith(".htm")) html = content;
else text = content;
}
if (!templateId && !html && !text) text = await need("", "body", "Body (text): ");
const message = { from, to: toList(to).length > 1 ? toList(to) : to };
if (subject) message.subject = subject;
if (html) message.html = html;
if (text) message.text = text;
if (flags.cc) message.cc = toList(flags.cc);
if (flags.bcc) message.bcc = toList(flags.bcc);
if (flags["reply-to"]) message.replyTo = flags["reply-to"];
if (flags["in-reply-to"]) message.inReplyTo = flags["in-reply-to"];
if (templateId) message.templateId = templateId;
if (flags["template-data"]) {
try { message.templateData = JSON.parse(flags["template-data"]); }
catch { return die(`--template-data must be valid JSON (got: ${flags["template-data"]})`); }
}
// --dry-run: assemble and print the exact request body without sending. Scriptable preflight —
// no credential needed since nothing leaves the machine.
if (flags["dry-run"]) {
return out({ dryRun: true, request: message }, flags, () =>
console.log(dim("dry run — not sent. Request body:\n") + JSON.stringify(message, null, 2)));
}
const mk = client(flags);
let res;
try { res = await mk.send(message); }
catch (e) { return die(e instanceof MailKiteError ? `Send failed (${e.status}): ${e.message}` : e.message); }
out(res, flags, (r) => console.log(green("✓ ") + `Sent — id ${bold(r.id)}, status ${r.status}`));
};
// Batch send: one personalized message per recipient in a single call. The batch body is
// JSON (shared fields + recipients[]) — too structured for flags, so it comes from --file
// or inline --json. Same --dry-run preflight as send.
commands["send-batch"] = async ({ flags }) => {
// (--json stays the machine-output flag, as everywhere else; the body comes via --file/--body.)
let raw;
if (flags.file) raw = readFileSync(flags.file, "utf8");
else if (flags.body && flags.body !== true) raw = flags.body;
else return die("send-batch needs the batch body as JSON: --file batch.json or --body '<json>'");
let batch;
try { batch = JSON.parse(raw); }
catch { return die("the batch body must be valid JSON ({ from, recipients: [{ to, … }], … })"); }
if (flags["dry-run"]) {
return out({ dryRun: true, request: batch }, flags, () =>
console.log(dim("dry run — not sent. Request body:\n") + JSON.stringify(batch, null, 2)));
}
const mk = client(flags);
let res;
try { res = await mk.sendBatch(batch); }
catch (e) { return die(e instanceof MailKiteError ? `Batch send failed (${e.status}): ${e.message}` : e.message); }
out(res, flags, (r) => {
console.log(green("✓ ") + `Batch — ${r.sent} sent, ${r.scheduled} scheduled, ${r.failed} failed`);
for (const item of r.results ?? []) {
const line = ` ${item.status === "failed" ? "✗" : "✓"} ${item.to} — ${item.status}` +
(item.id ? ` (${item.id})` : "") + (item.error ? `: ${item.error}` : "");
console.log(item.status === "failed" ? line : dim(line));
}
});
};
// Send a message straight to an inbox agent and print its reply. Defaults to the account's
// default agent; --route-id / --address pick a specific one, --model overrides the model.
commands.agent = async ({ _, flags }) => {
const mk = client(flags);
const text = await need(_[0] || flags.text, "text", "Message to the agent: ");
const message = { text };
if (flags.subject) message.subject = flags.subject;
if (flags.from) message.from = flags.from;
if (flags["route-id"]) message.routeId = flags["route-id"];
if (flags.address) message.address = flags.address;
if (flags.model) message.model = flags.model;
let res;
try { res = await mk.agent(message); }
catch (e) { return die(e instanceof MailKiteError ? `Agent failed (${e.status}): ${e.message}` : e.message); }
out(res, flags, (r) => { if (r.ok) console.log(r.text || dim("(no reply)")); else die(r.error || "agent run failed"); });
};
// Route a message to one of your registered routes (by id or address), running its action.
commands.route = async ({ _, flags }) => {
const mk = client(flags);
const from = await need(flags.from, "from", "From: ");
const message = { from };
if (flags["route-id"]) message.routeId = flags["route-id"];
if (flags.address) message.address = flags.address;
if (!message.routeId && !message.address) {
const target = await need(_[0] || "", "route", "Route id (rte_…) or address it matches: ");
if (target.startsWith("rte_")) message.routeId = target; else message.address = target;
}
if (flags.subject) message.subject = flags.subject;
let text = flags.text;
if (flags.file) text = readFileSync(flags.file, "utf8");
if (text) message.text = text;
if (flags.html) message.html = flags.html;
let res;
try { res = await mk.route(message); }
catch (e) { return die(e instanceof MailKiteError ? `Route failed (${e.status}): ${e.message}` : e.message); }
out(res, flags, (r) => console.log(green("✓ ") + `Routed — id ${bold(r.id)}, action ${r.action}`));
};
commands.domains = async ({ _, flags }) => {
const sub = _[0] || "list";
const mk = client(flags);
if (sub === "list") {
const rows = await mk.listDomains();
return out(rows, flags, (list) => {
if (!Array.isArray(list) || !list.length) return console.log(dim("No domains yet. Add one: mailkite domains add <domain>"));
for (const d of list) console.log(`${bold(d.domain)} ${dim(d.id)} ${d.status === "verified" ? green(d.status) : d.status}`);
});
}
if (sub === "add") {
const domain = await need(_[1] || flags.domain, "domain", "Domain to add: ");
const res = await mk.createDomain({ domain });
return out(res, flags, (r) => { console.log(green("✓ ") + `Added ${bold(r.domain.domain)} (${dim(r.domain.id)})`); printDns(r.dns); });
}
if (sub === "get") {
const id = await need(_[1] || flags.id, "id", "Domain id: ");
return out(await mk.getDomain(id), flags);
}
if (sub === "verify") {
const id = await need(_[1] || flags.id, "id", "Domain id: ");
const res = await mk.verifyDomain(id);
return out(res, flags, (r) => {
const mark = (b) => (b ? green("✓") : red("✗"));
console.log(`status: ${r.status === "verified" ? green(r.status) : r.status}`);
console.log(` MX ${mark(r.checks?.mx)} SPF ${mark(r.checks?.spf)} DKIM ${mark(r.checks?.dkim)} DMARC ${mark(r.checks?.dmarc)}`);
});
}
if (sub === "rm" || sub === "delete") {
const id = await need(_[1] || flags.id, "id", "Domain id: ");
return out(await mk.deleteDomain(id), flags, () => console.log(green("✓ ") + "Domain removed."));
}
if (sub === "check") {
const domain = await need(_[1] || flags.domain, "domain", "Domain to check: ");
const res = await mk.checkDomainAvailability(domain);
return out(res, flags, (r) => {
if (r.configured === false) return console.log(dim("Domain registration isn't enabled for this account."));
if (r.available) {
const p = r.price ? " " + dim(`${r.price.amount} ${r.price.currency} / ${r.price.period}${r.price.periodUnit}`) : "";
console.log(green("✓ ") + `${bold(r.domain)} is available${p}${r.premium ? red(" (premium)") : ""}`);
console.log(dim(`Register it: mailkite domains register ${r.domain}`));
} else {
console.log(red("✗ ") + `${bold(r.domain)} is not available${r.reason ? dim(` (${r.reason})`) : ""}`);
}
});
}
if (sub === "register" || sub === "buy") {
const domain = await need(_[1] || flags.domain, "domain", "Domain to register: ");
// Show the price first (no charge) so the user confirms with eyes open.
const avail = await mk.checkDomainAvailability(domain).catch(() => null);
if (avail?.configured === false) die("Domain registration isn't enabled for this account.");
if (avail && !avail.available) die(`${domain} is not available${avail.reason ? ` (${avail.reason})` : ""}.`);
if (avail?.price) console.error(`Price: ${bold(`${avail.price.amount} ${avail.price.currency}`)} / ${avail.price.period}${avail.price.periodUnit}${avail.premium ? red(" (premium)") : ""}`);
const contact = {
firstName: await need(flags["first-name"], "first name", "First name: "),
lastName: await need(flags["last-name"], "last name", "Last name: "),
email: await need(flags.email, "email", "Email: "),
phone: await need(flags.phone, "phone", "Phone (+countrycode.number, e.g. +1.4155551234): "),
address: await need(flags.address, "address", "Address: "),
city: await need(flags.city, "city", "City: "),
zip: await need(flags.zip, "postal code", "Postal code: "),
country: await need(flags.country, "country", "Country (2-letter, e.g. US): "),
};
if (flags.state) contact.state = flags.state;
const org = flags.organization || flags.org;
if (org) { contact.organization = org; contact.type = flags.type || "company"; }
const years = flags.years ? Number(flags.years) : undefined;
// Final confirmation — this charges the registrar.
const ans = flags.yes || flags.force ? "y" : (await prompt(`Register ${bold(domain)} now? This charges your registrar. [y/N] `)).toLowerCase();
if (ans !== "y" && ans !== "yes") return console.error(dim("Cancelled."));
const res = await mk.registerDomain({ domain, contact, years });
return out(res, flags, (r) => {
console.log(green("✓ ") + `Registered ${bold(r.domain?.domain || domain)}${r.registration?.status ? dim(` (${r.registration.status})`) : ""}`);
if (r.dnsProvisioned) console.log(green("✓ ") + "Mail DNS provisioned automatically.");
else printDns(r.dns);
});
}
die(`Unknown subcommand: domains ${sub}`);
};
function printDns(records, hintProvider) {
if (!Array.isArray(records)) return;
console.log("\n" + bold("DNS records to add at your DNS provider:"));
for (const r of records) {
const pri = r.priority != null ? ` priority=${r.priority}` : "";
console.log(` ${cyan(r.type.padEnd(4))} ${r.name}\n → ${r.value}${pri}`);
}
const hint = PROVIDER_HINTS[(hintProvider || "").toLowerCase()];
if (hint) console.log("\n" + dim(hint));
console.log(dim("\nThen run: mailkite domains verify <id>"));
}
commands.dns = async ({ _, flags }) => {
const mk = client(flags);
const idOrDomain = await need(_[0] || flags.domain, "domain", "Domain id: ");
const res = await mk.getDomain(idOrDomain);
out(res.dns, flags, (dns) => printDns(dns, flags.provider));
};
commands.webhook = async ({ _, flags }) => {
const sub = _[0] || "show";
const mk = client(flags);
const id = await need(_[1] || flags.id || flags.domain, "domain id", "Domain id: ");
if (sub === "set") {
const url = await need(_[2] || flags.url, "url", "Webhook URL (https://…): ");
const res = await mk.setWebhook(id, { url });
return out(res, flags, (r) => console.log(green("✓ ") + `Webhook set → ${bold(r.webhookUrl)}`));
}
if (sub === "rm" || sub === "delete") {
return out(await mk.deleteWebhook(id), flags, () => console.log(green("✓ ") + "Webhook removed."));
}
if (sub === "test") {
const res = await mk.testWebhook(id);
return out(res, flags, (r) => console.log((r.ok ? green("✓ ") : red("✗ ")) + `Test event delivered — HTTP ${r.status}`));
}
if (sub === "show") {
const d = await mk.getDomain(id);
return out({ webhookUrl: d.domain?.webhookUrl ?? null }, flags, (r) => console.log(r.webhookUrl || dim("No webhook set.")));
}
// One webhook, all events: opt the domain's inbound webhook into engagement events
// (signed email.sent/bounced/complained/opened/clicked) — "all" or a comma-separated list.
if (sub === "events") {
const raw = await need(_[2] || flags.events, "events", 'Events ("all" or comma-separated email.* types): ');
const events = raw === "all" ? "all" : raw.split(",").map((s) => s.trim()).filter(Boolean);
const res = await mk.setWebhookEvents(id, { events });
return out(res, flags, (r) => console.log(green("✓ ") + `Webhook events set → ${bold(r.domain?.webhook_events || JSON.stringify(events))}`));
}
if (sub === "rm-events" || sub === "delete-events") {
return out(await mk.deleteWebhookEvents(id), flags, () => console.log(green("✓ ") + "Webhook events opt-in removed (inbound only)."));
}
// Split-endpoint override: engagement events at a dedicated URL instead of the inbound webhook.
if (sub === "set-tracking") {
const url = await need(_[2] || flags.url, "url", "Tracking webhook URL (https://…): ");
const res = await mk.setTrackingWebhook(id, { url });
return out(res, flags, (r) => console.log(green("✓ ") + `Tracking webhook set → ${bold(r.domain?.tracking_webhook_url || url)}`));
}
if (sub === "rm-tracking" || sub === "delete-tracking") {
return out(await mk.deleteTrackingWebhook(id), flags, () => console.log(green("✓ ") + "Tracking webhook removed."));
}
die(`Unknown subcommand: webhook ${sub}`);
};
commands.secret = async ({ _, flags }) => {
const sub = _[0] || "get";
const mk = client(flags);
if (sub === "get") return out(await mk.request("GET", "/api/webhooks/secret"), flags, (r) => console.log(r.secret));
if (sub === "rotate") return out(await mk.request("POST", "/api/webhooks/secret/rotate"), flags, (r) => console.log(green("✓ ") + r.secret));
die(`Unknown subcommand: secret ${sub}`);
};
commands.routes = async ({ _, flags }) => {
const sub = _[0] || "list";
const mk = client(flags);
if (sub === "list") {
return out(await mk.listRoutes(), flags, (list) => {
if (!Array.isArray(list) || !list.length) return console.log(dim("No routes."));
for (const r of list) console.log(`${bold(r.match_pattern)} → ${r.action}${r.destination ? " " + dim(r.destination) : ""}`);
});
}
if (sub === "create") {
const match = await need(flags.match, "match", "Match (e.g. support@you.dev): ");
const action = await need(flags.action, "action", "Action (webhook|forward|store|drop): ");
const destination = flags.destination || "";
const res = await mk.createRoute({ match, action, destination });
return out(res, flags, (r) => console.log(green("✓ ") + `Route created ${dim(r.id || "")}`));
}
die(`Unknown subcommand: routes ${sub}`);
};
commands.messages = async ({ _, flags }) => {
const sub = _[0] || "list";
const mk = client(flags);
if (sub === "list") {
// Optional newest-first paging: --before <received_at cursor> --limit <n>.
return out(await mk.listMessages(flags.before, flags.limit), flags, (list) => {
if (!Array.isArray(list) || !list.length) return console.log(dim("No messages yet."));
for (const m of list) console.log(`${dim(new Date(m.received_at).toISOString())} ${bold(m.from_addr)} → ${m.to_addr} ${m.subject || dim("(no subject)")}`);
});
}
if (sub === "get") {
const id = await need(_[1] || flags.id, "id", "Message id: ");
return out(await mk.getMessage(id), flags);
}
if (sub === "tail") return tailMessages(mk, flags);
die(`Unknown subcommand: messages ${sub}`);
};
// Poll /api/messages and print new arrivals — how the CLI (and the AI skill)
// confirm an inbound email was received, with no public webhook needed.
async function tailMessages(mk, flags) {
const intervalMs = Number(flags.interval || 2000);
const timeoutMs = flags.timeout ? Number(flags.timeout) * 1000 : 0;
const matchSubject = flags.subject ? String(flags.subject) : null;
const started = Date.now();
const seen = new Set();
// Seed with existing ids unless --all, so we only surface genuinely new mail.
if (!flags.all) {
try { for (const m of await mk.listMessages()) seen.add(m.id); } catch {}
}
if (!flags.json) console.error(dim(`Waiting for inbound mail… (poll ${intervalMs}ms${timeoutMs ? `, timeout ${timeoutMs / 1000}s` : ""})`));
while (true) {
let list = [];
try { list = await mk.listMessages(); } catch (e) { if (!flags.json) console.error(red("poll error: ") + e.message); }
for (const m of Array.isArray(list) ? list.slice().reverse() : []) {
if (seen.has(m.id)) continue;
seen.add(m.id);
if (matchSubject && !(m.subject || "").includes(matchSubject)) continue;
if (flags.json) console.log(JSON.stringify(m));
else console.log(green("● ") + `${bold(m.from_addr)} → ${m.to_addr} ${m.subject || dim("(no subject)")} ${dim(m.id)}`);
if (flags.once) return;
}
if (timeoutMs && Date.now() - started > timeoutMs) {
if (!flags.json) console.error(dim("timeout reached."));
process.exit(flags["fail-on-timeout"] ? 2 : 0);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
commands.deliveries = async ({ _, flags }) => {
const sub = _[0];
const mk = client(flags);
if (sub === "retry") {
const id = await need(_[1] || flags.id, "id", "Delivery id: ");
return out(await mk.retryDelivery(id), flags, (r) => console.log((r.ok ? green("✓ ") : red("✗ ")) + `Retried — HTTP ${r.status}`));
}
die(`Unknown subcommand: deliveries ${sub || ""}`);
};
// Verify an x-mailkite-signature header locally (no network) via the SDK helper.
commands["verify-webhook"] = async ({ flags }) => {
const signature = await need(flags.signature, "signature", "x-mailkite-signature value: ");
const secret = await need(flags.secret, "secret", "Webhook secret (whsec_…): ");
let payload = flags.payload;
if (flags.file) payload = readFileSync(flags.file, "utf8");
if (payload == null) payload = await need("", "payload", "Raw request body: ");
const tolerance = flags.tolerance != null ? Number(flags.tolerance) : undefined;
const valid = MailKite.verifyWebhook(signature, payload, secret, tolerance);
out({ valid }, flags, () => console.log(valid ? green("✓ valid signature") : red("✗ invalid signature")));
if (!valid) process.exit(3);
};
// Launch the MailKite MCP server (stdio), passing the stored token through.
// Long-running: we await the child so the CLI stays up for the duration.
commands.mcp = async ({ flags }) => {
const token = resolveToken(flags);
const env = { ...process.env };
if (token) env.MAILKITE_API_KEY = token;
env.MAILKITE_BASE_URL = resolveBaseUrl(flags);
const child = spawn("npx", ["-y", "@mailkite/mcp"], { stdio: "inherit", env });
await new Promise((resolve) => child.on("exit", (code) => { process.exitCode = code ?? 0; resolve(); }));
};
// Onboarding wizard: login → add domain → DNS → verify → webhook → test send.
// Scriptable: pass --email/--password/--domain/--webhook/--to to run unattended.
commands.init = async ({ flags }) => {
console.error(bold("MailKite setup\n"));
// 1. Auth
if (!resolveToken(flags)) {
console.error("1. Sign in");
await (flags.signup ? commands.signup : commands.login)({ _: [], flags });
} else {
console.error(green("1. ✓ Already signed in"));
}
const mk = client(flags);
// 2. Domain
const domain = await need(flags.domain, "domain", "\n2. Domain to add (e.g. mail.yourapp.com): ");
let dom;
const existing = (await mk.listDomains().catch(() => [])).find?.((d) => d.domain === domain);
if (existing) { dom = existing; console.error(green(` ✓ ${domain} already added (${dom.id})`)); const full = await mk.getDomain(dom.id); printDns(full.dns, flags.provider); }
else { const r = await mk.createDomain({ domain }); dom = r.domain; console.error(green(` ✓ Added ${domain}`)); printDns(r.dns, flags.provider); }
// 3. Verify (optional wait)
if (flags.verify || flags.wait) {
console.error("\n3. Verifying DNS…");
const deadline = Date.now() + (flags.wait ? Number(flags.wait) * 1000 : 0);
let v;
do {
v = await mk.verifyDomain(dom.id);
if (v.status === "verified") break;
if (Date.now() < deadline) await new Promise((r) => setTimeout(r, 5000));
} while (Date.now() < deadline);
console.error(v.status === "verified" ? green(" ✓ Verified") : red(` ✗ Not verified yet (${JSON.stringify(v.checks)}) — DNS can take time.`));
}
// 4. Webhook
if (flags.webhook) {
const res = await mk.setWebhook(dom.id, { url: flags.webhook });
console.error(green(`\n4. ✓ Webhook → ${res.webhookUrl}`));
}
// 5. Test send
if (flags.to) {
const from = flags.from || `hello@${domain}`;
try {
const r = await mk.send({ from, to: flags.to, subject: flags.subject || "MailKite test ✅", text: "It works — sent from the MailKite CLI.", html: "<p>It works — sent from the <strong>MailKite CLI</strong>.</p>" });
console.error(green(`\n5. ✓ Test email sent (id ${r.id})`));
} catch (e) { console.error(red(`\n5. ✗ Send failed: ${e.message}`)); }
}
console.error(bold("\nDone.") + " Next: set DNS at your provider, then `mailkite domains verify " + dom.id + "`.");
};
commands.version = async ({ flags }) => out({ version: VERSION }, flags, () => console.log(`mailkite/${VERSION} node-${process.version}`));
// ---- help -------------------------------------------------------------------
const HELP = `${bold("mailkite")} — MailKite command-line client ${dim("v" + VERSION)}
${bold("USAGE")}
mailkite <command> [subcommand] [--flags]
${bold("AUTH")}
login Sign in via browser (OAuth); saves the token. This is the way to
get an admin session. Use --password for email/password instead
signup Create an account and sign in
register Create an account with just an email; saves the API key (--email [--channel --ref])
Sending stays blocked until you click the verification link
logout Remove the saved token
whoami Show the signed-in account, plan, verification state, API base, token expiry
${bold("ACCOUNT")}
keys Account API key (show | rotate | scoped | create --domain-id <dom_…> [--name] | rm <key_…>)
passwords App passwords (list | create --domain <acme.dev> [--address "support-*"] [--protocols imap,api] | rm <apw_…>)
usage This period's email/AI usage vs the plan's included bucket
suppressions Do-not-send list (list | add <email> [--reason --note] | rm <email>)
${bold("SEND")}
send Send a message (--from --to --subject [--html|--text|--file] [--cc --bcc --reply-to --in-reply-to] [--template-id --template-data '{...}'] [--dry-run])
send-batch Send one personalized message per recipient (--file batch.json | --body '<json>' [--dry-run])
${bold("AGENTS")}
agent <text> Message an inbox agent and print its reply ([--route-id|--address] [--model --subject --from])
route Route a message to a registered route (--route-id <id>|--address <addr> --from [--subject --text|--file --html])
${bold("DOMAINS & DNS")}
domains list List your domains
domains add <domain> Add a domain you own; prints the DNS records to set
domains check <domain> Check if a domain is available to register (+ price)
domains register <domain> Buy a domain; provisions DNS + adds it (--first-name --last-name
--email --phone --address --city --zip --country [--state --org --years --yes])
domains get <id> Show a domain (with DNS + webhook)
domains verify <id> Re-check DNS (MX/SPF/DKIM/DMARC)
domains rm <id> Remove a domain
dns <id> [--provider cf|godaddy|…] Print DNS records (+ provider hint)
${bold("WEBHOOKS & RECEIVING")}
webhook set <id> <url> Set the domain's catch-all webhook
webhook events <id> <list> Opt the inbound webhook into engagement events ("all" or email.sent,email.bounced,…)
webhook rm-events <id> Opt back out of engagement events (inbound only)
webhook set-tracking <id> <url> Split-endpoint override: engagement events at a dedicated URL
webhook rm-tracking <id> Remove the tracking-event webhook
webhook rm <id> Remove the webhook
webhook test <id> Send a signed test event to the webhook
secret get | rotate The account webhook signing secret (whsec_…)
verify-webhook --signature --secret [--file|--payload] Verify a signature locally
messages list | get <id> Stored inbound messages
messages tail [--once --timeout N --subject TXT --json] Wait for new inbound mail
routes list | create --match --action --destination
${bold("DELIVERIES")}
deliveries retry <id> Re-deliver a stored message to its webhook
${bold("WORKFLOW")}
init [--email --password --domain --webhook --to --verify --wait N]
End-to-end setup wizard (scriptable for agents)
mcp Run the MailKite MCP server (stdio) with your token
${bold("GLOBAL FLAGS")}
--json Machine-readable JSON output (great for scripts/agents)
--token <t> Bearer token (overrides env + config)
--base-url <u> API base URL (default ${DEFAULT_BASE_URL})
-h, --help Show help -v, --version Show version
${bold("ENV")} MAILKITE_API_KEY / MAILKITE_TOKEN, MAILKITE_BASE_URL
${bold("CONFIG")} ${CONFIG_FILE}
`;
// ---- main -------------------------------------------------------------------
async function main() {
const { _, flags } = parseArgs(process.argv.slice(2));
if (flags.version && !_.length) return commands.version({ _, flags });
const cmd = _.shift();
if (!cmd || flags.help && !cmd) { console.log(HELP); return; }
const handler = commands[cmd];
if (!handler) { console.error(red(`Unknown command: ${cmd}\n`)); console.log(HELP); process.exit(1); }
if (flags.help) { console.log(HELP); return; }
try {
await handler({ _, flags });
} catch (e) {
if (e instanceof MailKiteError) die(`API error ${e.status}: ${e.message}`);
die(e?.stack || e?.message || String(e));
}
// The SDK uses fetch (undici), whose keep-alive sockets keep the event loop
// alive for a few seconds after the work is done. Give stdout a brief tick to
// drain (matters when piped), then exit promptly so commands return at once and
// stay snappy for scripts/agents — regardless of TTY vs pipe.
await new Promise((r) => setTimeout(r, 25));
process.exit(process.exitCode || 0);
}
main();