@mailkite/cli 0.1.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 (3) hide show
  1. package/README.md +96 -0
  2. package/cli.mjs +529 -0
  3. package/package.json +40 -0
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @mailkite/cli
2
+
3
+ The MailKite command-line client — a wrangler/vercel-style terminal tool for
4
+ [MailKite](https://mailkite.dev). Sign in, add domains, set DNS + webhooks, send
5
+ mail, and tail inbound messages, all from your shell.
6
+
7
+ It's a **thin layer over the [MailKite Node SDK](../node)**: every network call
8
+ goes through the `MailKite` client, so auth, base URL, and error handling live in
9
+ one place. The CLI adds token storage, interactive flows, and a one-shot setup
10
+ wizard on top.
11
+
12
+ ```bash
13
+ npx @mailkite/cli --help # no install needed
14
+ npm i -g @mailkite/cli # or install the `mailkite` binary globally
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```bash
20
+ mailkite signup --email you@example.com --password •••••• # or: login
21
+ mailkite domains add mail.yourapp.com # prints the DNS records
22
+ # …add those records at your DNS provider…
23
+ mailkite domains verify <domainId> # MX/SPF/DKIM/DMARC
24
+ mailkite webhook set <domainId> https://yourapp.com/hooks/mailkite
25
+ mailkite send --from hello@mail.yourapp.com --to you@example.com \
26
+ --subject "It works" --html "<p>Hi from MailKite</p>"
27
+ mailkite messages tail # watch inbound arrive
28
+ ```
29
+
30
+ Or do the whole flow in one command:
31
+
32
+ ```bash
33
+ mailkite init --email you@example.com --password •••• \
34
+ --domain mail.yourapp.com --provider cloudflare \
35
+ --webhook https://yourapp.com/hooks/mailkite --to you@example.com --verify
36
+ ```
37
+
38
+ ## Built for scripts and agents
39
+
40
+ - **Fully non-interactive.** Every command works from flags + env; prompts only
41
+ appear as a fallback on an interactive TTY. Missing a required flag in a
42
+ non-TTY context fails loudly instead of hanging.
43
+ - **`--json` everywhere** for machine-readable output.
44
+ - **Snappy.** Commands exit immediately when done.
45
+
46
+ ```bash
47
+ ID=$(mailkite domains add mail.app.com --json | jq -r .domain.id)
48
+ mailkite domains verify "$ID" --json | jq -e '.status=="verified"'
49
+ ```
50
+
51
+ ## Commands
52
+
53
+ | Group | Commands |
54
+ | --- | --- |
55
+ | Auth | `login`, `signup`, `logout`, `whoami` |
56
+ | Send | `send` |
57
+ | Domains | `domains list\|add\|get\|verify\|rm`, `dns <id> [--provider]` |
58
+ | Webhooks | `webhook set\|rm\|test\|show`, `secret get\|rotate`, `verify-webhook` |
59
+ | Receiving | `messages list\|get\|tail`, `routes list\|create`, `deliveries retry` |
60
+ | Workflow | `init` (setup wizard), `mcp` (run the MCP server) |
61
+
62
+ Run `mailkite <command> --help` or just `mailkite` for the full reference.
63
+
64
+ ## Auth & config
65
+
66
+ The CLI uses **one bearer token** for everything (sending and management) — the
67
+ account token from `login`/`signup`. Resolution order:
68
+
69
+ 1. `--token <t>` flag
70
+ 2. `MAILKITE_API_KEY` / `MAILKITE_TOKEN` env
71
+ 3. `~/.mailkite/config.json` (written by `login`/`signup`, mode `600`)
72
+
73
+ Base URL: `--base-url` › `MAILKITE_BASE_URL` › config › `https://api.mailkite.dev`.
74
+
75
+ ## Receiving without a public URL
76
+
77
+ `messages tail` polls the stored-messages API and prints new arrivals — so you can
78
+ confirm an inbound round-trip (send → receive) with no tunnel:
79
+
80
+ ```bash
81
+ mailkite messages tail --once --subject "test" --timeout 120 --json
82
+ ```
83
+
84
+ To verify webhook signatures locally (no network), use `verify-webhook` or the
85
+ SDK's `verifyWebhook` helper.
86
+
87
+ ## Develop
88
+
89
+ ```bash
90
+ npm install
91
+ npm test # drives the commands against a mock API + checks local signature verify
92
+ ```
93
+
94
+ ## License
95
+
96
+ MIT
package/cli.mjs ADDED
@@ -0,0 +1,529 @@
1
+ #!/usr/bin/env node
2
+ // MailKite CLI — a wrangler-style terminal client for MailKite.
3
+ //
4
+ // It is a thin layer over the MailKite Node SDK (sdks/node): every network call
5
+ // goes through the `MailKite` class, so auth, base URL, and error handling stay
6
+ // in one place. Auth/token storage and the interactive flows are the CLI's own.
7
+ //
8
+ // Designed to be fully scriptable for AI agents: every command works from flags
9
+ // + env with no prompts (prompts only appear as a fallback on an interactive TTY),
10
+ // and every data command supports `--json` for machine-readable output.
11
+
12
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import path from "node:path";
15
+ import readline from "node:readline";
16
+ import { spawn } from "node:child_process";
17
+ import { MailKite, MailKiteError } from "mailkite";
18
+
19
+ const VERSION = "0.1.0";
20
+ const CONFIG_DIR = path.join(homedir(), ".mailkite");
21
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
22
+ const DEFAULT_BASE_URL = "https://api.mailkite.dev";
23
+
24
+ // ---- tiny arg parser --------------------------------------------------------
25
+ // Supports: positionals, --flag, --key value, --key=value, -h/-v shortcuts.
26
+ function parseArgs(argv) {
27
+ const _ = [];
28
+ const flags = {};
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const a = argv[i];
31
+ if (a === "-h") { flags.help = true; continue; }
32
+ if (a === "-v") { flags.version = true; continue; }
33
+ if (a.startsWith("--")) {
34
+ const eq = a.indexOf("=");
35
+ if (eq !== -1) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
36
+ const key = a.slice(2);
37
+ const next = argv[i + 1];
38
+ if (next === undefined || next.startsWith("--")) { flags[key] = true; }
39
+ else { flags[key] = next; i++; }
40
+ } else {
41
+ _.push(a);
42
+ }
43
+ }
44
+ return { _, flags };
45
+ }
46
+
47
+ // ---- output helpers ---------------------------------------------------------
48
+ const isTTY = process.stdout.isTTY;
49
+ const c = (code, s) => (isTTY ? `\x1b[${code}m${s}\x1b[0m` : s);
50
+ const bold = (s) => c("1", s);
51
+ const dim = (s) => c("2", s);
52
+ const green = (s) => c("32", s);
53
+ const red = (s) => c("31", s);
54
+ const cyan = (s) => c("36", s);
55
+
56
+ function out(data, flags, human) {
57
+ if (flags.json) { console.log(JSON.stringify(data, null, 2)); return; }
58
+ if (typeof human === "function") human(data);
59
+ else console.log(typeof data === "string" ? data : JSON.stringify(data, null, 2));
60
+ }
61
+
62
+ function die(msg, code = 1) {
63
+ console.error(red("✗ ") + msg);
64
+ process.exit(code);
65
+ }
66
+
67
+ // ---- config -----------------------------------------------------------------
68
+ function loadConfig() {
69
+ try { return JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch { return {}; }
70
+ }
71
+ function saveConfig(cfg) {
72
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
73
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
74
+ }
75
+
76
+ function resolveToken(flags) {
77
+ return (
78
+ flags.token ||
79
+ process.env.MAILKITE_API_KEY ||
80
+ process.env.MAILKITE_TOKEN ||
81
+ loadConfig().token ||
82
+ ""
83
+ );
84
+ }
85
+ function resolveBaseUrl(flags) {
86
+ return flags["base-url"] || process.env.MAILKITE_BASE_URL || loadConfig().baseUrl || DEFAULT_BASE_URL;
87
+ }
88
+
89
+ // A client for endpoints that need auth. Errors clearly if no token is present.
90
+ function client(flags, { requireToken = true } = {}) {
91
+ const token = resolveToken(flags);
92
+ if (requireToken && !token) {
93
+ die("Not signed in. Run `mailkite login` (or set MAILKITE_API_KEY / pass --token).");
94
+ }
95
+ return new MailKite(token, resolveBaseUrl(flags));
96
+ }
97
+
98
+ // ---- interactive prompt (TTY fallback only) ---------------------------------
99
+ function prompt(question, { hidden = false } = {}) {
100
+ if (!process.stdin.isTTY) return Promise.resolve("");
101
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
102
+ return new Promise((resolve) => {
103
+ if (hidden) {
104
+ const onData = (ch) => {
105
+ const s = ch.toString();
106
+ if (s === "\n" || s === "\r" || s === "") return;
107
+ process.stdout.write("*");
108
+ };
109
+ process.stdin.on("data", onData);
110
+ rl.question(question, (ans) => { process.stdin.off("data", onData); process.stdout.write("\n"); rl.close(); resolve(ans.trim()); });
111
+ } else {
112
+ rl.question(question, (ans) => { rl.close(); resolve(ans.trim()); });
113
+ }
114
+ });
115
+ }
116
+
117
+ async function need(value, label, promptText, opts) {
118
+ if (value) return value;
119
+ const v = await prompt(promptText, opts);
120
+ if (!v) die(`${label} is required (pass it as a flag for non-interactive use).`);
121
+ return v;
122
+ }
123
+
124
+ const toList = (v) => (Array.isArray(v) ? v : String(v).split(",").map((s) => s.trim()).filter(Boolean));
125
+
126
+ // Decode a JWT payload (no verification — just to show who you are locally).
127
+ function decodeJwt(token) {
128
+ try {
129
+ const [, payload] = token.split(".");
130
+ return JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
131
+ } catch { return null; }
132
+ }
133
+
134
+ // Concise per-provider DNS hint (full playbooks live in the AI skill).
135
+ const PROVIDER_HINTS = {
136
+ cloudflare: "Cloudflare: Dashboard → DNS → Records, or `npx wrangler` / API POST /zones/:zone/dns_records. Set proxy=DNS-only (grey cloud) for MX.",
137
+ godaddy: "GoDaddy: Domain Portfolio → DNS → Add, or API PATCH /v1/domains/:domain/records.",
138
+ namecheap: "Namecheap: Domain List → Manage → Advanced DNS → Add New Record.",
139
+ route53: "Route 53: Hosted zone → Create record, or `aws route53 change-resource-record-sets`.",
140
+ };
141
+
142
+ // =============================================================================
143
+ // Commands
144
+ // =============================================================================
145
+ const commands = {};
146
+
147
+ commands.login = async ({ flags }) => {
148
+ const email = await need(flags.email, "email", "Email: ");
149
+ const password = await need(flags.password, "password", "Password: ", { hidden: true });
150
+ const mk = new MailKite("", resolveBaseUrl(flags));
151
+ let res;
152
+ try { res = await mk.request("POST", "/api/auth/login", { email, password }); }
153
+ catch (e) { return die(e instanceof MailKiteError ? `Login failed (${e.status}): ${e.message}` : e.message); }
154
+ const cfg = loadConfig();
155
+ saveConfig({ ...cfg, token: res.token, baseUrl: resolveBaseUrl(flags) });
156
+ out(res, flags, () => console.log(green("✓ ") + `Signed in as ${bold(res.user.email)}. Token saved to ${dim(CONFIG_FILE)}`));
157
+ };
158
+
159
+ commands.signup = async ({ flags }) => {
160
+ const email = await need(flags.email, "email", "Email: ");
161
+ const password = await need(flags.password, "password", "Password: ", { hidden: true });
162
+ const mk = new MailKite("", resolveBaseUrl(flags));
163
+ let res;
164
+ try { res = await mk.request("POST", "/api/auth/signup", { email, password }); }
165
+ catch (e) { return die(e instanceof MailKiteError ? `Signup failed (${e.status}): ${e.message}` : e.message); }
166
+ saveConfig({ ...loadConfig(), token: res.token, baseUrl: resolveBaseUrl(flags) });
167
+ out(res, flags, () => console.log(green("✓ ") + `Account created for ${bold(res.user.email)}. Signed in.`));
168
+ };
169
+
170
+ commands.logout = async ({ flags }) => {
171
+ const cfg = loadConfig();
172
+ delete cfg.token;
173
+ saveConfig(cfg);
174
+ out({ ok: true }, flags, () => console.log(green("✓ ") + "Signed out (token removed)."));
175
+ };
176
+
177
+ commands.whoami = async ({ flags }) => {
178
+ const token = resolveToken(flags);
179
+ if (!token) return die("Not signed in.");
180
+ const claims = decodeJwt(token);
181
+ const info = {
182
+ userId: claims?.sub ?? null,
183
+ baseUrl: resolveBaseUrl(flags),
184
+ tokenExpires: claims?.exp ? new Date(claims.exp * 1000).toISOString() : null,
185
+ source: flags.token ? "flag" : process.env.MAILKITE_API_KEY ? "MAILKITE_API_KEY" : process.env.MAILKITE_TOKEN ? "MAILKITE_TOKEN" : "config",
186
+ };
187
+ out(info, flags, (i) => {
188
+ console.log(`${bold("user")} ${i.userId ?? dim("unknown")}`);
189
+ console.log(`${bold("api")} ${i.baseUrl}`);
190
+ console.log(`${bold("expires")} ${i.tokenExpires ?? dim("n/a")} ${dim("(" + i.source + ")")}`);
191
+ });
192
+ };
193
+
194
+ commands.send = async ({ flags }) => {
195
+ const mk = client(flags);
196
+ const from = await need(flags.from, "from", "From (you@your-verified-domain): ");
197
+ const to = await need(flags.to, "to", "To: ");
198
+ const subject = await need(flags.subject, "subject", "Subject: ");
199
+ let html = flags.html;
200
+ let text = flags.text;
201
+ if (flags.file) {
202
+ const content = readFileSync(flags.file, "utf8");
203
+ if (flags.file.endsWith(".html") || flags.file.endsWith(".htm")) html = content;
204
+ else text = content;
205
+ }
206
+ if (!html && !text) text = await need("", "body", "Body (text): ");
207
+ const message = { from, to: toList(to).length > 1 ? toList(to) : to, subject };
208
+ if (html) message.html = html;
209
+ if (text) message.text = text;
210
+ if (flags.cc) message.cc = toList(flags.cc);
211
+ if (flags.bcc) message.bcc = toList(flags.bcc);
212
+ if (flags["reply-to"]) message.replyTo = flags["reply-to"];
213
+ if (flags["in-reply-to"]) message.inReplyTo = flags["in-reply-to"];
214
+ let res;
215
+ try { res = await mk.send(message); }
216
+ catch (e) { return die(e instanceof MailKiteError ? `Send failed (${e.status}): ${e.message}` : e.message); }
217
+ out(res, flags, (r) => console.log(green("✓ ") + `Sent — id ${bold(r.id)}, status ${r.status}`));
218
+ };
219
+
220
+ commands.domains = async ({ _, flags }) => {
221
+ const sub = _[0] || "list";
222
+ const mk = client(flags);
223
+ if (sub === "list") {
224
+ const rows = await mk.listDomains();
225
+ return out(rows, flags, (list) => {
226
+ if (!Array.isArray(list) || !list.length) return console.log(dim("No domains yet. Add one: mailkite domains add <domain>"));
227
+ for (const d of list) console.log(`${bold(d.domain)} ${dim(d.id)} ${d.status === "verified" ? green(d.status) : d.status}`);
228
+ });
229
+ }
230
+ if (sub === "add") {
231
+ const domain = await need(_[1] || flags.domain, "domain", "Domain to add: ");
232
+ const res = await mk.createDomain({ domain });
233
+ return out(res, flags, (r) => { console.log(green("✓ ") + `Added ${bold(r.domain.domain)} (${dim(r.domain.id)})`); printDns(r.dns); });
234
+ }
235
+ if (sub === "get") {
236
+ const id = await need(_[1] || flags.id, "id", "Domain id: ");
237
+ return out(await mk.getDomain(id), flags);
238
+ }
239
+ if (sub === "verify") {
240
+ const id = await need(_[1] || flags.id, "id", "Domain id: ");
241
+ const res = await mk.verifyDomain(id);
242
+ return out(res, flags, (r) => {
243
+ const mark = (b) => (b ? green("✓") : red("✗"));
244
+ console.log(`status: ${r.status === "verified" ? green(r.status) : r.status}`);
245
+ console.log(` MX ${mark(r.checks?.mx)} SPF ${mark(r.checks?.spf)} DKIM ${mark(r.checks?.dkim)} DMARC ${mark(r.checks?.dmarc)}`);
246
+ });
247
+ }
248
+ if (sub === "rm" || sub === "delete") {
249
+ const id = await need(_[1] || flags.id, "id", "Domain id: ");
250
+ return out(await mk.deleteDomain(id), flags, () => console.log(green("✓ ") + "Domain removed."));
251
+ }
252
+ die(`Unknown subcommand: domains ${sub}`);
253
+ };
254
+
255
+ function printDns(records, hintProvider) {
256
+ if (!Array.isArray(records)) return;
257
+ console.log("\n" + bold("DNS records to add at your DNS provider:"));
258
+ for (const r of records) {
259
+ const pri = r.priority != null ? ` priority=${r.priority}` : "";
260
+ console.log(` ${cyan(r.type.padEnd(4))} ${r.name}\n → ${r.value}${pri}`);
261
+ }
262
+ const hint = PROVIDER_HINTS[(hintProvider || "").toLowerCase()];
263
+ if (hint) console.log("\n" + dim(hint));
264
+ console.log(dim("\nThen run: mailkite domains verify <id>"));
265
+ }
266
+
267
+ commands.dns = async ({ _, flags }) => {
268
+ const mk = client(flags);
269
+ const idOrDomain = await need(_[0] || flags.domain, "domain", "Domain id: ");
270
+ const res = await mk.getDomain(idOrDomain);
271
+ out(res.dns, flags, (dns) => printDns(dns, flags.provider));
272
+ };
273
+
274
+ commands.webhook = async ({ _, flags }) => {
275
+ const sub = _[0] || "show";
276
+ const mk = client(flags);
277
+ const id = await need(_[1] || flags.id || flags.domain, "domain id", "Domain id: ");
278
+ if (sub === "set") {
279
+ const url = await need(_[2] || flags.url, "url", "Webhook URL (https://…): ");
280
+ const res = await mk.setWebhook(id, { url });
281
+ return out(res, flags, (r) => console.log(green("✓ ") + `Webhook set → ${bold(r.webhookUrl)}`));
282
+ }
283
+ if (sub === "rm" || sub === "delete") {
284
+ return out(await mk.deleteWebhook(id), flags, () => console.log(green("✓ ") + "Webhook removed."));
285
+ }
286
+ if (sub === "test") {
287
+ const res = await mk.testWebhook(id);
288
+ return out(res, flags, (r) => console.log((r.ok ? green("✓ ") : red("✗ ")) + `Test event delivered — HTTP ${r.status}`));
289
+ }
290
+ if (sub === "show") {
291
+ const d = await mk.getDomain(id);
292
+ return out({ webhookUrl: d.domain?.webhookUrl ?? null }, flags, (r) => console.log(r.webhookUrl || dim("No webhook set.")));
293
+ }
294
+ die(`Unknown subcommand: webhook ${sub}`);
295
+ };
296
+
297
+ commands.secret = async ({ _, flags }) => {
298
+ const sub = _[0] || "get";
299
+ const mk = client(flags);
300
+ if (sub === "get") return out(await mk.request("GET", "/api/webhooks/secret"), flags, (r) => console.log(r.secret));
301
+ if (sub === "rotate") return out(await mk.request("POST", "/api/webhooks/secret/rotate"), flags, (r) => console.log(green("✓ ") + r.secret));
302
+ die(`Unknown subcommand: secret ${sub}`);
303
+ };
304
+
305
+ commands.routes = async ({ _, flags }) => {
306
+ const sub = _[0] || "list";
307
+ const mk = client(flags);
308
+ if (sub === "list") {
309
+ return out(await mk.listRoutes(), flags, (list) => {
310
+ if (!Array.isArray(list) || !list.length) return console.log(dim("No routes."));
311
+ for (const r of list) console.log(`${bold(r.match_pattern)} → ${r.action}${r.destination ? " " + dim(r.destination) : ""}`);
312
+ });
313
+ }
314
+ if (sub === "create") {
315
+ const match = await need(flags.match, "match", "Match (e.g. support@you.dev): ");
316
+ const action = await need(flags.action, "action", "Action (webhook|forward|store|drop): ");
317
+ const destination = flags.destination || "";
318
+ const res = await mk.createRoute({ match, action, destination });
319
+ return out(res, flags, (r) => console.log(green("✓ ") + `Route created ${dim(r.id || "")}`));
320
+ }
321
+ die(`Unknown subcommand: routes ${sub}`);
322
+ };
323
+
324
+ commands.messages = async ({ _, flags }) => {
325
+ const sub = _[0] || "list";
326
+ const mk = client(flags);
327
+ if (sub === "list") {
328
+ return out(await mk.listMessages(), flags, (list) => {
329
+ if (!Array.isArray(list) || !list.length) return console.log(dim("No messages yet."));
330
+ 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)")}`);
331
+ });
332
+ }
333
+ if (sub === "get") {
334
+ const id = await need(_[1] || flags.id, "id", "Message id: ");
335
+ return out(await mk.getMessage(id), flags);
336
+ }
337
+ if (sub === "tail") return tailMessages(mk, flags);
338
+ die(`Unknown subcommand: messages ${sub}`);
339
+ };
340
+
341
+ // Poll /api/messages and print new arrivals — how the CLI (and the AI skill)
342
+ // confirm an inbound email was received, with no public webhook needed.
343
+ async function tailMessages(mk, flags) {
344
+ const intervalMs = Number(flags.interval || 2000);
345
+ const timeoutMs = flags.timeout ? Number(flags.timeout) * 1000 : 0;
346
+ const matchSubject = flags.subject ? String(flags.subject) : null;
347
+ const started = Date.now();
348
+ const seen = new Set();
349
+ // Seed with existing ids unless --all, so we only surface genuinely new mail.
350
+ if (!flags.all) {
351
+ try { for (const m of await mk.listMessages()) seen.add(m.id); } catch {}
352
+ }
353
+ if (!flags.json) console.error(dim(`Waiting for inbound mail… (poll ${intervalMs}ms${timeoutMs ? `, timeout ${timeoutMs / 1000}s` : ""})`));
354
+ while (true) {
355
+ let list = [];
356
+ try { list = await mk.listMessages(); } catch (e) { if (!flags.json) console.error(red("poll error: ") + e.message); }
357
+ for (const m of Array.isArray(list) ? list.slice().reverse() : []) {
358
+ if (seen.has(m.id)) continue;
359
+ seen.add(m.id);
360
+ if (matchSubject && !(m.subject || "").includes(matchSubject)) continue;
361
+ if (flags.json) console.log(JSON.stringify(m));
362
+ else console.log(green("● ") + `${bold(m.from_addr)} → ${m.to_addr} ${m.subject || dim("(no subject)")} ${dim(m.id)}`);
363
+ if (flags.once) return;
364
+ }
365
+ if (timeoutMs && Date.now() - started > timeoutMs) {
366
+ if (!flags.json) console.error(dim("timeout reached."));
367
+ process.exit(flags["fail-on-timeout"] ? 2 : 0);
368
+ }
369
+ await new Promise((r) => setTimeout(r, intervalMs));
370
+ }
371
+ }
372
+
373
+ commands.deliveries = async ({ _, flags }) => {
374
+ const sub = _[0];
375
+ const mk = client(flags);
376
+ if (sub === "retry") {
377
+ const id = await need(_[1] || flags.id, "id", "Delivery id: ");
378
+ return out(await mk.retryDelivery(id), flags, (r) => console.log((r.ok ? green("✓ ") : red("✗ ")) + `Retried — HTTP ${r.status}`));
379
+ }
380
+ die(`Unknown subcommand: deliveries ${sub || ""}`);
381
+ };
382
+
383
+ // Verify an x-mailkite-signature header locally (no network) via the SDK helper.
384
+ commands["verify-webhook"] = async ({ flags }) => {
385
+ const signature = await need(flags.signature, "signature", "x-mailkite-signature value: ");
386
+ const secret = await need(flags.secret, "secret", "Webhook secret (whsec_…): ");
387
+ let payload = flags.payload;
388
+ if (flags.file) payload = readFileSync(flags.file, "utf8");
389
+ if (payload == null) payload = await need("", "payload", "Raw request body: ");
390
+ const tolerance = flags.tolerance != null ? Number(flags.tolerance) : undefined;
391
+ const valid = MailKite.verifyWebhook(signature, payload, secret, tolerance);
392
+ out({ valid }, flags, () => console.log(valid ? green("✓ valid signature") : red("✗ invalid signature")));
393
+ if (!valid) process.exit(3);
394
+ };
395
+
396
+ // Launch the MailKite MCP server (stdio), passing the stored token through.
397
+ // Long-running: we await the child so the CLI stays up for the duration.
398
+ commands.mcp = async ({ flags }) => {
399
+ const token = resolveToken(flags);
400
+ const env = { ...process.env };
401
+ if (token) env.MAILKITE_API_KEY = token;
402
+ env.MAILKITE_BASE_URL = resolveBaseUrl(flags);
403
+ const child = spawn("npx", ["-y", "@mailkite/mcp"], { stdio: "inherit", env });
404
+ await new Promise((resolve) => child.on("exit", (code) => { process.exitCode = code ?? 0; resolve(); }));
405
+ };
406
+
407
+ // Onboarding wizard: login → add domain → DNS → verify → webhook → test send.
408
+ // Scriptable: pass --email/--password/--domain/--webhook/--to to run unattended.
409
+ commands.init = async ({ flags }) => {
410
+ console.error(bold("MailKite setup\n"));
411
+ // 1. Auth
412
+ if (!resolveToken(flags)) {
413
+ console.error("1. Sign in");
414
+ await (flags.signup ? commands.signup : commands.login)({ _: [], flags });
415
+ } else {
416
+ console.error(green("1. ✓ Already signed in"));
417
+ }
418
+ const mk = client(flags);
419
+ // 2. Domain
420
+ const domain = await need(flags.domain, "domain", "\n2. Domain to add (e.g. mail.yourapp.com): ");
421
+ let dom;
422
+ const existing = (await mk.listDomains().catch(() => [])).find?.((d) => d.domain === domain);
423
+ if (existing) { dom = existing; console.error(green(` ✓ ${domain} already added (${dom.id})`)); const full = await mk.getDomain(dom.id); printDns(full.dns, flags.provider); }
424
+ else { const r = await mk.createDomain({ domain }); dom = r.domain; console.error(green(` ✓ Added ${domain}`)); printDns(r.dns, flags.provider); }
425
+ // 3. Verify (optional wait)
426
+ if (flags.verify || flags.wait) {
427
+ console.error("\n3. Verifying DNS…");
428
+ const deadline = Date.now() + (flags.wait ? Number(flags.wait) * 1000 : 0);
429
+ let v;
430
+ do {
431
+ v = await mk.verifyDomain(dom.id);
432
+ if (v.status === "verified") break;
433
+ if (Date.now() < deadline) await new Promise((r) => setTimeout(r, 5000));
434
+ } while (Date.now() < deadline);
435
+ console.error(v.status === "verified" ? green(" ✓ Verified") : red(` ✗ Not verified yet (${JSON.stringify(v.checks)}) — DNS can take time.`));
436
+ }
437
+ // 4. Webhook
438
+ if (flags.webhook) {
439
+ const res = await mk.setWebhook(dom.id, { url: flags.webhook });
440
+ console.error(green(`\n4. ✓ Webhook → ${res.webhookUrl}`));
441
+ }
442
+ // 5. Test send
443
+ if (flags.to) {
444
+ const from = flags.from || `hello@${domain}`;
445
+ try {
446
+ 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>" });
447
+ console.error(green(`\n5. ✓ Test email sent (id ${r.id})`));
448
+ } catch (e) { console.error(red(`\n5. ✗ Send failed: ${e.message}`)); }
449
+ }
450
+ console.error(bold("\nDone.") + " Next: set DNS at your provider, then `mailkite domains verify " + dom.id + "`.");
451
+ };
452
+
453
+ commands.version = async ({ flags }) => out({ version: VERSION }, flags, () => console.log(`mailkite/${VERSION} node-${process.version}`));
454
+
455
+ // ---- help -------------------------------------------------------------------
456
+ const HELP = `${bold("mailkite")} — MailKite command-line client ${dim("v" + VERSION)}
457
+
458
+ ${bold("USAGE")}
459
+ mailkite <command> [subcommand] [--flags]
460
+
461
+ ${bold("AUTH")}
462
+ login Sign in with email + password; saves the token
463
+ signup Create an account and sign in
464
+ logout Remove the saved token
465
+ whoami Show the signed-in user, API base, and token expiry
466
+
467
+ ${bold("SEND")}
468
+ send Send a message (--from --to --subject [--html|--text|--file] [--cc --bcc --reply-to --in-reply-to])
469
+
470
+ ${bold("DOMAINS & DNS")}
471
+ domains list List your domains
472
+ domains add <domain> Add a domain; prints the DNS records to set
473
+ domains get <id> Show a domain (with DNS + webhook)
474
+ domains verify <id> Re-check DNS (MX/SPF/DKIM/DMARC)
475
+ domains rm <id> Remove a domain
476
+ dns <id> [--provider cf|godaddy|…] Print DNS records (+ provider hint)
477
+
478
+ ${bold("WEBHOOKS & RECEIVING")}
479
+ webhook set <id> <url> Set the domain's catch-all webhook
480
+ webhook rm <id> Remove the webhook
481
+ webhook test <id> Send a signed test event to the webhook
482
+ secret get | rotate The account webhook signing secret (whsec_…)
483
+ verify-webhook --signature --secret [--file|--payload] Verify a signature locally
484
+ messages list | get <id> Stored inbound messages
485
+ messages tail [--once --timeout N --subject TXT --json] Wait for new inbound mail
486
+ routes list | create --match --action --destination
487
+
488
+ ${bold("DELIVERIES")}
489
+ deliveries retry <id> Re-deliver a stored message to its webhook
490
+
491
+ ${bold("WORKFLOW")}
492
+ init [--email --password --domain --webhook --to --verify --wait N]
493
+ End-to-end setup wizard (scriptable for agents)
494
+ mcp Run the MailKite MCP server (stdio) with your token
495
+
496
+ ${bold("GLOBAL FLAGS")}
497
+ --json Machine-readable JSON output (great for scripts/agents)
498
+ --token <t> Bearer token (overrides env + config)
499
+ --base-url <u> API base URL (default ${DEFAULT_BASE_URL})
500
+ -h, --help Show help -v, --version Show version
501
+
502
+ ${bold("ENV")} MAILKITE_API_KEY / MAILKITE_TOKEN, MAILKITE_BASE_URL
503
+ ${bold("CONFIG")} ${CONFIG_FILE}
504
+ `;
505
+
506
+ // ---- main -------------------------------------------------------------------
507
+ async function main() {
508
+ const { _, flags } = parseArgs(process.argv.slice(2));
509
+ if (flags.version && !_.length) return commands.version({ _, flags });
510
+ const cmd = _.shift();
511
+ if (!cmd || flags.help && !cmd) { console.log(HELP); return; }
512
+ const handler = commands[cmd];
513
+ if (!handler) { console.error(red(`Unknown command: ${cmd}\n`)); console.log(HELP); process.exit(1); }
514
+ if (flags.help) { console.log(HELP); return; }
515
+ try {
516
+ await handler({ _, flags });
517
+ } catch (e) {
518
+ if (e instanceof MailKiteError) die(`API error ${e.status}: ${e.message}`);
519
+ die(e?.stack || e?.message || String(e));
520
+ }
521
+ // The SDK uses fetch (undici), whose keep-alive sockets keep the event loop
522
+ // alive for a few seconds after the work is done. Give stdout a brief tick to
523
+ // drain (matters when piped), then exit promptly so commands return at once and
524
+ // stay snappy for scripts/agents — regardless of TTY vs pipe.
525
+ await new Promise((r) => setTimeout(r, 25));
526
+ process.exit(process.exitCode || 0);
527
+ }
528
+
529
+ main();
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@mailkite/cli",
3
+ "version": "0.1.0",
4
+ "description": "MailKite command-line client — sign in, add domains, set DNS + webhooks, send mail, and tail inbound messages from your terminal. Built on the MailKite Node SDK.",
5
+ "type": "module",
6
+ "bin": {
7
+ "mailkite": "cli.mjs"
8
+ },
9
+ "files": [
10
+ "cli.mjs",
11
+ "README.md"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "scripts": {
17
+ "test": "node test.mjs"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "mailkite",
24
+ "email",
25
+ "cli",
26
+ "transactional",
27
+ "webhook",
28
+ "dns"
29
+ ],
30
+ "homepage": "https://mailkite.dev/docs/cli",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/fijiwebdesign/mailkite.git",
34
+ "directory": "sdks/cli"
35
+ },
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "mailkite": "^0.1.0"
39
+ }
40
+ }