@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.
- package/README.md +96 -0
- package/cli.mjs +529 -0
- 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
|
+
}
|