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 +126 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +37 -0
- package/dist/commands/analytics.d.ts +3 -0
- package/dist/commands/analytics.js +97 -0
- package/dist/commands/auth.d.ts +3 -0
- package/dist/commands/auth.js +82 -0
- package/dist/commands/domains.d.ts +3 -0
- package/dist/commands/domains.js +135 -0
- package/dist/commands/emails.d.ts +3 -0
- package/dist/commands/emails.js +273 -0
- package/dist/commands/suppressions.d.ts +9 -0
- package/dist/commands/suppressions.js +44 -0
- package/dist/commands/templates.d.ts +3 -0
- package/dist/commands/templates.js +123 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +82 -0
- package/dist/context.d.ts +45 -0
- package/dist/context.js +54 -0
- package/dist/failure.d.ts +22 -0
- package/dist/failure.js +34 -0
- package/dist/flags.d.ts +3 -0
- package/dist/flags.js +7 -0
- package/dist/output.d.ts +15 -0
- package/dist/output.js +84 -0
- package/dist/pages.d.ts +42 -0
- package/dist/pages.js +76 -0
- package/dist/parse.d.ts +37 -0
- package/dist/parse.js +92 -0
- package/dist/program.d.ts +35 -0
- package/dist/program.js +83 -0
- package/dist/prompt.d.ts +12 -0
- package/dist/prompt.js +37 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +11 -0
- package/package.json +63 -0
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
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,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,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,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
|
+
}
|