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
|
@@ -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,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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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>;
|
package/dist/context.js
ADDED
|
@@ -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
|
+
}
|