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.
@@ -0,0 +1,273 @@
1
+ import { resolveContext } from "../context.js";
2
+ import { CommandFailure, EXIT_NOTHING_SENT, unwrap } from "../failure.js";
3
+ import { formatTable, toJson } from "../output.js";
4
+ import { collectPair, collectString, pairsToTags, parseMessageType, readBody, readJsonFile, wireToSendInput, } from "../parse.js";
5
+ import { withGlobalOptions } from "../flags.js";
6
+ import { defaultSleep, renderPagedList } from "../pages.js";
7
+ /** Attach the shared send flag set to a command (used by both registrations). */
8
+ function configureSendCommand(cmd) {
9
+ return withGlobalOptions(cmd
10
+ .description("Send one email.")
11
+ .requiredOption("--from <address>", "Sender address (verified domain).")
12
+ .requiredOption("--to <address>", "Recipient address (repeatable).", collectString)
13
+ .option("--cc <address>", "Cc address (repeatable).", collectString)
14
+ .option("--bcc <address>", "Bcc address (repeatable).", collectString)
15
+ .option("--reply-to <address>", "Reply-to address (repeatable).", collectString)
16
+ .option("--subject <subject>", "Subject line.")
17
+ .option("--html <html>", "Inline HTML body.")
18
+ .option("--html-file <path>", "Read the HTML body from a file.")
19
+ .option("--text <text>", "Inline plain-text body.")
20
+ .option("--text-file <path>", "Read the plain-text body from a file.")
21
+ .option("--type <transactional|marketing>", "Message type (default: transactional, applied by the API).", parseMessageType)
22
+ .option("--template <id>", "Render a stored template.")
23
+ .option("--var <name=value>", "Template variable (repeatable).", collectPair)
24
+ .option("--tag <name=value>", "Message tag (repeatable).", collectPair)
25
+ .option("--header <name=value>", "Custom message header (repeatable).", collectPair)
26
+ .option("--scheduled-at <iso8601>", "Schedule delivery for a future time (up to 90 days out).")
27
+ .option("--idempotency-key <key>", "Idempotency key — a retry with the same key never double-sends."));
28
+ }
29
+ async function sendAction(opts, deps) {
30
+ const ctx = await resolveContext(opts, deps);
31
+ const html = await readBody(opts.html, opts.htmlFile, "--html");
32
+ const text = await readBody(opts.text, opts.textFile, "--text");
33
+ if (html === undefined && text === undefined && opts.template === undefined) {
34
+ throw new CommandFailure("Provide a body: --html/--html-file, --text/--text-file, or --template.", 2);
35
+ }
36
+ if (opts.var !== undefined && opts.template === undefined) {
37
+ throw new CommandFailure("--var requires --template.", 2);
38
+ }
39
+ const input = {
40
+ from: opts.from,
41
+ to: opts.to,
42
+ ...(opts.cc !== undefined ? { cc: opts.cc } : {}),
43
+ ...(opts.bcc !== undefined ? { bcc: opts.bcc } : {}),
44
+ ...(opts.replyTo !== undefined ? { replyTo: opts.replyTo } : {}),
45
+ ...(opts.subject !== undefined ? { subject: opts.subject } : {}),
46
+ ...(html !== undefined ? { html } : {}),
47
+ ...(text !== undefined ? { text } : {}),
48
+ ...(opts.type !== undefined ? { type: opts.type } : {}),
49
+ ...(opts.header !== undefined ? { headers: opts.header } : {}),
50
+ ...(opts.tag !== undefined ? { tags: pairsToTags(opts.tag) } : {}),
51
+ ...(opts.scheduledAt !== undefined
52
+ ? { scheduledAt: opts.scheduledAt }
53
+ : {}),
54
+ ...(opts.template !== undefined
55
+ ? {
56
+ template: {
57
+ id: opts.template,
58
+ ...(opts.var !== undefined ? { variables: opts.var } : {}),
59
+ },
60
+ }
61
+ : {}),
62
+ };
63
+ const data = unwrap(await ctx.client.emails.send(input, opts.idempotencyKey !== undefined
64
+ ? { idempotencyKey: opts.idempotencyKey }
65
+ : undefined));
66
+ if (ctx.json) {
67
+ deps.stdout.write(toJson(data));
68
+ // Same exit contract as the human arm — a JSON consumer in a `&&` chain
69
+ // deserves it just as much — but silent, so the pipe stays pure JSON.
70
+ if (data.status === "canceled") {
71
+ throw new CommandFailure("", EXIT_NOTHING_SENT);
72
+ }
73
+ return;
74
+ }
75
+ // A send drained by suppression still returns 202, carrying
76
+ // `status: "canceled"` — nothing will go out. Reporting that as "Accepted"
77
+ // tells the operator the opposite of what happened. The parenthetical is not
78
+ // padding: `toDrainedBySuppression` (acceptSend.ts) cancels even when a
79
+ // cc/bcc SURVIVES, because a suppressed `to` is never replaced by promoting
80
+ // one — so "all of them were suppressed" would be the wrong explanation.
81
+ if (data.status === "canceled") {
82
+ deps.stdout.write(`Canceled: ${data.id} (${ctx.environment})\n`);
83
+ deps.stderr.write("Canceled by suppression — no mail will be sent (a fully suppressed To cancels the send even when a Cc or Bcc survives).\n");
84
+ throw new CommandFailure("", EXIT_NOTHING_SENT);
85
+ }
86
+ // `status` is narrowed to the literal "queued" here. The annotation is the
87
+ // point: if `AcceptedEmail["status"]` ever grows a third member, this stops
88
+ // compiling instead of silently printing "Accepted" for it — which is the
89
+ // exact bug this branch was added to fix.
90
+ const _queuedOnly = data.status;
91
+ void _queuedOnly;
92
+ // The environment is printed on every accept line so a live key is never
93
+ // mistaken for a test one at the moment it matters most.
94
+ deps.stdout.write(`Accepted: ${data.id} (${ctx.environment})\n`);
95
+ }
96
+ export function registerEmailsCommands(program, deps) {
97
+ // Root-level alias: `mailfully send …` is the advertised quickstart form.
98
+ configureSendCommand(program.command("send")).action((opts) => sendAction(opts, deps));
99
+ const emails = program
100
+ .command("emails")
101
+ .description("Send email and work with the message log.");
102
+ configureSendCommand(emails.command("send")).action((opts) => sendAction(opts, deps));
103
+ withGlobalOptions(emails
104
+ .command("batch")
105
+ .description("Send up to 100 emails from a JSON file (a wire-shape array).")
106
+ .argument("<file>", "Path to a JSON array of email objects.")
107
+ .option("--idempotency-key <key>", "Idempotency key — a retry with the same key never double-sends.")).action(async (file, opts) => {
108
+ const ctx = await resolveContext(opts, deps);
109
+ const parsed = await readJsonFile(file);
110
+ if (!Array.isArray(parsed)) {
111
+ throw new CommandFailure(`${file} must contain a JSON array of email objects.`, 2);
112
+ }
113
+ const inputs = parsed.map((item) => {
114
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
115
+ throw new CommandFailure(`${file} must contain only email objects.`, 2);
116
+ }
117
+ return wireToSendInput(item);
118
+ });
119
+ const data = unwrap(await ctx.client.emails.batch(inputs, opts.idempotencyKey !== undefined
120
+ ? { idempotencyKey: opts.idempotencyKey }
121
+ : undefined));
122
+ const canceled = data.data.filter((row) => row.status === "canceled");
123
+ const total = data.data.length;
124
+ const allCanceled = total > 0 && canceled.length === total;
125
+ if (ctx.json) {
126
+ deps.stdout.write(toJson(data));
127
+ // Same exit contract as the human arm — a JSON consumer in a `&&`
128
+ // chain deserves it too — but silent, so the pipe stays pure JSON.
129
+ if (allCanceled) {
130
+ throw new CommandFailure("", EXIT_NOTHING_SENT);
131
+ }
132
+ return;
133
+ }
134
+ // Three summaries, not two. A batch drained END TO END must not lead
135
+ // with "Accepted" — that is the claim the single-send arm refuses to
136
+ // make, and the reader most likely to be misled (a script piping the id
137
+ // list) is exactly the one who discarded stderr. "N of them" in the
138
+ // partial case is deliberate too: "2 emails, 1 canceled" reads as three.
139
+ const summary = canceled.length === 0
140
+ ? `Accepted ${total} emails`
141
+ : canceled.length === total
142
+ ? `Canceled all ${total} emails by suppression`
143
+ : `Accepted ${total} emails, ${canceled.length} of them canceled by suppression`;
144
+ deps.stdout.write(`${summary} (${ctx.environment}):\n`);
145
+ for (const row of data.data) {
146
+ deps.stdout.write(`${row.id}\n`);
147
+ }
148
+ // WHICH rows were dropped is a hint, not data — the id list above stays
149
+ // one bare id per line for pipes, so the names go to stderr.
150
+ if (canceled.length > 0) {
151
+ deps.stderr.write(`No mail will be sent for: ${canceled.map((row) => row.id).join(", ")}\n`);
152
+ }
153
+ // A PARTIAL cancellation stays 0: some mail really did go out, and the
154
+ // stdout count already says how much. Only a batch that delivered
155
+ // NOTHING gets the nonzero code.
156
+ if (allCanceled) {
157
+ throw new CommandFailure("", EXIT_NOTHING_SENT);
158
+ }
159
+ });
160
+ withGlobalOptions(emails
161
+ .command("get")
162
+ .description("Fetch one email's detail.")
163
+ .argument("<id>", "The email id (em_…).")).action(async (id, opts) => {
164
+ const ctx = await resolveContext(opts, deps);
165
+ const data = unwrap(await ctx.client.emails.get(id));
166
+ if (ctx.json) {
167
+ deps.stdout.write(toJson(data));
168
+ return;
169
+ }
170
+ deps.stdout.write([
171
+ `ID: ${data.id}`,
172
+ `Status: ${data.last_event}`,
173
+ `From: ${data.from}`,
174
+ `To: ${data.to.join(", ")}`,
175
+ `Subject: ${data.subject ?? ""}`,
176
+ `Created: ${data.created_at}`,
177
+ `Scheduled: ${data.scheduled_at ?? ""}`,
178
+ // Array.isArray guard: a malformed tags payload (seen from bad seed
179
+ // data) must degrade to an empty cell, not crash the renderer.
180
+ `Tags: ${(Array.isArray(data.tags) ? data.tags : [])
181
+ .map((t) => `${t.name}=${t.value}`)
182
+ .join(", ")}`,
183
+ ].join("\n") + "\n");
184
+ });
185
+ withGlobalOptions(emails
186
+ .command("events")
187
+ .description("Show one email's event timeline.")
188
+ .argument("<id>", "The email id (em_…).")).action(async (id, opts) => {
189
+ const ctx = await resolveContext(opts, deps);
190
+ const data = unwrap(await ctx.client.emails.events(id));
191
+ if (ctx.json) {
192
+ deps.stdout.write(toJson(data));
193
+ return;
194
+ }
195
+ deps.stdout.write(formatTable(["Type", "At", "Detail"], data.data.map((event) => [
196
+ event.type,
197
+ event.event_at,
198
+ event.detail === null ? "" : JSON.stringify(event.detail),
199
+ ])));
200
+ });
201
+ withGlobalOptions(emails
202
+ .command("list")
203
+ .description("List sent emails (newest first).")
204
+ .option("--status <status>", "Filter by status (e.g. delivered, bounced).")
205
+ .option("--tag <value>", "Filter by tag value (the value half of a send-time name=value tag).")
206
+ .option("--domain <domain>", "Filter by recipient domain.")
207
+ .option("--recipient <address>", "Filter by exact recipient address.")
208
+ .option("--search <term>", "Free-text search (subject, from address, id, or recipient).")
209
+ // Deliberately NOT --from/--to: those mean addresses on `send`, and the
210
+ // wire params from/to stay an internal mapping detail.
211
+ .option("--since <date>", "Window start (ISO date, e.g. 2026-07-01).")
212
+ .option("--until <date>", "Window end (ISO date).")
213
+ .option("--limit <n>", "Page size (max 100).")
214
+ .option("--cursor <cursor>", "Resume from a previous next-cursor value.")
215
+ .option("--all", "Fetch every page (ignores --limit; starts from --cursor when given).")).action(async (opts) => {
216
+ const ctx = await resolveContext(opts, deps);
217
+ const baseQuery = {
218
+ ...(opts.status !== undefined ? { status: opts.status } : {}),
219
+ ...(opts.tag !== undefined ? { tag: opts.tag } : {}),
220
+ ...(opts.domain !== undefined ? { domain: opts.domain } : {}),
221
+ ...(opts.recipient !== undefined ? { recipient: opts.recipient } : {}),
222
+ ...(opts.search !== undefined ? { q: opts.search } : {}),
223
+ ...(opts.since !== undefined ? { from: opts.since } : {}),
224
+ ...(opts.until !== undefined ? { to: opts.until } : {}),
225
+ };
226
+ await renderPagedList({
227
+ all: opts.all,
228
+ cursor: opts.cursor,
229
+ limit: opts.limit,
230
+ json: ctx.json,
231
+ header: ["ID", "Status", "From", "To", "Subject", "Created"],
232
+ toCells: (r) => [
233
+ r.id,
234
+ r.last_event,
235
+ r.from,
236
+ r.to.join(","),
237
+ r.subject ?? "",
238
+ r.created_at,
239
+ ],
240
+ fetchPage: (paging) => ctx.client.emails.list({ ...baseQuery, ...paging }),
241
+ stdout: deps.stdout,
242
+ stderr: deps.stderr,
243
+ sleep: deps.sleep ?? defaultSleep,
244
+ });
245
+ });
246
+ withGlobalOptions(emails
247
+ .command("cancel")
248
+ .description("Cancel a scheduled email that has not been sent yet.")
249
+ .argument("<id>", "The email id (em_…).")).action(async (id, opts) => {
250
+ const ctx = await resolveContext(opts, deps);
251
+ const data = unwrap(await ctx.client.emails.cancel(id));
252
+ if (ctx.json) {
253
+ deps.stdout.write(toJson(data));
254
+ return;
255
+ }
256
+ deps.stdout.write(`${data.id} is now ${data.last_event}.\n`);
257
+ });
258
+ withGlobalOptions(emails
259
+ .command("reschedule")
260
+ .description("Move a scheduled email to a new delivery time.")
261
+ .argument("<id>", "The email id (em_…).")
262
+ .requiredOption("--scheduled-at <iso8601>", "The new delivery time (up to 90 days out).")).action(async (id, opts) => {
263
+ const ctx = await resolveContext(opts, deps);
264
+ const data = unwrap(await ctx.client.emails.reschedule(id, {
265
+ scheduledAt: opts.scheduledAt,
266
+ }));
267
+ if (ctx.json) {
268
+ deps.stdout.write(toJson(data));
269
+ return;
270
+ }
271
+ deps.stdout.write(`${data.id} rescheduled for ${data.scheduled_at ?? "(unset)"}.\n`);
272
+ });
273
+ }
@@ -0,0 +1,9 @@
1
+ import type { Command } from "commander";
2
+ import type { ProgramDeps } from "../program.js";
3
+ /**
4
+ * Read-only by design: `read:suppressions` is mintable on an API key, but
5
+ * adding/removing a suppression is a `manage:suppressions` (dashboard-session)
6
+ * operation, so the CLI ships `list` only and the help text says where the
7
+ * write side lives.
8
+ */
9
+ export declare function registerSuppressionsCommands(program: Command, deps: ProgramDeps): void;
@@ -0,0 +1,44 @@
1
+ import { resolveContext } from "../context.js";
2
+ import { defaultSleep, renderPagedList } from "../pages.js";
3
+ import { withGlobalOptions } from "../flags.js";
4
+ function suppressionCells(s) {
5
+ return [s.id, s.email, s.reason, s.source, s.created_at];
6
+ }
7
+ const SUPPRESSION_HEADER = ["ID", "Email", "Reason", "Source", "Created"];
8
+ /**
9
+ * Read-only by design: `read:suppressions` is mintable on an API key, but
10
+ * adding/removing a suppression is a `manage:suppressions` (dashboard-session)
11
+ * operation, so the CLI ships `list` only and the help text says where the
12
+ * write side lives.
13
+ */
14
+ export function registerSuppressionsCommands(program, deps) {
15
+ const suppressions = program
16
+ .command("suppressions")
17
+ .description("Inspect suppressed recipients (adding/removing lives in the dashboard).");
18
+ withGlobalOptions(suppressions
19
+ .command("list")
20
+ .description("List suppressed recipients.")
21
+ .option("--email <address>", "Filter by exact address.")
22
+ .option("--reason <reason>", "Filter by reason (e.g. hard_bounce, complaint).")
23
+ .option("--limit <n>", "Page size (max 100).")
24
+ .option("--cursor <cursor>", "Resume from a previous next-cursor value.")
25
+ .option("--all", "Fetch every page (ignores --limit; starts from --cursor when given).")).action(async (opts) => {
26
+ const ctx = await resolveContext(opts, deps);
27
+ const baseQuery = {
28
+ ...(opts.email !== undefined ? { email: opts.email } : {}),
29
+ ...(opts.reason !== undefined ? { reason: opts.reason } : {}),
30
+ };
31
+ await renderPagedList({
32
+ all: opts.all,
33
+ cursor: opts.cursor,
34
+ limit: opts.limit,
35
+ json: ctx.json,
36
+ header: SUPPRESSION_HEADER,
37
+ toCells: suppressionCells,
38
+ fetchPage: (paging) => ctx.client.suppressions.list({ ...baseQuery, ...paging }),
39
+ stdout: deps.stdout,
40
+ stderr: deps.stderr,
41
+ sleep: deps.sleep ?? defaultSleep,
42
+ });
43
+ });
44
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { ProgramDeps } from "../program.js";
3
+ export declare function registerTemplatesCommands(program: Command, deps: ProgramDeps): void;
@@ -0,0 +1,123 @@
1
+ import { resolveContext } from "../context.js";
2
+ import { CommandFailure, unwrap } from "../failure.js";
3
+ import { formatTable, toJson } from "../output.js";
4
+ import { readBody, readJsonFile } from "../parse.js";
5
+ import { withGlobalOptions } from "../flags.js";
6
+ /** Attach the shared body flag set to a command (create and update). */
7
+ function withBodyOptions(cmd) {
8
+ return cmd
9
+ .option("--html <html>", "Inline HTML body.")
10
+ .option("--html-file <path>", "Read the HTML body from a file.")
11
+ .option("--text <text>", "Inline plain-text body.")
12
+ .option("--text-file <path>", "Read the plain-text body from a file.")
13
+ .option("--variables-schema-file <path>", "Read the variables schema (JSON) from a file.");
14
+ }
15
+ /** Read and parse the variables schema file (shared contract: see readJsonFile). */
16
+ async function readVariablesSchema(path) {
17
+ if (path === undefined)
18
+ return undefined;
19
+ return readJsonFile(path);
20
+ }
21
+ /** The one-template-per-row summary used by `templates list`. */
22
+ function templateCells(t) {
23
+ return [
24
+ t.id,
25
+ t.name,
26
+ t.html !== null ? "yes" : "",
27
+ t.text !== null ? "yes" : "",
28
+ t.updated_at,
29
+ ];
30
+ }
31
+ const TEMPLATE_HEADER = ["ID", "Name", "HTML", "Text", "Updated"];
32
+ export function registerTemplatesCommands(program, deps) {
33
+ const templates = program
34
+ .command("templates")
35
+ .description("Manage stored email templates.");
36
+ withGlobalOptions(withBodyOptions(templates
37
+ .command("create")
38
+ .description("Create a template.")
39
+ .requiredOption("--name <name>", "Template name."))).action(async (opts) => {
40
+ const ctx = await resolveContext(opts, deps);
41
+ const html = await readBody(opts.html, opts.htmlFile, "--html");
42
+ const text = await readBody(opts.text, opts.textFile, "--text");
43
+ if (html === undefined && text === undefined) {
44
+ throw new CommandFailure("Provide a body: --html/--html-file or --text/--text-file.", 2);
45
+ }
46
+ const variablesSchema = await readVariablesSchema(opts.variablesSchemaFile);
47
+ const data = unwrap(await ctx.client.templates.create({
48
+ name: opts.name,
49
+ ...(html !== undefined ? { html } : {}),
50
+ ...(text !== undefined ? { text } : {}),
51
+ ...(variablesSchema !== undefined ? { variablesSchema } : {}),
52
+ }));
53
+ if (ctx.json) {
54
+ deps.stdout.write(toJson(data));
55
+ return;
56
+ }
57
+ deps.stdout.write(`Created ${data.id} (${data.name}).\n`);
58
+ });
59
+ withGlobalOptions(templates.command("list").description("List this org's templates.")).action(async (opts) => {
60
+ const ctx = await resolveContext(opts, deps);
61
+ const data = unwrap(await ctx.client.templates.list());
62
+ if (ctx.json) {
63
+ deps.stdout.write(toJson(data));
64
+ return;
65
+ }
66
+ deps.stdout.write(formatTable(TEMPLATE_HEADER, data.data.map(templateCells)));
67
+ });
68
+ withGlobalOptions(templates
69
+ .command("get")
70
+ .description("Show one template.")
71
+ .argument("<id>", "The template id (tmpl_…).")).action(async (id, opts) => {
72
+ const ctx = await resolveContext(opts, deps);
73
+ const data = unwrap(await ctx.client.templates.get(id));
74
+ if (ctx.json) {
75
+ deps.stdout.write(toJson(data));
76
+ return;
77
+ }
78
+ deps.stdout.write([
79
+ `ID: ${data.id}`,
80
+ `Name: ${data.name}`,
81
+ `Updated: ${data.updated_at}`,
82
+ "",
83
+ data.html ?? data.text ?? "(no body)",
84
+ ].join("\n") + "\n");
85
+ });
86
+ withGlobalOptions(withBodyOptions(templates
87
+ .command("update")
88
+ .description("Update the provided fields of a template.")
89
+ .argument("<id>", "The template id (tmpl_…).")
90
+ .option("--name <name>", "New template name."))).action(async (id, opts) => {
91
+ const ctx = await resolveContext(opts, deps);
92
+ const html = await readBody(opts.html, opts.htmlFile, "--html");
93
+ const text = await readBody(opts.text, opts.textFile, "--text");
94
+ const variablesSchema = await readVariablesSchema(opts.variablesSchemaFile);
95
+ const patch = {
96
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
97
+ ...(html !== undefined ? { html } : {}),
98
+ ...(text !== undefined ? { text } : {}),
99
+ ...(variablesSchema !== undefined ? { variablesSchema } : {}),
100
+ };
101
+ if (Object.keys(patch).length === 0) {
102
+ throw new CommandFailure("Provide at least one field to update (--name, --html/--html-file, --text/--text-file, --variables-schema-file).", 2);
103
+ }
104
+ const data = unwrap(await ctx.client.templates.update(id, patch));
105
+ if (ctx.json) {
106
+ deps.stdout.write(toJson(data));
107
+ return;
108
+ }
109
+ deps.stdout.write(`Updated ${data.id} (${data.name}).\n`);
110
+ });
111
+ withGlobalOptions(templates
112
+ .command("delete")
113
+ .description("Delete a template.")
114
+ .argument("<id>", "The template id (tmpl_…).")).action(async (id, opts) => {
115
+ const ctx = await resolveContext(opts, deps);
116
+ const data = unwrap(await ctx.client.templates.delete(id));
117
+ if (ctx.json) {
118
+ deps.stdout.write(toJson(data));
119
+ return;
120
+ }
121
+ deps.stdout.write(`Deleted ${data.id}.\n`);
122
+ });
123
+ }
@@ -0,0 +1,30 @@
1
+ /** The on-disk config shape (snake_case, mirroring the wire convention). */
2
+ export interface CliConfig {
3
+ api_key?: string;
4
+ base_url?: string;
5
+ }
6
+ /**
7
+ * Resolve the config path: `MAILFULLY_CONFIG_PATH` env override, else
8
+ * `$XDG_CONFIG_HOME/mailfully/config.json`, else `~/.config/mailfully/config.json`.
9
+ *
10
+ * NOTE: only those two keys are read from `env` — the home-directory fallback
11
+ * calls `os.homedir()`, which reads the REAL process environment. `env.HOME`
12
+ * is NOT part of the injectable seam; tests that must redirect the config file
13
+ * set `MAILFULLY_CONFIG_PATH` or `XDG_CONFIG_HOME` instead.
14
+ */
15
+ export declare function defaultConfigPath(env: Record<string, string | undefined>): string;
16
+ /**
17
+ * Read the config file. Missing, unreadable, or malformed files all yield `{}`
18
+ * — the CLI treats a broken config as "not logged in", never as a crash.
19
+ */
20
+ export declare function loadConfig(path: string): Promise<CliConfig>;
21
+ /** Write the config, creating the directory (0700) and file (0600) tightly. */
22
+ export declare function saveConfig(path: string, config: CliConfig): Promise<void>;
23
+ /**
24
+ * Delete the config file; `true` if it existed. A missing file (ENOENT)
25
+ * yields `false` — the ordinary "not logged in" case. Any other error (e.g.
26
+ * EACCES) is rethrown rather than swallowed: `logout` must report a real
27
+ * failure to remove the file instead of claiming success while the key is
28
+ * still on disk.
29
+ */
30
+ export declare function deleteConfig(path: string): Promise<boolean>;
package/dist/config.js ADDED
@@ -0,0 +1,82 @@
1
+ import { chmod, mkdir, rm, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ /**
5
+ * Resolve the config path: `MAILFULLY_CONFIG_PATH` env override, else
6
+ * `$XDG_CONFIG_HOME/mailfully/config.json`, else `~/.config/mailfully/config.json`.
7
+ *
8
+ * NOTE: only those two keys are read from `env` — the home-directory fallback
9
+ * calls `os.homedir()`, which reads the REAL process environment. `env.HOME`
10
+ * is NOT part of the injectable seam; tests that must redirect the config file
11
+ * set `MAILFULLY_CONFIG_PATH` or `XDG_CONFIG_HOME` instead.
12
+ */
13
+ export function defaultConfigPath(env) {
14
+ if (env.MAILFULLY_CONFIG_PATH !== undefined &&
15
+ env.MAILFULLY_CONFIG_PATH !== "") {
16
+ return env.MAILFULLY_CONFIG_PATH;
17
+ }
18
+ const base = env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
19
+ return join(base, "mailfully", "config.json");
20
+ }
21
+ /**
22
+ * Read the config file. Missing, unreadable, or malformed files all yield `{}`
23
+ * — the CLI treats a broken config as "not logged in", never as a crash.
24
+ */
25
+ export async function loadConfig(path) {
26
+ let raw;
27
+ try {
28
+ raw = await readFile(path, "utf8");
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ try {
34
+ const parsed = JSON.parse(raw);
35
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
36
+ return {};
37
+ }
38
+ const record = parsed;
39
+ const config = {};
40
+ if (typeof record.api_key === "string")
41
+ config.api_key = record.api_key;
42
+ if (typeof record.base_url === "string")
43
+ config.base_url = record.base_url;
44
+ return config;
45
+ }
46
+ catch {
47
+ return {};
48
+ }
49
+ }
50
+ /** Write the config, creating the directory (0700) and file (0600) tightly. */
51
+ export async function saveConfig(path, config) {
52
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
53
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, {
54
+ encoding: "utf8",
55
+ mode: 0o600,
56
+ });
57
+ // `mode` on writeFile only applies when the file is created — an existing,
58
+ // looser-permission file (e.g. left behind by an older CLI version) keeps
59
+ // its old mode otherwise. Tighten it explicitly on every save.
60
+ if (process.platform !== "win32") {
61
+ await chmod(path, 0o600);
62
+ }
63
+ }
64
+ /**
65
+ * Delete the config file; `true` if it existed. A missing file (ENOENT)
66
+ * yields `false` — the ordinary "not logged in" case. Any other error (e.g.
67
+ * EACCES) is rethrown rather than swallowed: `logout` must report a real
68
+ * failure to remove the file instead of claiming success while the key is
69
+ * still on disk.
70
+ */
71
+ export async function deleteConfig(path) {
72
+ try {
73
+ await rm(path);
74
+ return true;
75
+ }
76
+ catch (err) {
77
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
78
+ return false;
79
+ }
80
+ throw err;
81
+ }
82
+ }
@@ -0,0 +1,45 @@
1
+ import { Mailfully } from "@mailfully/node";
2
+ /** The three per-command global flags (added by withGlobalOptions, Task 7). */
3
+ export interface GlobalFlags {
4
+ apiKey?: string;
5
+ apiUrl?: string;
6
+ json?: boolean;
7
+ }
8
+ /** What resolveContext needs from the program (a slice of ProgramDeps). */
9
+ export interface ContextDeps {
10
+ env: Record<string, string | undefined>;
11
+ configPath: string;
12
+ fetchImpl?: typeof fetch;
13
+ }
14
+ /** The resolved per-invocation context every command action works from. */
15
+ export interface CliContext {
16
+ client: Mailfully;
17
+ apiKey: string;
18
+ baseUrl: string;
19
+ environment: "live" | "test" | "unknown";
20
+ json: boolean;
21
+ }
22
+ /** Classify a key by its `mf_live_` / `mf_test_` prefix. */
23
+ export declare function keyEnvironment(apiKey: string): "live" | "test" | "unknown";
24
+ /**
25
+ * `mf_live_abcd…wxyz` — the stored-key display form. Never print a full key.
26
+ * Real Mailfully keys are ~51 chars; anything ≤24 gets prefix-only truncation
27
+ * so a short foreign token is never substantially revealed.
28
+ */
29
+ export declare function redactKey(apiKey: string): string;
30
+ /**
31
+ * The documented base-URL precedence (flag > env > config file), shared by
32
+ * {@link resolveContext} and login's key-verification probe so the two can
33
+ * never drift. Returns undefined when nothing overrides the default.
34
+ */
35
+ export declare function resolveBaseUrl(apiUrlFlag: string | undefined, env: {
36
+ MAILFULLY_API_URL?: string;
37
+ }, config: {
38
+ base_url?: string;
39
+ }): string | undefined;
40
+ /**
41
+ * Resolve key + base URL with the documented precedence (flag > env > config
42
+ * file > default) and construct the SDK client. Throws {@link MissingApiKeyError}
43
+ * when no key is found anywhere.
44
+ */
45
+ export declare function resolveContext(flags: GlobalFlags, deps: ContextDeps): Promise<CliContext>;
@@ -0,0 +1,54 @@
1
+ import { DEFAULT_BASE_URL, Mailfully } from "@mailfully/node";
2
+ import { loadConfig } from "./config.js";
3
+ import { MissingApiKeyError } from "./failure.js";
4
+ /** Classify a key by its `mf_live_` / `mf_test_` prefix. */
5
+ export function keyEnvironment(apiKey) {
6
+ if (apiKey.startsWith("mf_live_"))
7
+ return "live";
8
+ if (apiKey.startsWith("mf_test_"))
9
+ return "test";
10
+ return "unknown";
11
+ }
12
+ /**
13
+ * `mf_live_abcd…wxyz` — the stored-key display form. Never print a full key.
14
+ * Real Mailfully keys are ~51 chars; anything ≤24 gets prefix-only truncation
15
+ * so a short foreign token is never substantially revealed.
16
+ */
17
+ export function redactKey(apiKey) {
18
+ if (apiKey.length <= 24)
19
+ return `${apiKey.slice(0, 4)}…`;
20
+ return `${apiKey.slice(0, 12)}…${apiKey.slice(-4)}`;
21
+ }
22
+ /**
23
+ * The documented base-URL precedence (flag > env > config file), shared by
24
+ * {@link resolveContext} and login's key-verification probe so the two can
25
+ * never drift. Returns undefined when nothing overrides the default.
26
+ */
27
+ export function resolveBaseUrl(apiUrlFlag, env, config) {
28
+ return apiUrlFlag ?? env.MAILFULLY_API_URL ?? config.base_url;
29
+ }
30
+ /**
31
+ * Resolve key + base URL with the documented precedence (flag > env > config
32
+ * file > default) and construct the SDK client. Throws {@link MissingApiKeyError}
33
+ * when no key is found anywhere.
34
+ */
35
+ export async function resolveContext(flags, deps) {
36
+ const config = await loadConfig(deps.configPath);
37
+ const apiKey = flags.apiKey ?? deps.env.MAILFULLY_API_KEY ?? config.api_key;
38
+ if (apiKey === undefined || apiKey === "") {
39
+ throw new MissingApiKeyError();
40
+ }
41
+ const baseUrl = resolveBaseUrl(flags.apiUrl, deps.env, config) ?? DEFAULT_BASE_URL;
42
+ const client = new Mailfully({
43
+ apiKey,
44
+ baseUrl,
45
+ ...(deps.fetchImpl !== undefined ? { fetch: deps.fetchImpl } : {}),
46
+ });
47
+ return {
48
+ client,
49
+ apiKey,
50
+ baseUrl,
51
+ environment: keyEnvironment(apiKey),
52
+ json: flags.json ?? false,
53
+ };
54
+ }