groundmail-cli 0.30.0 → 0.31.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 +17 -0
- package/dist/cli.js +621 -33
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -47,6 +47,23 @@ Or without a global install:
|
|
|
47
47
|
npx groundmail-cli emails send --from … --to … --subject … --html …
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
## Watch an email land
|
|
51
|
+
|
|
52
|
+
Add `--watch` to see the recipient's mail server accept it, with its real reply and the time it took:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
groundmail emails send --to you@gmail.com --subject "Hello" --text "Hi" --watch
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
✓ accepted by Groundmail 0.3s
|
|
60
|
+
✓ delivering to gmail-smtp-in.l.google.com
|
|
61
|
+
✓ 250 2.0.0 OK 1793… - gsmtp 2.9s
|
|
62
|
+
Delivered to you@gmail.com in 2.9s.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Without `--from`, it sends from the sandbox sender (`noreply@mail.groundmail.dev`) to your own account email, so it works right after `groundmail login`. Deferrals show the server's reply and keep watching; a bounce prints the exact reply, the bounce type and what to do. `groundmail emails get <id> --watch` follows an email you already sent; `--json` prints one JSON object per stage; `--timeout <seconds>` (default 60). Exit codes: `0` delivered, `1` error, `3` bounced or failed, `4` still on its way, `5` test send.
|
|
66
|
+
|
|
50
67
|
## Common commands
|
|
51
68
|
|
|
52
69
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,505 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import "./chunk-4SSJDDBV.js";
|
|
3
3
|
|
|
4
|
+
// src/commands/emails/watch.ts
|
|
5
|
+
var WATCH_EXIT = {
|
|
6
|
+
/** The recipient's mail server accepted it. */
|
|
7
|
+
delivered: 0,
|
|
8
|
+
/** The send or a status check failed: bad flags, sign-in, the API refused the send. */
|
|
9
|
+
error: 1,
|
|
10
|
+
/** It bounced, failed, was suppressed or was cancelled. */
|
|
11
|
+
notDelivered: 3,
|
|
12
|
+
/** Still on its way when `--timeout` ran out. */
|
|
13
|
+
timeout: 4,
|
|
14
|
+
/** A test send: simulated, never delivered. */
|
|
15
|
+
test: 5
|
|
16
|
+
};
|
|
17
|
+
var DEFAULT_WATCH_TIMEOUT_SECONDS = 60;
|
|
18
|
+
var LABEL_WIDTH = 42;
|
|
19
|
+
function formatDuration(ms) {
|
|
20
|
+
const clamped = Math.max(0, ms);
|
|
21
|
+
if (clamped < 6e4) return `${(clamped / 1e3).toFixed(1)}s`;
|
|
22
|
+
const minutes = Math.floor(clamped / 6e4);
|
|
23
|
+
const seconds = Math.floor(clamped % 6e4 / 1e3);
|
|
24
|
+
return `${minutes}m ${seconds}s`;
|
|
25
|
+
}
|
|
26
|
+
function between(from, to) {
|
|
27
|
+
if (!from || !to) return null;
|
|
28
|
+
const a = Date.parse(from);
|
|
29
|
+
const b = Date.parse(to);
|
|
30
|
+
return Number.isFinite(a) && Number.isFinite(b) ? Math.max(0, b - a) : null;
|
|
31
|
+
}
|
|
32
|
+
function timedFrom(email) {
|
|
33
|
+
const scheduled = email.scheduled_at ? Date.parse(email.scheduled_at) : NaN;
|
|
34
|
+
return Number.isFinite(scheduled) && scheduled > Date.parse(email.created_at) ? email.scheduled_at : email.created_at;
|
|
35
|
+
}
|
|
36
|
+
var ID_LIKE = /^[A-Za-z0-9][A-Za-z0-9._\-=+/]{7,}$/;
|
|
37
|
+
function idLike(token) {
|
|
38
|
+
return ID_LIKE.test(token) && /\d/.test(token) && !/^\d\.\d{1,3}\.\d{1,3}$/.test(token);
|
|
39
|
+
}
|
|
40
|
+
function shortenReply(reply, max) {
|
|
41
|
+
if (reply.length <= LABEL_WIDTH) return reply;
|
|
42
|
+
const parts = reply.split(/(\s+)/);
|
|
43
|
+
const out = [];
|
|
44
|
+
let inRun = false;
|
|
45
|
+
for (const part of parts) {
|
|
46
|
+
if (/^\s+$/.test(part)) {
|
|
47
|
+
if (!inRun) out.push(part);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (idLike(part)) {
|
|
51
|
+
if (!inRun) out.push(`${part.slice(0, 4)}\u2026`);
|
|
52
|
+
inRun = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (inRun) out.push(" ");
|
|
56
|
+
inRun = false;
|
|
57
|
+
out.push(part);
|
|
58
|
+
}
|
|
59
|
+
const collapsed = out.join("").replace(/ +$/, "");
|
|
60
|
+
if (collapsed.length <= max) return collapsed;
|
|
61
|
+
const keep = max - 1;
|
|
62
|
+
const head = Math.ceil(keep * 0.7);
|
|
63
|
+
return `${collapsed.slice(0, head)}\u2026${collapsed.slice(collapsed.length - (keep - head))}`;
|
|
64
|
+
}
|
|
65
|
+
var SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
66
|
+
var MARK_TEXT = { ok: "\u2713", fail: "\u2717", warn: "!", wait: "\u2192" };
|
|
67
|
+
var MARK_COLOR = { ok: "32", fail: "31", warn: "33", wait: "36" };
|
|
68
|
+
var Renderer = class {
|
|
69
|
+
constructor(io, now, spinnerMs) {
|
|
70
|
+
this.io = io;
|
|
71
|
+
this.now = now;
|
|
72
|
+
this.live = null;
|
|
73
|
+
this.lastPlainLive = null;
|
|
74
|
+
this.frame = 0;
|
|
75
|
+
this.timer = null;
|
|
76
|
+
if (io.isTTY && spinnerMs > 0) {
|
|
77
|
+
this.timer = setInterval(() => {
|
|
78
|
+
this.frame = (this.frame + 1) % SPINNER.length;
|
|
79
|
+
this.drawLive();
|
|
80
|
+
}, spinnerMs);
|
|
81
|
+
this.timer.unref?.();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
paint(code, text) {
|
|
85
|
+
return this.io.isTTY && this.io.color !== false ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
86
|
+
}
|
|
87
|
+
layout(prefix, label, time) {
|
|
88
|
+
if (!time) return ` ${prefix} ${label}`;
|
|
89
|
+
const gap = label.length < LABEL_WIDTH ? " ".repeat(LABEL_WIDTH - label.length) : " ";
|
|
90
|
+
return ` ${prefix} ${label}${gap}${this.paint("2", time)}`;
|
|
91
|
+
}
|
|
92
|
+
clearLive() {
|
|
93
|
+
if (this.io.isTTY && this.live) this.io.write("\r\x1B[2K");
|
|
94
|
+
}
|
|
95
|
+
drawLive() {
|
|
96
|
+
if (!this.io.isTTY || !this.live) return;
|
|
97
|
+
const spinner = this.paint("36", SPINNER[this.frame]);
|
|
98
|
+
this.io.write(`\r\x1B[2K${this.layout(spinner, this.live.label, formatDuration(this.now() - this.live.since))}`);
|
|
99
|
+
}
|
|
100
|
+
/** A finished line. */
|
|
101
|
+
line(mark, label, ms) {
|
|
102
|
+
this.clearLive();
|
|
103
|
+
this.io.write(`${this.layout(this.paint(MARK_COLOR[mark], MARK_TEXT[mark]), label, ms == null ? null : formatDuration(ms))}
|
|
104
|
+
`);
|
|
105
|
+
this.drawLive();
|
|
106
|
+
}
|
|
107
|
+
/** An indented detail under the last line (a server reply, say). */
|
|
108
|
+
detail(text) {
|
|
109
|
+
this.clearLive();
|
|
110
|
+
this.io.write(` ${text}
|
|
111
|
+
`);
|
|
112
|
+
this.drawLive();
|
|
113
|
+
}
|
|
114
|
+
/** A sentence after the lines (the outcome and what to do). */
|
|
115
|
+
note(text) {
|
|
116
|
+
this.clearLive();
|
|
117
|
+
this.io.write(` ${text}
|
|
118
|
+
`);
|
|
119
|
+
this.drawLive();
|
|
120
|
+
}
|
|
121
|
+
/** The waiting line. Plain output prints it once per new label. */
|
|
122
|
+
waiting(label, since) {
|
|
123
|
+
if (!this.io.isTTY) {
|
|
124
|
+
if (label !== this.lastPlainLive) this.io.write(`${this.layout(MARK_TEXT.wait, label, null)}
|
|
125
|
+
`);
|
|
126
|
+
this.lastPlainLive = label;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (this.live?.label === label) return;
|
|
130
|
+
this.live = { label, since: this.live?.since ?? since };
|
|
131
|
+
this.drawLive();
|
|
132
|
+
}
|
|
133
|
+
/** The waiting line is done: on a terminal it becomes a finished line. */
|
|
134
|
+
settle(mark, label) {
|
|
135
|
+
if (!this.live) return;
|
|
136
|
+
const text = label ?? this.live.label;
|
|
137
|
+
this.clearLive();
|
|
138
|
+
this.live = null;
|
|
139
|
+
if (this.io.isTTY) this.io.write(`${this.layout(this.paint(MARK_COLOR[mark], MARK_TEXT[mark]), text, null)}
|
|
140
|
+
`);
|
|
141
|
+
}
|
|
142
|
+
get plainLive() {
|
|
143
|
+
return this.lastPlainLive;
|
|
144
|
+
}
|
|
145
|
+
stop() {
|
|
146
|
+
if (this.timer) clearInterval(this.timer);
|
|
147
|
+
this.timer = null;
|
|
148
|
+
this.clearLive();
|
|
149
|
+
this.live = null;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
var TROUBLESHOOTING_URL = "https://docs.groundmail.dev/troubleshooting#emails-arent-arriving";
|
|
153
|
+
function looksTooLarge(text) {
|
|
154
|
+
return /\b552\b[^\n]*\b5\.3\.4\b|\b5\.2\.3\b|message size|too large|size exceeds/i.test(text ?? "");
|
|
155
|
+
}
|
|
156
|
+
function outcomeAdvice(email) {
|
|
157
|
+
const recipient = email.to[0] ?? "the address";
|
|
158
|
+
const reply = email.delivery?.response ?? email.bounce_reason ?? "";
|
|
159
|
+
if (email.status === "suppressed") {
|
|
160
|
+
return `Not sent: a recipient is on your suppression list. Remove the suppression only if you know the address works now: groundmail suppressions remove --email ${recipient}`;
|
|
161
|
+
}
|
|
162
|
+
if (email.status === "cancelled") return "Cancelled before it went out.";
|
|
163
|
+
if (email.bounce_type === "block") {
|
|
164
|
+
return `Bounced (block): the receiving server refused the sender, not the address. ${recipient} isn't suppressed and this doesn't count toward your bounce rate; send to it again later. More: ${TROUBLESHOOTING_URL}`;
|
|
165
|
+
}
|
|
166
|
+
if (email.bounce_type === "hard") {
|
|
167
|
+
return `Bounced (hard): the address can't receive mail, so it's on your suppression list now and later sends to it are skipped. Check it for typos, and remove the suppression only if you know it works: groundmail suppressions remove --email ${recipient}`;
|
|
168
|
+
}
|
|
169
|
+
if (looksTooLarge(reply)) {
|
|
170
|
+
return "Failed: the receiving server refused it for its size. The address is fine and not suppressed: send a smaller email, and link to large files instead of attaching them.";
|
|
171
|
+
}
|
|
172
|
+
return `Failed: it wasn't delivered and the address isn't suppressed. The reason is above; more: ${TROUBLESHOOTING_URL}`;
|
|
173
|
+
}
|
|
174
|
+
var DELIVERED_STATUSES = /* @__PURE__ */ new Set(["delivered", "opened", "clicked", "complained"]);
|
|
175
|
+
var FAILED_STATUSES = /* @__PURE__ */ new Set(["bounced", "failed", "suppressed", "cancelled"]);
|
|
176
|
+
var NOT_ACCEPTED = /* @__PURE__ */ new Set(["queued", "scheduled", "suppressed", "cancelled"]);
|
|
177
|
+
function pollDelayMs(elapsedMs) {
|
|
178
|
+
if (elapsedMs < 1e4) return 400;
|
|
179
|
+
if (elapsedMs < 2e4) return 1e3;
|
|
180
|
+
if (elapsedMs < 3e4) return 2e3;
|
|
181
|
+
if (elapsedMs < 6e4) return 3e3;
|
|
182
|
+
return 5e3;
|
|
183
|
+
}
|
|
184
|
+
var RATE_LIMITED_DELAY_MS = 5e3;
|
|
185
|
+
var MX_WAIT_MS = 1500;
|
|
186
|
+
function recipientDomain(to) {
|
|
187
|
+
const first = to?.[0]?.split(",")[0]?.trim() ?? "";
|
|
188
|
+
const at = first.lastIndexOf("@");
|
|
189
|
+
const domain = at === -1 ? "" : first.slice(at + 1).replace(/>$/, "").trim().toLowerCase();
|
|
190
|
+
return domain || null;
|
|
191
|
+
}
|
|
192
|
+
async function lookupMx(domain) {
|
|
193
|
+
try {
|
|
194
|
+
const { resolveMx } = await import("dns/promises");
|
|
195
|
+
const records = await resolveMx(domain);
|
|
196
|
+
const best = records.sort((a, b) => a.priority - b.priority)[0]?.exchange?.replace(/\.$/, "").toLowerCase();
|
|
197
|
+
return best || null;
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function withTimeout(promise, ms) {
|
|
203
|
+
if (ms <= 0) return Promise.resolve(TIMED_OUT);
|
|
204
|
+
let timer;
|
|
205
|
+
return Promise.race([
|
|
206
|
+
promise.finally(() => clearTimeout(timer)),
|
|
207
|
+
new Promise((resolve) => {
|
|
208
|
+
timer = setTimeout(() => resolve(TIMED_OUT), ms);
|
|
209
|
+
})
|
|
210
|
+
]);
|
|
211
|
+
}
|
|
212
|
+
var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
|
|
213
|
+
function errorStatus(error) {
|
|
214
|
+
const status = error?.status;
|
|
215
|
+
return typeof status === "number" ? status : null;
|
|
216
|
+
}
|
|
217
|
+
async function watchEmail(options, deps) {
|
|
218
|
+
const now = deps.now ?? Date.now;
|
|
219
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
220
|
+
const io = deps.io;
|
|
221
|
+
const json = options.json;
|
|
222
|
+
const stderr = deps.stderr ?? ((line) => console.error(line));
|
|
223
|
+
const watchStart = now();
|
|
224
|
+
const deadline = watchStart + options.timeoutMs;
|
|
225
|
+
let startedAt = options.startedAt ?? watchStart;
|
|
226
|
+
const sinceAccepted = (email) => {
|
|
227
|
+
if (options.startedAt !== void 0) return;
|
|
228
|
+
const created = Date.parse(email.created_at);
|
|
229
|
+
if (Number.isFinite(created)) startedAt = Math.min(created, watchStart);
|
|
230
|
+
};
|
|
231
|
+
const render = new Renderer(json ? { ...io, isTTY: false } : io, now, json ? 0 : deps.spinnerMs ?? 80);
|
|
232
|
+
const emit = (stage) => io.write(`${JSON.stringify(stage)}
|
|
233
|
+
`);
|
|
234
|
+
let mx = options.mx ?? null;
|
|
235
|
+
const startMx = (to) => {
|
|
236
|
+
if (mx || !deps.resolveMx) return;
|
|
237
|
+
const domain = recipientDomain(to);
|
|
238
|
+
if (domain) mx = deps.resolveMx(domain).catch(() => null);
|
|
239
|
+
};
|
|
240
|
+
startMx(options.to);
|
|
241
|
+
let accepted = false;
|
|
242
|
+
let heldShown = false;
|
|
243
|
+
let deliveringShown = null;
|
|
244
|
+
const deferrals = /* @__PURE__ */ new Set();
|
|
245
|
+
let lastStatus = "queued";
|
|
246
|
+
let mxHost = null;
|
|
247
|
+
let mxSettled = false;
|
|
248
|
+
const finish2 = (code) => {
|
|
249
|
+
render.stop();
|
|
250
|
+
return code;
|
|
251
|
+
};
|
|
252
|
+
const awaitMx = async () => {
|
|
253
|
+
if (mxSettled) return;
|
|
254
|
+
mxSettled = true;
|
|
255
|
+
if (!mx) return;
|
|
256
|
+
const result = await withTimeout(mx, Math.min(MX_WAIT_MS, Math.max(0, deadline - now())));
|
|
257
|
+
mxHost = result === TIMED_OUT ? null : result;
|
|
258
|
+
};
|
|
259
|
+
const showDelivering = (email, retrying) => {
|
|
260
|
+
const host = email.delivery?.receiving_server ?? mxHost;
|
|
261
|
+
const target = host ?? `the mail server for ${recipientDomain(email.to) ?? "the recipient"}`;
|
|
262
|
+
if (json) {
|
|
263
|
+
if (host && deliveringShown !== host) emit({ stage: "delivering", id: email.id, receiving_server: host });
|
|
264
|
+
} else {
|
|
265
|
+
render.waiting(retrying ? `retrying delivery to ${target}` : `delivering to ${target}`, startedAt);
|
|
266
|
+
}
|
|
267
|
+
deliveringShown = host ?? deliveringShown;
|
|
268
|
+
};
|
|
269
|
+
const step = async (email) => {
|
|
270
|
+
lastStatus = email.status;
|
|
271
|
+
startMx(email.to);
|
|
272
|
+
sinceAccepted(email);
|
|
273
|
+
if (email.test) {
|
|
274
|
+
if (json) emit({ stage: "test", id: email.id, status: email.status });
|
|
275
|
+
else render.note(TEST_SEND_NOTE);
|
|
276
|
+
return WATCH_EXIT.test;
|
|
277
|
+
}
|
|
278
|
+
if (!accepted && !NOT_ACCEPTED.has(email.status) && !(email.status === "failed" && !email.sent_at)) {
|
|
279
|
+
accepted = true;
|
|
280
|
+
const ms = between(timedFrom(email), email.sent_at);
|
|
281
|
+
if (json) emit({ stage: "accepted", id: email.id, status: email.status, created_at: email.created_at, sent_at: email.sent_at, elapsed_ms: ms });
|
|
282
|
+
else render.line("ok", "accepted by Groundmail", ms);
|
|
283
|
+
for (const held of email.held_back ?? []) {
|
|
284
|
+
if (json) emit({ stage: "held_back", id: email.id, email: held.email, reason: held.reason });
|
|
285
|
+
else render.line("warn", `not sent to ${held.email} (${held.reason})`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (email.status === "scheduled") {
|
|
289
|
+
const at = email.scheduled_at ? Date.parse(email.scheduled_at) : NaN;
|
|
290
|
+
if (Number.isFinite(at) && at > deadline + 1e3) {
|
|
291
|
+
if (json) emit({ stage: "scheduled", id: email.id, scheduled_at: email.scheduled_at, check: `groundmail emails get ${email.id} --watch` });
|
|
292
|
+
else {
|
|
293
|
+
render.stop();
|
|
294
|
+
render.note(`Scheduled for ${email.scheduled_at}. Watch it then: groundmail emails get ${email.id} --watch`);
|
|
295
|
+
}
|
|
296
|
+
return WATCH_EXIT.timeout;
|
|
297
|
+
}
|
|
298
|
+
if (!json) render.waiting(`scheduled for ${email.scheduled_at ?? "later"}`, startedAt);
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
const delivery = email.delivery ?? null;
|
|
302
|
+
if (delivery?.deferred_at && !deferrals.has(delivery.deferred_at)) {
|
|
303
|
+
const first = deferrals.size === 0;
|
|
304
|
+
deferrals.add(delivery.deferred_at);
|
|
305
|
+
const ms = between(timedFrom(email), delivery.deferred_at);
|
|
306
|
+
const host = delivery.receiving_server ?? "the receiving server";
|
|
307
|
+
if (json) {
|
|
308
|
+
emit({ stage: "deferred", id: email.id, receiving_server: delivery.receiving_server, response: delivery.deferred_response, deferred_at: delivery.deferred_at, elapsed_ms: ms });
|
|
309
|
+
} else {
|
|
310
|
+
render.line("warn", `deferred by ${host}`, ms);
|
|
311
|
+
render.detail(delivery.deferred_response ?? "no reply: the connection failed or timed out");
|
|
312
|
+
if (first) render.note("Delivery is retried automatically.");
|
|
313
|
+
}
|
|
314
|
+
} else if (email.status === "delayed" && !deferrals.size) {
|
|
315
|
+
deferrals.add("status");
|
|
316
|
+
if (json) emit({ stage: "deferred", id: email.id, receiving_server: null, response: null, deferred_at: null, elapsed_ms: null });
|
|
317
|
+
else {
|
|
318
|
+
render.line("warn", "deferred by the receiving server");
|
|
319
|
+
render.note("Delivery is retried automatically.");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const landed = DELIVERED_STATUSES.has(email.status) || Boolean(email.delivered_at) && !FAILED_STATUSES.has(email.status);
|
|
323
|
+
if (landed) {
|
|
324
|
+
await awaitMx();
|
|
325
|
+
const host = delivery?.receiving_server ?? mxHost;
|
|
326
|
+
const ms = between(timedFrom(email), email.delivered_at);
|
|
327
|
+
const to = email.to.join(", ");
|
|
328
|
+
if (json) {
|
|
329
|
+
emit({
|
|
330
|
+
stage: "delivered",
|
|
331
|
+
id: email.id,
|
|
332
|
+
to: email.to,
|
|
333
|
+
receiving_server: host,
|
|
334
|
+
response: delivery?.response ?? null,
|
|
335
|
+
tls: delivery?.tls ?? null,
|
|
336
|
+
delivered_at: email.delivered_at,
|
|
337
|
+
elapsed_ms: ms
|
|
338
|
+
});
|
|
339
|
+
return WATCH_EXIT.delivered;
|
|
340
|
+
}
|
|
341
|
+
const deliveringLabel = host ? `delivering to ${host}` : "delivering to the recipient's mail server";
|
|
342
|
+
if (io.isTTY) {
|
|
343
|
+
render.waiting(deliveringLabel, startedAt);
|
|
344
|
+
render.settle("ok", deliveringLabel);
|
|
345
|
+
} else if (render.plainLive !== deliveringLabel) {
|
|
346
|
+
render.line("ok", deliveringLabel);
|
|
347
|
+
}
|
|
348
|
+
const reply = delivery?.response;
|
|
349
|
+
const label = reply ? io.isTTY ? shortenReply(reply, Math.max(LABEL_WIDTH, (io.columns ?? 80) - 14)) : reply : "accepted by the receiving server";
|
|
350
|
+
render.line("ok", label, ms);
|
|
351
|
+
render.note(ms == null ? `Delivered to ${to}.` : `Delivered to ${to} in ${formatDuration(ms)}.`);
|
|
352
|
+
return finish2(WATCH_EXIT.delivered);
|
|
353
|
+
}
|
|
354
|
+
if (FAILED_STATUSES.has(email.status)) {
|
|
355
|
+
const host = delivery?.receiving_server ?? null;
|
|
356
|
+
const reply = delivery?.response ?? email.bounce_reason ?? null;
|
|
357
|
+
const ms = between(timedFrom(email), email.bounced_at);
|
|
358
|
+
const advice = outcomeAdvice(email);
|
|
359
|
+
if (json) {
|
|
360
|
+
emit({
|
|
361
|
+
stage: email.status,
|
|
362
|
+
id: email.id,
|
|
363
|
+
status: email.status,
|
|
364
|
+
bounce_type: email.bounce_type ?? null,
|
|
365
|
+
receiving_server: host,
|
|
366
|
+
response: reply,
|
|
367
|
+
elapsed_ms: ms,
|
|
368
|
+
advice
|
|
369
|
+
});
|
|
370
|
+
return WATCH_EXIT.notDelivered;
|
|
371
|
+
}
|
|
372
|
+
render.stop();
|
|
373
|
+
const label = email.status === "cancelled" ? "cancelled" : email.status === "suppressed" ? "not sent" : host ? `refused by ${host}` : email.sent_at ? "refused by the receiving server" : "not sent";
|
|
374
|
+
render.line("fail", label, ms);
|
|
375
|
+
if (reply) render.detail(reply);
|
|
376
|
+
render.note(advice);
|
|
377
|
+
return finish2(WATCH_EXIT.notDelivered);
|
|
378
|
+
}
|
|
379
|
+
if (email.status === "held") {
|
|
380
|
+
if (!heldShown) {
|
|
381
|
+
heldShown = true;
|
|
382
|
+
if (json) emit({ stage: "held", id: email.id });
|
|
383
|
+
else {
|
|
384
|
+
render.line("warn", "held before sending");
|
|
385
|
+
render.note("Held for content review, or sending is paused on the account. It goes out or is rejected after review.");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (!json) render.waiting("held, waiting for review", startedAt);
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
if (email.status === "queued") {
|
|
392
|
+
if (!json) render.waiting("queued", startedAt);
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
await awaitMx();
|
|
396
|
+
showDelivering(email, deferrals.size > 0);
|
|
397
|
+
return null;
|
|
398
|
+
};
|
|
399
|
+
let consecutiveErrors = 0;
|
|
400
|
+
let lastError = null;
|
|
401
|
+
for (; ; ) {
|
|
402
|
+
const remaining = deadline - now();
|
|
403
|
+
let result = null;
|
|
404
|
+
let rateLimited = false;
|
|
405
|
+
try {
|
|
406
|
+
result = await withTimeout(deps.getEmail(options.id), Math.max(remaining, 1));
|
|
407
|
+
consecutiveErrors = 0;
|
|
408
|
+
} catch (error) {
|
|
409
|
+
const status = errorStatus(error);
|
|
410
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
411
|
+
if (status !== null && status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
|
412
|
+
render.stop();
|
|
413
|
+
const hint = error.hint;
|
|
414
|
+
if (json) emit({ stage: "error", id: options.id, status, code: error.code ?? null, message, hint: hint ?? null });
|
|
415
|
+
else {
|
|
416
|
+
stderr(message);
|
|
417
|
+
if (hint) stderr(hint);
|
|
418
|
+
}
|
|
419
|
+
return WATCH_EXIT.error;
|
|
420
|
+
}
|
|
421
|
+
rateLimited = status === 429;
|
|
422
|
+
consecutiveErrors += 1;
|
|
423
|
+
lastError = message;
|
|
424
|
+
}
|
|
425
|
+
if (result && result !== TIMED_OUT) {
|
|
426
|
+
const code = await step(result.email);
|
|
427
|
+
if (code !== null) return finish2(code);
|
|
428
|
+
}
|
|
429
|
+
if (now() >= deadline || result === TIMED_OUT) break;
|
|
430
|
+
const base = pollDelayMs(now() - watchStart);
|
|
431
|
+
const delay = rateLimited ? Math.max(RATE_LIMITED_DELAY_MS, base) : consecutiveErrors ? Math.min(5e3, base * 2 ** consecutiveErrors) : base;
|
|
432
|
+
await sleep(Math.min(delay, Math.max(0, deadline - now())));
|
|
433
|
+
if (now() >= deadline) {
|
|
434
|
+
const last = await withTimeout(deps.getEmail(options.id), 2e3).catch(() => null);
|
|
435
|
+
if (last && last !== TIMED_OUT) {
|
|
436
|
+
const code = await step(last.email);
|
|
437
|
+
if (code !== null) return finish2(code);
|
|
438
|
+
}
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const waited = now() - watchStart;
|
|
443
|
+
render.settle("wait");
|
|
444
|
+
render.stop();
|
|
445
|
+
if (json) {
|
|
446
|
+
emit({ stage: "timeout", id: options.id, status: lastStatus, waited_ms: waited, check: `groundmail emails get ${options.id}` });
|
|
447
|
+
} else {
|
|
448
|
+
render.note(`Still on its way after ${formatDuration(waited)} (status: ${lastStatus}). Check on it later:`);
|
|
449
|
+
render.note(` groundmail emails get ${options.id}`);
|
|
450
|
+
if (consecutiveErrors && lastError) render.note(`(The last status check failed: ${lastError})`);
|
|
451
|
+
}
|
|
452
|
+
return WATCH_EXIT.timeout;
|
|
453
|
+
}
|
|
454
|
+
var TEST_SEND_NOTE = "Test send: simulated, never delivered. Test keys (gm_test_\u2026) and simulator addresses never reach a real mail server, so there is nothing to watch.";
|
|
455
|
+
function reportTestSend(io, input) {
|
|
456
|
+
if (input.json) {
|
|
457
|
+
io.write(`${JSON.stringify({ stage: "test", id: input.id, status: input.status })}
|
|
458
|
+
`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const render = new Renderer(io, Date.now, 0);
|
|
462
|
+
render.line("ok", "accepted by Groundmail (test send)");
|
|
463
|
+
render.note(TEST_SEND_NOTE);
|
|
464
|
+
}
|
|
465
|
+
function parseWatchTimeout(raw) {
|
|
466
|
+
if (raw === void 0) return { ms: DEFAULT_WATCH_TIMEOUT_SECONDS * 1e3 };
|
|
467
|
+
const seconds = Number(raw);
|
|
468
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return { error: "--timeout must be a number of seconds above 0" };
|
|
469
|
+
return { ms: Math.round(seconds * 1e3) };
|
|
470
|
+
}
|
|
471
|
+
function processWatchIo(env = process.env) {
|
|
472
|
+
return {
|
|
473
|
+
write: (text) => {
|
|
474
|
+
process.stdout.write(text);
|
|
475
|
+
},
|
|
476
|
+
isTTY: Boolean(process.stdout.isTTY) && env.TERM !== "dumb",
|
|
477
|
+
columns: process.stdout.columns,
|
|
478
|
+
color: !env.NO_COLOR
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
4
482
|
// src/commands/emails/send.ts
|
|
483
|
+
var SANDBOX_FROM = "noreply@mail.groundmail.dev";
|
|
484
|
+
function describeSendError(error) {
|
|
485
|
+
const e = error ?? {};
|
|
486
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
487
|
+
const status = typeof e.status === "number" ? e.status : null;
|
|
488
|
+
const code = typeof e.code === "string" ? e.code : null;
|
|
489
|
+
const hint = typeof e.hint === "string" && e.hint ? e.hint : null;
|
|
490
|
+
const lines = [message];
|
|
491
|
+
if (status === 403 && /sandbox/i.test(message)) {
|
|
492
|
+
const account = message.match(/limited to (\S+@\S+?)\.(?:\s|$)/)?.[1];
|
|
493
|
+
lines.push(
|
|
494
|
+
`The sandbox sender (${SANDBOX_FROM}) only sends to your account email${account ? `, ${account}` : " (groundmail whoami shows it)"}. Watch one land there:`,
|
|
495
|
+
` groundmail emails send --to ${account ?? "<your account email>"} --subject "Hello" --text "Hi" --watch`,
|
|
496
|
+
"To send to anyone else, verify your domain (groundmail domains add example.com), add a payment method at https://app.groundmail.dev/settings/billing, and pass --from you@example.com."
|
|
497
|
+
);
|
|
498
|
+
} else if (hint) {
|
|
499
|
+
lines.push(hint);
|
|
500
|
+
}
|
|
501
|
+
return { message, status, code, hint, lines };
|
|
502
|
+
}
|
|
5
503
|
function readFlag(args, flag) {
|
|
6
504
|
const index = args.indexOf(flag);
|
|
7
505
|
if (index === -1) return void 0;
|
|
@@ -52,13 +550,28 @@ async function runSendCommand(args, deps) {
|
|
|
52
550
|
const env = deps.env ?? process.env;
|
|
53
551
|
const stdout = deps.stdout ?? ((line) => console.log(line));
|
|
54
552
|
const stderr = deps.stderr ?? ((line) => console.error(line));
|
|
553
|
+
const now = deps.now ?? Date.now;
|
|
55
554
|
const options = parseSendCommandOptions(args, env);
|
|
56
555
|
if ("error" in options) {
|
|
57
556
|
stderr(options.error);
|
|
58
557
|
return 1;
|
|
59
558
|
}
|
|
559
|
+
const watch = args.includes("--watch");
|
|
560
|
+
const json = args.includes("--json");
|
|
561
|
+
const timeout = parseWatchTimeout(readFlag(args, "--timeout"));
|
|
562
|
+
if (watch && "error" in timeout) {
|
|
563
|
+
stderr(timeout.error);
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
if (options.fromDefaulted) {
|
|
567
|
+
stderr(`No --from: sending from the sandbox sender ${SANDBOX_FROM}, which only sends to your account email. Pass --from to send from your own domain.`);
|
|
568
|
+
}
|
|
569
|
+
const startedAt = now();
|
|
570
|
+
const recipientDomain2 = options.to.split(",")[0]?.split("@")[1]?.replace(/>$/, "").trim();
|
|
571
|
+
const mx = watch && deps.resolveMx && recipientDomain2 ? deps.resolveMx(recipientDomain2).catch(() => null) : void 0;
|
|
572
|
+
let result;
|
|
60
573
|
try {
|
|
61
|
-
|
|
574
|
+
result = await deps.sendEmail({
|
|
62
575
|
from: options.from,
|
|
63
576
|
to: options.to,
|
|
64
577
|
subject: options.subject,
|
|
@@ -70,15 +583,43 @@ async function runSendCommand(args, deps) {
|
|
|
70
583
|
unsubscribe: options.unsubscribe,
|
|
71
584
|
...options.tenant ? { tenant: options.tenant } : {}
|
|
72
585
|
});
|
|
586
|
+
} catch (error) {
|
|
587
|
+
const described = describeSendError(error);
|
|
588
|
+
if (watch && json) {
|
|
589
|
+
stdout(JSON.stringify({ stage: "error", status: described.status, code: described.code, message: described.message, hint: described.hint }));
|
|
590
|
+
} else {
|
|
591
|
+
for (const line of described.lines) stderr(line);
|
|
592
|
+
}
|
|
593
|
+
return 1;
|
|
594
|
+
}
|
|
595
|
+
if (!watch) {
|
|
73
596
|
stdout(JSON.stringify(result));
|
|
74
597
|
if (result.test) {
|
|
75
598
|
stderr("Test send: simulated, nothing was delivered. Its status and webhooks are marked test.");
|
|
76
599
|
}
|
|
77
600
|
return 0;
|
|
78
|
-
}
|
|
79
|
-
|
|
601
|
+
}
|
|
602
|
+
const io = deps.watchIo ?? processWatchIo(env);
|
|
603
|
+
if (result.test) {
|
|
604
|
+
reportTestSend(io, { id: result.id, status: result.status, json });
|
|
605
|
+
return WATCH_EXIT.test;
|
|
606
|
+
}
|
|
607
|
+
if (!deps.getEmail) {
|
|
608
|
+
stderr("--watch needs a client that can read the email.");
|
|
80
609
|
return 1;
|
|
81
610
|
}
|
|
611
|
+
return watchEmail(
|
|
612
|
+
{ id: result.id, timeoutMs: "ms" in timeout ? timeout.ms : 6e4, json, startedAt, to: [options.to], mx },
|
|
613
|
+
{
|
|
614
|
+
getEmail: deps.getEmail,
|
|
615
|
+
resolveMx: deps.resolveMx,
|
|
616
|
+
io,
|
|
617
|
+
stderr,
|
|
618
|
+
now: deps.now,
|
|
619
|
+
sleep: deps.sleep,
|
|
620
|
+
spinnerMs: deps.spinnerMs
|
|
621
|
+
}
|
|
622
|
+
);
|
|
82
623
|
}
|
|
83
624
|
function parseSendCommandOptions(args, env) {
|
|
84
625
|
const parsed = parseSendCommandArgs(args);
|
|
@@ -90,14 +631,15 @@ function parseSendCommandOptions(args, env) {
|
|
|
90
631
|
if ("error" in parsedTags) {
|
|
91
632
|
return { error: parsedTags.error };
|
|
92
633
|
}
|
|
93
|
-
if (!parsed.
|
|
94
|
-
return { error:
|
|
634
|
+
if (!parsed.to || !parsed.subject) {
|
|
635
|
+
return { error: `Required flags: --to --subject (and --html or --text). --from defaults to the sandbox sender ${SANDBOX_FROM} (your account email only).` };
|
|
95
636
|
}
|
|
96
637
|
if (!parsed.html && !parsed.text) {
|
|
97
638
|
return { error: "Provide --html or --text" };
|
|
98
639
|
}
|
|
99
640
|
return {
|
|
100
|
-
from: parsed.from,
|
|
641
|
+
from: parsed.from || SANDBOX_FROM,
|
|
642
|
+
fromDefaulted: !parsed.from,
|
|
101
643
|
to: parsed.to,
|
|
102
644
|
subject: parsed.subject,
|
|
103
645
|
html: parsed.html,
|
|
@@ -112,12 +654,12 @@ function parseSendCommandOptions(args, env) {
|
|
|
112
654
|
};
|
|
113
655
|
}
|
|
114
656
|
function createDefaultSendDeps(apiKey, baseUrl) {
|
|
657
|
+
let client2 = null;
|
|
658
|
+
const getClient = () => client2 ?? (client2 = import("groundmail").then(({ Groundmail }) => new Groundmail(apiKey, baseUrl ? { baseUrl } : void 0)));
|
|
115
659
|
return {
|
|
116
|
-
sendEmail: async (params) =>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return client2.emails.send(params);
|
|
120
|
-
}
|
|
660
|
+
sendEmail: async (params) => (await getClient()).emails.send(params),
|
|
661
|
+
getEmail: async (id) => (await getClient()).emails.get(id),
|
|
662
|
+
resolveMx: lookupMx
|
|
121
663
|
};
|
|
122
664
|
}
|
|
123
665
|
async function runSendCommandWithClient(args, env = process.env) {
|
|
@@ -294,16 +836,30 @@ async function runResendCommandWithClient(args, env = process.env) {
|
|
|
294
836
|
}
|
|
295
837
|
|
|
296
838
|
// src/commands/emails/get.ts
|
|
839
|
+
var VALUE_FLAGS = /* @__PURE__ */ new Set(["--id", "--api-key", "--base-url", "--timeout", "--workspace"]);
|
|
297
840
|
function readFlag5(args, flag) {
|
|
298
841
|
const index = args.indexOf(flag);
|
|
299
842
|
if (index === -1) return void 0;
|
|
300
843
|
return args[index + 1];
|
|
301
844
|
}
|
|
845
|
+
function emailIdFromArgs(args) {
|
|
846
|
+
const flagged = readFlag5(args, "--id");
|
|
847
|
+
if (flagged) return flagged;
|
|
848
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
849
|
+
const arg = args[index];
|
|
850
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
851
|
+
index += 1;
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (!arg.startsWith("--")) return arg;
|
|
855
|
+
}
|
|
856
|
+
return void 0;
|
|
857
|
+
}
|
|
302
858
|
async function runGetCommand(args, deps) {
|
|
303
859
|
const env = deps.env ?? process.env;
|
|
304
860
|
const stdout = deps.stdout ?? ((line) => console.log(line));
|
|
305
861
|
const stderr = deps.stderr ?? ((line) => console.error(line));
|
|
306
|
-
const id =
|
|
862
|
+
const id = emailIdFromArgs(args);
|
|
307
863
|
const apiKey = readFlag5(args, "--api-key") || env.GROUNDMAIL_API_KEY;
|
|
308
864
|
if (!apiKey) {
|
|
309
865
|
stderr("Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key.");
|
|
@@ -313,6 +869,26 @@ async function runGetCommand(args, deps) {
|
|
|
313
869
|
stderr("Required: --id <msg_\u2026>");
|
|
314
870
|
return { exitCode: 1, error: "missing id" };
|
|
315
871
|
}
|
|
872
|
+
if (args.includes("--watch")) {
|
|
873
|
+
const timeout = parseWatchTimeout(readFlag5(args, "--timeout"));
|
|
874
|
+
if ("error" in timeout) {
|
|
875
|
+
stderr(timeout.error);
|
|
876
|
+
return { exitCode: 1, error: timeout.error };
|
|
877
|
+
}
|
|
878
|
+
const exitCode = await watchEmail(
|
|
879
|
+
{ id, timeoutMs: timeout.ms, json: args.includes("--json") },
|
|
880
|
+
{
|
|
881
|
+
getEmail: deps.getEmail,
|
|
882
|
+
resolveMx: deps.resolveMx,
|
|
883
|
+
io: deps.watchIo ?? processWatchIo(env),
|
|
884
|
+
stderr,
|
|
885
|
+
now: deps.now,
|
|
886
|
+
sleep: deps.sleep,
|
|
887
|
+
spinnerMs: deps.spinnerMs
|
|
888
|
+
}
|
|
889
|
+
);
|
|
890
|
+
return { exitCode };
|
|
891
|
+
}
|
|
316
892
|
try {
|
|
317
893
|
const result = await deps.getEmail(id);
|
|
318
894
|
stdout(JSON.stringify(result));
|
|
@@ -330,10 +906,12 @@ async function runGetCommandWithClient(args, env = process.env) {
|
|
|
330
906
|
}
|
|
331
907
|
const { Groundmail } = await import("groundmail");
|
|
332
908
|
const client2 = new Groundmail(apiKey, baseUrl ? { baseUrl } : void 0);
|
|
333
|
-
|
|
909
|
+
const result = await runGetCommand(args, {
|
|
334
910
|
env,
|
|
335
|
-
getEmail: (id) => client2.emails.get(id)
|
|
911
|
+
getEmail: (id) => client2.emails.get(id),
|
|
912
|
+
resolveMx: lookupMx
|
|
336
913
|
});
|
|
914
|
+
return { exitCode: result.exitCode };
|
|
337
915
|
}
|
|
338
916
|
|
|
339
917
|
// src/commands/emails/insights.ts
|
|
@@ -737,18 +1315,25 @@ async function createDomainsClient(apiKey, baseUrl) {
|
|
|
737
1315
|
const { Groundmail } = await import("groundmail");
|
|
738
1316
|
return new Groundmail(apiKey, baseUrl ? { baseUrl } : void 0);
|
|
739
1317
|
}
|
|
1318
|
+
function pushRecord(lines, record) {
|
|
1319
|
+
lines.push(` ${record.type.padEnd(6)} ${record.host}`);
|
|
1320
|
+
if (record.type === "MX" && record.priority != null) {
|
|
1321
|
+
lines.push(` priority: ${record.priority}`);
|
|
1322
|
+
lines.push(` mail server: ${record.value}`);
|
|
1323
|
+
} else {
|
|
1324
|
+
lines.push(` ${record.value}`);
|
|
1325
|
+
}
|
|
1326
|
+
lines.push(` (${record.purpose})`);
|
|
1327
|
+
lines.push("");
|
|
1328
|
+
}
|
|
740
1329
|
function formatDnsRecords(records) {
|
|
1330
|
+
const toAdd = records.filter((record) => !record.already_set);
|
|
1331
|
+
const alreadySet = records.filter((record) => record.already_set);
|
|
741
1332
|
const lines = ["DNS records to add:", ""];
|
|
742
|
-
for (const record of
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
lines.push(` mail server: ${record.value}`);
|
|
747
|
-
} else {
|
|
748
|
-
lines.push(` ${record.value}`);
|
|
749
|
-
}
|
|
750
|
-
lines.push(` (${record.purpose})`);
|
|
751
|
-
lines.push("");
|
|
1333
|
+
for (const record of toAdd) pushRecord(lines, record);
|
|
1334
|
+
if (alreadySet.length) {
|
|
1335
|
+
lines.push("Already set, nothing to change:", "");
|
|
1336
|
+
for (const record of alreadySet) pushRecord(lines, record);
|
|
752
1337
|
}
|
|
753
1338
|
return lines.join("\n").trimEnd();
|
|
754
1339
|
}
|
|
@@ -1466,7 +2051,7 @@ function trimUrl(value) {
|
|
|
1466
2051
|
}
|
|
1467
2052
|
|
|
1468
2053
|
// package.json
|
|
1469
|
-
var version = "0.
|
|
2054
|
+
var version = "0.31.0";
|
|
1470
2055
|
|
|
1471
2056
|
// src/user-agent.ts
|
|
1472
2057
|
function buildUserAgent(own) {
|
|
@@ -2774,7 +3359,7 @@ async function loadTemplateSource(filePath, options = {}, deps = {}) {
|
|
|
2774
3359
|
}
|
|
2775
3360
|
|
|
2776
3361
|
// src/commands/templates/push.ts
|
|
2777
|
-
var
|
|
3362
|
+
var VALUE_FLAGS2 = /* @__PURE__ */ new Set([
|
|
2778
3363
|
"--alias",
|
|
2779
3364
|
"--subject",
|
|
2780
3365
|
"--name",
|
|
@@ -2788,7 +3373,7 @@ function readPositional(args) {
|
|
|
2788
3373
|
for (let index = 0; index < args.length; index += 1) {
|
|
2789
3374
|
const arg = args[index];
|
|
2790
3375
|
if (arg.startsWith("--")) {
|
|
2791
|
-
if (
|
|
3376
|
+
if (VALUE_FLAGS2.has(arg)) index += 1;
|
|
2792
3377
|
continue;
|
|
2793
3378
|
}
|
|
2794
3379
|
return arg;
|
|
@@ -3485,7 +4070,7 @@ var KEY_ENV = {
|
|
|
3485
4070
|
mailgun: "MAILGUN_API_KEY"
|
|
3486
4071
|
};
|
|
3487
4072
|
var LABELS = { sendgrid: "SendGrid", postmark: "Postmark", mailgun: "Mailgun" };
|
|
3488
|
-
var
|
|
4073
|
+
var VALUE_FLAGS3 = /* @__PURE__ */ new Set(["--api-key", "--base-url"]);
|
|
3489
4074
|
function readFlag22(args, flag) {
|
|
3490
4075
|
const index = args.indexOf(flag);
|
|
3491
4076
|
if (index === -1) return void 0;
|
|
@@ -3572,7 +4157,7 @@ async function runStoredCommand(sub, args, env) {
|
|
|
3572
4157
|
if (!data.length) console.error("No imports yet. Previews are not stored.");
|
|
3573
4158
|
return { exitCode: 0 };
|
|
3574
4159
|
}
|
|
3575
|
-
const id = args.find((arg, i) => !arg.startsWith("--") && !
|
|
4160
|
+
const id = args.find((arg, i) => !arg.startsWith("--") && !VALUE_FLAGS3.has(args[i - 1] ?? ""));
|
|
3576
4161
|
if (!id) return { exitCode: 1, error: "Usage: groundmail migrate report <import id> [--csv]" };
|
|
3577
4162
|
if (args.includes("--csv")) {
|
|
3578
4163
|
process.stdout.write(await gm.migrations.reportCsv(id));
|
|
@@ -3676,12 +4261,12 @@ function readFlag23(args, flag) {
|
|
|
3676
4261
|
if (index === -1) return void 0;
|
|
3677
4262
|
return args[index + 1];
|
|
3678
4263
|
}
|
|
3679
|
-
var
|
|
4264
|
+
var VALUE_FLAGS4 = ["--api-key", "--base-url", "--domain", "--apply"];
|
|
3680
4265
|
function readFirstPositional15(args) {
|
|
3681
4266
|
for (let i = 0; i < args.length; i += 1) {
|
|
3682
4267
|
const arg = args[i];
|
|
3683
4268
|
if (arg.startsWith("--")) {
|
|
3684
|
-
if (
|
|
4269
|
+
if (VALUE_FLAGS4.includes(arg)) i += 1;
|
|
3685
4270
|
continue;
|
|
3686
4271
|
}
|
|
3687
4272
|
return arg;
|
|
@@ -3873,11 +4458,12 @@ var USAGE_LINES = [
|
|
|
3873
4458
|
" groundmail test-inbox address [--prefix signup] a fresh address on your team's test domain (mail there is caught, never delivered)",
|
|
3874
4459
|
" groundmail test-inbox wait --to <address> [--timeout 30] [--since <iso>] [--json] wait for an email; prints its code and links (exit 2 on timeout)",
|
|
3875
4460
|
" groundmail test-inbox list [--to <address>] [--since <iso>] [--json]",
|
|
3876
|
-
' groundmail emails send --from you@domain.com --to user@example.com --subject "Hello" --html "<p>Hi</p>" [--category transactional|product|newsletter] [--unsubscribe] [--tenant <tenant>]',
|
|
4461
|
+
' groundmail emails send [--from you@domain.com] --to user@example.com --subject "Hello" --html "<p>Hi</p>" [--category transactional|product|newsletter] [--unsubscribe] [--tenant <tenant>] [--watch [--timeout 60] [--json]] no --from: the sandbox sender (to your account email only)',
|
|
4462
|
+
' groundmail emails send --to you@example.com --subject "Hello" --text "Hi" --watch watch it land: the receiving server, its reply and the time',
|
|
3877
4463
|
" groundmail emails batch --file ./emails.json [--tenant <tenant>]",
|
|
3878
4464
|
" groundmail emails list [--limit 25] [--cursor \u2026] [--test | --live] [--tenant <tenant>] [--stream transactional|product|newsletter]",
|
|
3879
4465
|
" groundmail emails insights [--window 7d|30d]",
|
|
3880
|
-
" groundmail emails get --id msg_\u2026",
|
|
4466
|
+
" groundmail emails get --id msg_\u2026 [--watch [--timeout 60] [--json]] --watch: follow an email you already sent until it lands",
|
|
3881
4467
|
" groundmail emails cancel --id msg_\u2026",
|
|
3882
4468
|
" groundmail emails resend --id msg_\u2026",
|
|
3883
4469
|
` groundmail events send --name user.created --email user@example.com --data '{"name":"Ada"}'`,
|
|
@@ -3953,6 +4539,8 @@ var FOOTER_LINES = [
|
|
|
3953
4539
|
"",
|
|
3954
4540
|
"<tenant>: the tenant's external id (your id for that customer) or its Groundmail id.",
|
|
3955
4541
|
"",
|
|
4542
|
+
"--watch exit codes: 0 delivered, 1 error, 3 bounced or failed, 4 still on its way at --timeout, 5 test send (never delivered).",
|
|
4543
|
+
"",
|
|
3956
4544
|
"Add --help to any command for just its usage. --version prints the CLI version."
|
|
3957
4545
|
];
|
|
3958
4546
|
var CLI_VERSION = version;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groundmail-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Groundmail CLI — send transactional email from the terminal",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"prepublishOnly": "npm run build"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"groundmail": "0.
|
|
41
|
+
"groundmail": "0.30.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"tsup": "^8.5.0",
|