mailfully 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 ADDED
@@ -0,0 +1,126 @@
1
+ # Mailfully CLI
2
+
3
+ Send transactional email and manage your Mailfully account from the terminal.
4
+ Built on [`@mailfully/node`](https://www.npmjs.com/package/@mailfully/node).
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install -g mailfully
10
+ ```
11
+
12
+ Requires Node.js 24 or newer.
13
+
14
+ ## Quickstart
15
+
16
+ ```bash
17
+ mailfully login # paste an API key from the dashboard
18
+ mailfully send --from you@yourdomain.dev --to someone@example.com \
19
+ --subject "Hello" --text "Sent from the Mailfully CLI."
20
+ ```
21
+
22
+ ## Authentication
23
+
24
+ The CLI resolves an API key in this order:
25
+
26
+ 1. `--api-key <key>` on any command
27
+ 2. the `MAILFULLY_API_KEY` environment variable
28
+ 3. the key stored by `mailfully login` (`~/.config/mailfully/config.json`)
29
+
30
+ Keys are minted in the [dashboard](https://dashboard.mailfully.com) and carry
31
+ either the `live` or `test` environment. `mf_test_` keys exercise the full
32
+ send path against the SES mailbox simulator — free and reputation-safe.
33
+ `mailfully whoami` shows which key and API the CLI is talking to, and every
34
+ send confirmation names the environment it went to.
35
+
36
+ `mailfully login` stores the key in plaintext at
37
+ `~/.config/mailfully/config.json` (file mode 600), like `gh` or `wrangler`.
38
+ On shared machines prefer the interactive prompt or the environment variable
39
+ over `--api-key`, which lands the key in shell history.
40
+
41
+ Key management, webhooks, and suppression edits require a dashboard session
42
+ (API keys cannot carry `manage:*` scopes), so those live in the dashboard, not
43
+ the CLI.
44
+
45
+ ## Commands
46
+
47
+ ```
48
+ mailfully login | logout | whoami
49
+ mailfully send … # shorthand for emails send
50
+ mailfully emails send|batch|get|list|events|cancel|reschedule
51
+ mailfully domains add|list|get|verify|tracking
52
+ mailfully templates create|list|get|update|delete
53
+ mailfully suppressions list
54
+ mailfully analytics daily|domains|tags|reputation
55
+ ```
56
+
57
+ Every command that talks to the API accepts `--json`, `--api-key`, and `--api-url`. Run
58
+ `mailfully <command> --help` for the full flag list.
59
+
60
+ `--json` prints the raw API response for single calls (stable under the API's
61
+ `/v1` versioning). Two shapes are CLI-synthesized instead: `--all` merges
62
+ every page into one `{ "data": [...] }`, and `whoami` reports the CLI's own
63
+ view. Only data goes to stdout — hints and errors go to stderr, so piped
64
+ output stays clean.
65
+
66
+ ## Examples
67
+
68
+ ```bash
69
+ # Verify a new sending domain
70
+ mailfully domains add acme.dev --mail-from-prefix send
71
+ mailfully domains verify dom_…
72
+
73
+ # Send with a stored template, idempotently
74
+ mailfully send --from you@acme.dev --to user@example.com \
75
+ --template tmpl_… --var name=Ada --idempotency-key signup-1042
76
+
77
+ # Ship a batch from a file (a JSON array of API-shape email objects)
78
+ mailfully emails batch ./welcome-batch.json --idempotency-key batch-2026-07-28
79
+
80
+ # Investigate a delivery
81
+ mailfully emails list --search user@example.com
82
+ mailfully emails events em_…
83
+
84
+ # Watch deliverability
85
+ mailfully analytics daily --since 2026-07-01
86
+ mailfully analytics reputation
87
+ ```
88
+
89
+ ## Exit codes
90
+
91
+ `0` success · `1` API or network failure · `2` usage error · `3` accepted, but
92
+ nothing will be sent.
93
+
94
+ `3` means the API accepted the request and every recipient was suppressed, so
95
+ no mail goes out — `send` on a fully suppressed message, or `emails batch`
96
+ where *every* row was canceled (a partial batch stays `0`, because some mail
97
+ did go out). It is distinct from `1` on purpose: `1` is the API refusing you,
98
+ `3` is the API accepting you and dispatching nothing. Without it,
99
+ `mailfully send … && next-step` would treat zero delivery as success.
100
+
101
+ Scripts should treat any nonzero code as failure — finer-grained codes may be
102
+ added in future minor versions.
103
+
104
+ ## Documentation
105
+
106
+ - [Quickstart](https://mailfully.com/docs/quickstart)
107
+ - [Verify a sending domain](https://mailfully.com/docs/guides/verify-a-domain) —
108
+ what `mailfully domains add` / `verify` are doing
109
+ - [Test mode](https://mailfully.com/docs/guides/test-mode) — `mf_test_` keys,
110
+ which never touch your reputation or your bill
111
+ - [Batch sending](https://mailfully.com/docs/guides/batch-sending) — the JSON
112
+ shape `mailfully emails batch` expects
113
+ - [Idempotency](https://mailfully.com/docs/concepts/idempotency) —
114
+ `--idempotency-key`
115
+ - [Error types](https://mailfully.com/docs/concepts/errors) and
116
+ [rate limits](https://mailfully.com/docs/concepts/rate-limits) — what the
117
+ CLI's error hints are pointing at
118
+ - [Full API reference](https://mailfully.com/docs)
119
+
120
+ Homepage: [mailfully.com](https://mailfully.com) ·
121
+ Dashboard: [dashboard.mailfully.com](https://dashboard.mailfully.com) ·
122
+ Issues: [github.com/Mailfully/mailfully/issues](https://github.com/Mailfully/mailfully/issues)
123
+
124
+ ## License
125
+
126
+ MIT
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { defaultConfigPath } from "./config.js";
3
+ import { runCli } from "./program.js";
4
+ import { cliVersion } from "./version.js";
5
+ // engines.node is advisory only — npx on an old LTS would otherwise die with a
6
+ // cryptic runtime error instead of this sentence.
7
+ const major = Number(process.versions.node.split(".")[0] ?? "0");
8
+ if (major < 24) {
9
+ process.stderr.write(`mailfully requires Node.js 24 or newer (you are running ${process.versions.node}).\n`);
10
+ process.exit(1);
11
+ }
12
+ /**
13
+ * The production fetch seam: a 60s timeout (an interactive CLI must never hang
14
+ * forever on a stalled connection) and a User-Agent identifying the CLI
15
+ * version on every request (so wire behavior can be debugged/deprecated per
16
+ * installed version later). Tests inject their own fetchImpl and bypass this.
17
+ */
18
+ const realFetch = (input, init) => {
19
+ // A Headers merge, not a plain-object spread: init.headers can legitimately
20
+ // arrive as a Headers instance (case-insensitive, non-enumerable via
21
+ // spread), and spreading it would silently drop every header it carries —
22
+ // including a caller's Authorization.
23
+ const headers = new Headers(init?.headers);
24
+ headers.set("User-Agent", `mailfully-cli/${cliVersion()}`);
25
+ return fetch(input, {
26
+ ...init,
27
+ headers,
28
+ signal: AbortSignal.timeout(60_000),
29
+ });
30
+ };
31
+ process.exitCode = await runCli(process.argv.slice(2), {
32
+ env: process.env,
33
+ stdout: process.stdout,
34
+ stderr: process.stderr,
35
+ fetchImpl: realFetch,
36
+ configPath: defaultConfigPath(process.env),
37
+ });
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { ProgramDeps } from "../program.js";
3
+ export declare function registerAnalyticsCommands(program: Command, deps: ProgramDeps): void;
@@ -0,0 +1,97 @@
1
+ import { resolveContext } from "../context.js";
2
+ import { unwrap } from "../failure.js";
3
+ import { formatTable, toJson } from "../output.js";
4
+ import { withGlobalOptions } from "../flags.js";
5
+ const COUNTER_HEADER = [
6
+ "Sent",
7
+ "Delivered",
8
+ "Bounced",
9
+ "Complained",
10
+ "Opened",
11
+ "Clicked",
12
+ ];
13
+ function counterCells(c) {
14
+ return [
15
+ String(c.sent),
16
+ String(c.delivered),
17
+ String(c.bounced),
18
+ String(c.complained),
19
+ String(c.opened),
20
+ String(c.clicked),
21
+ ];
22
+ }
23
+ function windowQuery(opts) {
24
+ return {
25
+ ...(opts.since !== undefined ? { from: opts.since } : {}),
26
+ ...(opts.until !== undefined ? { to: opts.until } : {}),
27
+ };
28
+ }
29
+ // --since/--until, matching emails list (never --from, which means an address
30
+ // on send); the wire params stay from/to.
31
+ function withWindowOptions(cmd) {
32
+ return cmd
33
+ .option("--since <date>", "Window start (ISO date, e.g. 2026-07-01).")
34
+ .option("--until <date>", "Window end (ISO date).");
35
+ }
36
+ export function registerAnalyticsCommands(program, deps) {
37
+ const analytics = program
38
+ .command("analytics")
39
+ .description("Read delivery analytics rollups.");
40
+ withGlobalOptions(withWindowOptions(analytics.command("daily").description("Daily send/delivery counters."))).action(async (opts) => {
41
+ const ctx = await resolveContext(opts, deps);
42
+ const data = unwrap(await ctx.client.analytics.daily(windowQuery(opts)));
43
+ if (ctx.json) {
44
+ deps.stdout.write(toJson(data));
45
+ return;
46
+ }
47
+ deps.stdout.write(formatTable(["Day", ...COUNTER_HEADER], data.data.map((row) => [row.day, ...counterCells(row)])));
48
+ });
49
+ withGlobalOptions(withWindowOptions(analytics
50
+ .command("domains")
51
+ .description("Counters broken down by recipient domain.")
52
+ .option("--domain <domain>", "Filter to one recipient domain."))).action(async (opts) => {
53
+ const ctx = await resolveContext(opts, deps);
54
+ const data = unwrap(await ctx.client.analytics.byDomain({
55
+ ...windowQuery(opts),
56
+ ...(opts.domain !== undefined ? { domain: opts.domain } : {}),
57
+ }));
58
+ if (ctx.json) {
59
+ deps.stdout.write(toJson(data));
60
+ return;
61
+ }
62
+ deps.stdout.write(formatTable(["Domain", "Day", ...COUNTER_HEADER], data.data.map((row) => [row.domain, row.day, ...counterCells(row)])));
63
+ });
64
+ withGlobalOptions(withWindowOptions(analytics
65
+ .command("tags")
66
+ .description("Counters broken down by tag.")
67
+ .option("--tag <tag>", "Filter to one tag value."))).action(async (opts) => {
68
+ const ctx = await resolveContext(opts, deps);
69
+ const data = unwrap(await ctx.client.analytics.byTag({
70
+ ...windowQuery(opts),
71
+ ...(opts.tag !== undefined ? { tag: opts.tag } : {}),
72
+ }));
73
+ if (ctx.json) {
74
+ deps.stdout.write(toJson(data));
75
+ return;
76
+ }
77
+ deps.stdout.write(formatTable(["Tag", "Day", ...COUNTER_HEADER], data.data.map((row) => [row.tag, row.day, ...counterCells(row)])));
78
+ });
79
+ withGlobalOptions(analytics
80
+ .command("reputation")
81
+ .description("Latest external (Gmail Postmaster) reputation per domain.")).action(async (opts) => {
82
+ const ctx = await resolveContext(opts, deps);
83
+ const data = unwrap(await ctx.client.analytics.externalReputation());
84
+ if (ctx.json) {
85
+ deps.stdout.write(toJson(data));
86
+ return;
87
+ }
88
+ deps.stdout.write(formatTable(["Domain", "Gmail spam rate", "Observed"], data.data.domains.map((d) => [
89
+ d.domain,
90
+ d.gmailSpamRate === null ? "n/a" : String(d.gmailSpamRate),
91
+ d.observedAt,
92
+ ])));
93
+ if (data.data.worstGmailSpamRate !== null) {
94
+ deps.stdout.write(`\nWorst Gmail spam rate: ${data.data.worstGmailSpamRate}\n`);
95
+ }
96
+ });
97
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { ProgramDeps } from "../program.js";
3
+ export declare function registerAuthCommands(program: Command, deps: ProgramDeps): void;
@@ -0,0 +1,82 @@
1
+ import { Mailfully } from "@mailfully/node";
2
+ import { deleteConfig, loadConfig, saveConfig } from "../config.js";
3
+ import { keyEnvironment, redactKey, resolveBaseUrl, resolveContext, } from "../context.js";
4
+ import { CommandFailure } from "../failure.js";
5
+ import { renderError, toJson } from "../output.js";
6
+ import { promptSecret } from "../prompt.js";
7
+ import { withGlobalOptions } from "../flags.js";
8
+ /**
9
+ * Verify a key by hitting the cheapest authenticated read. There is no
10
+ * dedicated identity endpoint on the v1 API, so `GET /v1/domains` is the
11
+ * probe (requires `send` OR `read:emails`). A 403 `insufficient_scope`
12
+ * response means the key AUTHENTICATED but lacks a scope for this endpoint —
13
+ * legitimately mintable (e.g. an analytics-only key), so it counts as valid.
14
+ * Note a revoked key also comes back 403, but with type `invalid_api_key`,
15
+ * so the type check below correctly rejects it.
16
+ */
17
+ async function assertKeyUsable(client) {
18
+ const { error } = await client.domains.list();
19
+ if (error === null)
20
+ return;
21
+ if (error.statusCode === 403 && error.type === "insufficient_scope")
22
+ return;
23
+ throw new CommandFailure(renderError(error));
24
+ }
25
+ export function registerAuthCommands(program, deps) {
26
+ withGlobalOptions(program
27
+ .command("login")
28
+ .description("Store an API key for future commands (verified first).")).action(async (opts) => {
29
+ const prompt = deps.promptSecret ?? promptSecret;
30
+ const apiKey = opts.apiKey ??
31
+ (await prompt("Paste an API key from the dashboard (input hidden): "));
32
+ if (apiKey === "") {
33
+ throw new CommandFailure("No API key provided.", 2);
34
+ }
35
+ // Probe target honors the same flag > env > config precedence the rest of
36
+ // the CLI uses (resolveBaseUrl is shared with resolveContext), so a
37
+ // rotated key is verified against the server the CLI will actually talk
38
+ // to. Only an explicit --api-url is persisted.
39
+ const existing = await loadConfig(deps.configPath);
40
+ const probeUrl = resolveBaseUrl(opts.apiUrl, deps.env, existing);
41
+ await assertKeyUsable(new Mailfully({
42
+ apiKey,
43
+ ...(probeUrl !== undefined ? { baseUrl: probeUrl } : {}),
44
+ ...(deps.fetchImpl !== undefined ? { fetch: deps.fetchImpl } : {}),
45
+ }));
46
+ await saveConfig(deps.configPath, {
47
+ ...existing,
48
+ api_key: apiKey,
49
+ ...(opts.apiUrl !== undefined ? { base_url: opts.apiUrl } : {}),
50
+ });
51
+ deps.stdout.write(`Logged in with ${redactKey(apiKey)} (${keyEnvironment(apiKey)} environment).\n`);
52
+ if (opts.apiUrl === undefined && deps.env.MAILFULLY_API_URL !== undefined) {
53
+ // The env var steered the probe but is deliberately not persisted —
54
+ // otherwise unsetting it later would silently retarget the stored key.
55
+ deps.stdout.write(`Verified against ${deps.env.MAILFULLY_API_URL} (from MAILFULLY_API_URL — not stored; pass --api-url to store it).\n`);
56
+ }
57
+ });
58
+ // Deliberate exception: logout takes no global flags — it is purely local
59
+ // (deletes the config file) and never talks to the API.
60
+ program
61
+ .command("logout")
62
+ .description("Remove the stored API key.")
63
+ .action(async () => {
64
+ const removed = await deleteConfig(deps.configPath);
65
+ deps.stdout.write(removed ? "Logged out.\n" : "No stored credentials found.\n");
66
+ });
67
+ withGlobalOptions(program
68
+ .command("whoami")
69
+ .description("Show which key and API the CLI is talking to.")).action(async (opts) => {
70
+ const ctx = await resolveContext(opts, deps);
71
+ await assertKeyUsable(ctx.client);
72
+ if (ctx.json) {
73
+ deps.stdout.write(toJson({
74
+ key: redactKey(ctx.apiKey),
75
+ environment: ctx.environment,
76
+ base_url: ctx.baseUrl,
77
+ }));
78
+ return;
79
+ }
80
+ deps.stdout.write(`Authenticated with ${redactKey(ctx.apiKey)} (${ctx.environment} environment) against ${ctx.baseUrl}.\n`);
81
+ });
82
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { ProgramDeps } from "../program.js";
3
+ export declare function registerDomainsCommands(program: Command, deps: ProgramDeps): void;
@@ -0,0 +1,135 @@
1
+ import { resolveContext } from "../context.js";
2
+ import { CommandFailure, unwrap } from "../failure.js";
3
+ import { formatTable, toJson } from "../output.js";
4
+ import { withGlobalOptions } from "../flags.js";
5
+ /** The DNS records table (Priority is blank for non-MX records). */
6
+ function recordsTable(records) {
7
+ return formatTable(["Type", "Name", "Value", "Priority"], records.map((r) => [
8
+ r.type,
9
+ r.name,
10
+ r.value,
11
+ r.priority === undefined ? "" : String(r.priority),
12
+ ]));
13
+ }
14
+ /** The one-domain-per-row summary used by `domains list`. */
15
+ function domainCells(d) {
16
+ return [
17
+ d.id,
18
+ d.name,
19
+ d.status,
20
+ d.dkim_status,
21
+ d.mail_from_status,
22
+ d.tracking_enabled ? "on" : "off",
23
+ d.created_at,
24
+ ];
25
+ }
26
+ const DOMAIN_HEADER = [
27
+ "ID",
28
+ "Name",
29
+ "Status",
30
+ "DKIM",
31
+ "Mail from",
32
+ "Tracking",
33
+ "Created",
34
+ ];
35
+ export function registerDomainsCommands(program, deps) {
36
+ const domains = program
37
+ .command("domains")
38
+ .description("Add and verify sending domains.");
39
+ withGlobalOptions(domains
40
+ .command("add")
41
+ .description("Add a sending domain and print the DNS records to publish.")
42
+ .argument("<name>", "The domain name, e.g. acme.dev.")
43
+ .option("--mail-from-prefix <label>", "Custom MAIL FROM subdomain label (a single DNS label, e.g. send).")).action(async (name, opts) => {
44
+ const ctx = await resolveContext(opts, deps);
45
+ const data = unwrap(await ctx.client.domains.create({
46
+ name,
47
+ ...(opts.mailFromPrefix !== undefined
48
+ ? { mailFromPrefix: opts.mailFromPrefix }
49
+ : {}),
50
+ }));
51
+ if (ctx.json) {
52
+ deps.stdout.write(toJson(data));
53
+ return;
54
+ }
55
+ deps.stdout.write(`Added ${data.name} (${data.id}, status: ${data.status}).\n\n`);
56
+ deps.stdout.write("Publish these DNS records:\n\n");
57
+ deps.stdout.write(recordsTable(data.records));
58
+ deps.stdout.write(`\nThen run: mailfully domains verify ${data.id}\n`);
59
+ });
60
+ withGlobalOptions(domains.command("list").description("List this org's sending domains.")).action(async (opts) => {
61
+ const ctx = await resolveContext(opts, deps);
62
+ const data = unwrap(await ctx.client.domains.list());
63
+ if (ctx.json) {
64
+ deps.stdout.write(toJson(data));
65
+ return;
66
+ }
67
+ deps.stdout.write(formatTable(DOMAIN_HEADER, data.data.map(domainCells)));
68
+ });
69
+ withGlobalOptions(domains
70
+ .command("get")
71
+ .description("Show one domain's detail and DNS records.")
72
+ .argument("<id>", "The domain id (dom_…).")).action(async (id, opts) => {
73
+ const ctx = await resolveContext(opts, deps);
74
+ const data = unwrap(await ctx.client.domains.get(id));
75
+ if (ctx.json) {
76
+ deps.stdout.write(toJson(data));
77
+ return;
78
+ }
79
+ deps.stdout.write([
80
+ `ID: ${data.id}`,
81
+ `Name: ${data.name}`,
82
+ `Status: ${data.status}`,
83
+ `DKIM: ${data.dkim_status}`,
84
+ `Mail from: ${data.mail_from_status} (${data.mail_from_subdomain ?? "default"})`,
85
+ // `tracking_status` is deliberately NOT shown. It is a column no
86
+ // production code path writes (the sole writer in the repo is the dev
87
+ // seed), so it reads `not_started` on every real domain forever —
88
+ // printing it rendered as the nonsense "Tracking: on (not_started)".
89
+ // `tracking_enabled` is the real signal and is the one shown.
90
+ `Tracking: ${data.tracking_enabled ? "on" : "off"}`,
91
+ `Created: ${data.created_at}`,
92
+ ].join("\n") + "\n\nDNS records:\n\n");
93
+ deps.stdout.write(recordsTable(data.records));
94
+ });
95
+ withGlobalOptions(domains
96
+ .command("verify")
97
+ .description("Ask Mailfully to re-check the domain's DNS now.")
98
+ .argument("<id>", "The domain id (dom_…).")).action(async (id, opts) => {
99
+ const ctx = await resolveContext(opts, deps);
100
+ const data = unwrap(await ctx.client.domains.verify(id));
101
+ if (ctx.json) {
102
+ deps.stdout.write(toJson(data));
103
+ return;
104
+ }
105
+ deps.stdout.write(`${data.name}: ${data.status} (DKIM ${data.dkim_status}, mail from ${data.mail_from_status}).\n`);
106
+ if (data.status !== "verified") {
107
+ deps.stdout.write("DNS can take a while to propagate — rerun this command to check again.\n");
108
+ }
109
+ });
110
+ withGlobalOptions(domains
111
+ .command("tracking")
112
+ .description("Turn open/click tracking on or off for a domain.")
113
+ .argument("<id>", "The domain id (dom_…).")
114
+ .option("--enable", "Turn tracking on.")
115
+ .option("--disable", "Turn tracking off.")).action(async (id, opts) => {
116
+ const enable = opts.enable === true;
117
+ const disable = opts.disable === true;
118
+ if (enable === disable) {
119
+ throw new CommandFailure("Pass exactly one of --enable or --disable.", 2);
120
+ }
121
+ const ctx = await resolveContext(opts, deps);
122
+ const data = unwrap(await ctx.client.domains.tracking(id, { enabled: enable }));
123
+ if (ctx.json) {
124
+ deps.stdout.write(toJson(data));
125
+ return;
126
+ }
127
+ deps.stdout.write(
128
+ // No `tracking_status` here either — see the note in `domains show`.
129
+ `Tracking for ${data.name} is now ${data.tracking_enabled ? "on" : "off"}.\n`);
130
+ if (data.records.length > 0) {
131
+ deps.stdout.write("\nDNS records:\n\n");
132
+ deps.stdout.write(recordsTable(data.records));
133
+ }
134
+ });
135
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { ProgramDeps } from "../program.js";
3
+ export declare function registerEmailsCommands(program: Command, deps: ProgramDeps): void;