groundmail-cli 0.30.1 → 0.32.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.
Files changed (3) hide show
  1. package/README.md +18 -1
  2. package/dist/cli.js +671 -44
  3. 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
@@ -58,7 +75,7 @@ groundmail domains list
58
75
  groundmail domains verify example.com
59
76
  groundmail webhooks list
60
77
  groundmail emails list
61
- SENDGRID_API_KEY=… groundmail migrate sendgrid --dry-run # import from SendGrid, Postmark or Mailgun (beta)
78
+ SENDGRID_API_KEY=… groundmail migrate sendgrid --dry-run # import from SendGrid, Postmark, Mailgun, Resend or Amazon SES (beta)
62
79
  ```
63
80
 
64
81
  Run `groundmail --help` or `groundmail <command> --help` for flags.
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
- const result = await deps.sendEmail({
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
- } catch (error) {
79
- stderr(error instanceof Error ? error.message : String(error));
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.from || !parsed.to || !parsed.subject) {
94
- return { error: "Required flags: --from --to --subject (and --html or --text)" };
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
- const { Groundmail } = await import("groundmail");
118
- const client2 = new Groundmail(apiKey, baseUrl ? { baseUrl } : void 0);
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 = readFlag5(args, "--id") || args.find((a) => !a.startsWith("--"));
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
- return runGetCommand(args, {
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
@@ -1473,7 +2051,7 @@ function trimUrl(value) {
1473
2051
  }
1474
2052
 
1475
2053
  // package.json
1476
- var version = "0.30.1";
2054
+ var version = "0.32.0";
1477
2055
 
1478
2056
  // src/user-agent.ts
1479
2057
  function buildUserAgent(own) {
@@ -2781,7 +3359,7 @@ async function loadTemplateSource(filePath, options = {}, deps = {}) {
2781
3359
  }
2782
3360
 
2783
3361
  // src/commands/templates/push.ts
2784
- var VALUE_FLAGS = /* @__PURE__ */ new Set([
3362
+ var VALUE_FLAGS2 = /* @__PURE__ */ new Set([
2785
3363
  "--alias",
2786
3364
  "--subject",
2787
3365
  "--name",
@@ -2795,7 +3373,7 @@ function readPositional(args) {
2795
3373
  for (let index = 0; index < args.length; index += 1) {
2796
3374
  const arg = args[index];
2797
3375
  if (arg.startsWith("--")) {
2798
- if (VALUE_FLAGS.has(arg)) index += 1;
3376
+ if (VALUE_FLAGS2.has(arg)) index += 1;
2799
3377
  continue;
2800
3378
  }
2801
3379
  return arg;
@@ -3484,15 +4062,44 @@ async function switchWorkspace(argv, deps) {
3484
4062
  }
3485
4063
 
3486
4064
  // src/commands/migrate.ts
3487
- var PROVIDERS2 = ["sendgrid", "postmark", "mailgun"];
4065
+ var PROVIDERS2 = ["sendgrid", "postmark", "mailgun", "resend", "ses"];
3488
4066
  var RESOURCES = ["domains", "webhooks", "templates", "suppressions"];
3489
4067
  var KEY_ENV = {
3490
4068
  sendgrid: "SENDGRID_API_KEY",
3491
4069
  postmark: "POSTMARK_SERVER_TOKEN",
3492
- mailgun: "MAILGUN_API_KEY"
4070
+ mailgun: "MAILGUN_API_KEY",
4071
+ resend: "RESEND_API_KEY"
4072
+ };
4073
+ var LABELS = {
4074
+ sendgrid: "SendGrid",
4075
+ postmark: "Postmark",
4076
+ mailgun: "Mailgun",
4077
+ resend: "Resend",
4078
+ ses: "Amazon SES"
3493
4079
  };
3494
- var LABELS = { sendgrid: "SendGrid", postmark: "Postmark", mailgun: "Mailgun" };
3495
- var VALUE_FLAGS2 = /* @__PURE__ */ new Set(["--api-key", "--base-url"]);
4080
+ var AWS_REGION = /^[a-z]{2}(?:-gov)?-[a-z]+-\d{1,2}$/;
4081
+ function sesParams(args, env) {
4082
+ const accessKeyId = env.AWS_ACCESS_KEY_ID;
4083
+ const secret = env.AWS_SECRET_ACCESS_KEY;
4084
+ if (!accessKeyId || !secret) {
4085
+ return [
4086
+ "Missing AWS credentials. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (plus AWS_SESSION_TOKEN for temporary credentials),",
4087
+ 'or load them from your AWS CLI profile: eval "$(aws configure export-credentials --format env)".',
4088
+ "Use a key with the read-only import policy (https://docs.groundmail.dev/migrations#amazon-ses-iam-policy). It is used for this import only and never stored."
4089
+ ].join("\n");
4090
+ }
4091
+ const region = readFlag22(args, "--region") || env.AWS_REGION || env.AWS_DEFAULT_REGION;
4092
+ if (!region) return "Set the SES region with --region (for example --region us-east-1) or AWS_REGION. One region per import.";
4093
+ if (!AWS_REGION.test(region)) return `--region must be an AWS region such as us-east-1, not ${region}`;
4094
+ return {
4095
+ provider: "ses",
4096
+ aws_access_key_id: accessKeyId,
4097
+ aws_secret_access_key: secret,
4098
+ ...env.AWS_SESSION_TOKEN ? { aws_session_token: env.AWS_SESSION_TOKEN } : {},
4099
+ aws_region: region
4100
+ };
4101
+ }
4102
+ var VALUE_FLAGS3 = /* @__PURE__ */ new Set(["--api-key", "--base-url"]);
3496
4103
  function readFlag22(args, flag) {
3497
4104
  const index = args.indexOf(flag);
3498
4105
  if (index === -1) return void 0;
@@ -3510,7 +4117,9 @@ function formatMigrationSummary(m) {
3510
4117
  lines.push(` Domains ${n(m.domains.totals.imported)} of ${n(m.domains.totals.found)}`);
3511
4118
  }
3512
4119
  if (m.webhooks) {
3513
- lines.push(` Webhooks ${n(m.webhooks.totals.imported)} of ${n(m.webhooks.totals.found)} (created disabled)`);
4120
+ lines.push(
4121
+ m.provider === "ses" ? " Webhooks reported, not imported (SES has no webhooks; see the notes)" : ` Webhooks ${n(m.webhooks.totals.imported)} of ${n(m.webhooks.totals.found)} (created disabled)`
4122
+ );
3514
4123
  }
3515
4124
  if (m.templates) {
3516
4125
  const t = m.templates.totals;
@@ -3555,6 +4164,10 @@ function formatMigrationSummary(m) {
3555
4164
  lines.push("", "Next:");
3556
4165
  for (const line of m.attention) lines.push(` - ${line}`);
3557
4166
  }
4167
+ if (m.notes.length) {
4168
+ lines.push("", "Notes:");
4169
+ for (const line of m.notes) lines.push(` - ${line}`);
4170
+ }
3558
4171
  const node = m.code_changes?.find((c) => c.stack === "node");
3559
4172
  if (node) lines.push("", "Code change (Node.js):", node.diff);
3560
4173
  if (m.agent_prompt) lines.push("", "Prompt for your coding agent is in the JSON report (`agent_prompt`).");
@@ -3579,7 +4192,7 @@ async function runStoredCommand(sub, args, env) {
3579
4192
  if (!data.length) console.error("No imports yet. Previews are not stored.");
3580
4193
  return { exitCode: 0 };
3581
4194
  }
3582
- const id = args.find((arg, i) => !arg.startsWith("--") && !VALUE_FLAGS2.has(args[i - 1] ?? ""));
4195
+ const id = args.find((arg, i) => !arg.startsWith("--") && !VALUE_FLAGS3.has(args[i - 1] ?? ""));
3583
4196
  if (!id) return { exitCode: 1, error: "Usage: groundmail migrate report <import id> [--csv]" };
3584
4197
  if (args.includes("--csv")) {
3585
4198
  process.stdout.write(await gm.migrations.reportCsv(id));
@@ -3598,7 +4211,7 @@ async function runMigrateCommand(provider, args, env = process.env) {
3598
4211
  if (!provider || !PROVIDERS2.includes(provider)) {
3599
4212
  return {
3600
4213
  exitCode: 1,
3601
- error: "Usage: groundmail migrate <sendgrid|postmark|mailgun> [--dry-run] [--only templates,suppressions], groundmail migrate list, or groundmail migrate report <id> [--csv]"
4214
+ error: "Usage: groundmail migrate <sendgrid|postmark|mailgun|resend|ses> [--dry-run] [--only templates,suppressions], groundmail migrate list, or groundmail migrate report <id> [--csv]"
3602
4215
  };
3603
4216
  }
3604
4217
  const id = provider;
@@ -3606,21 +4219,29 @@ async function runMigrateCommand(provider, args, env = process.env) {
3606
4219
  if (!apiKey) {
3607
4220
  return { exitCode: 1, error: "Not signed in. Run `groundmail login`, or set GROUNDMAIL_API_KEY." };
3608
4221
  }
3609
- const providerKey = readFlag22(args, "--key") || env[KEY_ENV[id]];
3610
- if (!providerKey) {
3611
- return {
3612
- exitCode: 1,
3613
- error: `Missing ${LABELS[id]} key. Set ${KEY_ENV[id]} (or pass --key). It is used for this import only and never stored.`
3614
- };
3615
- }
3616
- const params = { provider: id, api_key: providerKey };
3617
- if (id === "postmark") {
3618
- const accountToken = readFlag22(args, "--account-token") || env.POSTMARK_ACCOUNT_TOKEN;
3619
- if (accountToken) params.account_api_key = accountToken;
4222
+ let params;
4223
+ if (id === "ses") {
4224
+ const ses = sesParams(args, env);
4225
+ if (typeof ses === "string") return { exitCode: 1, error: ses };
4226
+ params = ses;
3620
4227
  } else {
3621
- const region = readFlag22(args, "--region");
3622
- if (region === "us" || region === "eu") params.region = region;
3623
- else if (region) return { exitCode: 1, error: "--region must be us or eu" };
4228
+ const providerKey = readFlag22(args, "--key") || env[KEY_ENV[id]];
4229
+ if (!providerKey) {
4230
+ const resend = id === "resend" ? " Use a key with Full access (Resend \u2192 API Keys); a Sending access key cannot read your account." : "";
4231
+ return {
4232
+ exitCode: 1,
4233
+ error: `Missing ${LABELS[id]} key. Set ${KEY_ENV[id]} (or pass --key).${resend} It is used for this import only and never stored.`
4234
+ };
4235
+ }
4236
+ params = { provider: id, api_key: providerKey };
4237
+ if (id === "postmark") {
4238
+ const accountToken = readFlag22(args, "--account-token") || env.POSTMARK_ACCOUNT_TOKEN;
4239
+ if (accountToken) params.account_api_key = accountToken;
4240
+ } else if (id === "sendgrid" || id === "mailgun") {
4241
+ const region = readFlag22(args, "--region");
4242
+ if (region === "us" || region === "eu") params.region = region;
4243
+ else if (region) return { exitCode: 1, error: "--region must be us or eu" };
4244
+ }
3624
4245
  }
3625
4246
  const only = readFlag22(args, "--only");
3626
4247
  if (only) {
@@ -3636,7 +4257,7 @@ async function runMigrateCommand(provider, args, env = process.env) {
3636
4257
  const { Groundmail } = await import("groundmail");
3637
4258
  const baseUrl = readFlag22(args, "--base-url");
3638
4259
  const client2 = new Groundmail(apiKey, baseUrl ? { baseUrl } : void 0);
3639
- console.error(`${params.dry_run ? "Previewing" : "Importing"} from ${LABELS[id]} (Beta)\u2026`);
4260
+ console.error(`${params.dry_run ? "Previewing" : "Importing"} from ${LABELS[id]}${params.provider === "ses" ? ` (${params.aws_region})` : ""} (Beta)\u2026`);
3640
4261
  const report = await client2.migrations.run(params, {
3641
4262
  onProgress: (m) => {
3642
4263
  if (m.has_more) {
@@ -3683,12 +4304,12 @@ function readFlag23(args, flag) {
3683
4304
  if (index === -1) return void 0;
3684
4305
  return args[index + 1];
3685
4306
  }
3686
- var VALUE_FLAGS3 = ["--api-key", "--base-url", "--domain", "--apply"];
4307
+ var VALUE_FLAGS4 = ["--api-key", "--base-url", "--domain", "--apply"];
3687
4308
  function readFirstPositional15(args) {
3688
4309
  for (let i = 0; i < args.length; i += 1) {
3689
4310
  const arg = args[i];
3690
4311
  if (arg.startsWith("--")) {
3691
- if (VALUE_FLAGS3.includes(arg)) i += 1;
4312
+ if (VALUE_FLAGS4.includes(arg)) i += 1;
3692
4313
  continue;
3693
4314
  }
3694
4315
  return arg;
@@ -3880,11 +4501,12 @@ var USAGE_LINES = [
3880
4501
  " groundmail test-inbox address [--prefix signup] a fresh address on your team's test domain (mail there is caught, never delivered)",
3881
4502
  " groundmail test-inbox wait --to <address> [--timeout 30] [--since <iso>] [--json] wait for an email; prints its code and links (exit 2 on timeout)",
3882
4503
  " groundmail test-inbox list [--to <address>] [--since <iso>] [--json]",
3883
- ' groundmail emails send --from you@domain.com --to user@example.com --subject "Hello" --html "<p>Hi</p>" [--category transactional|product|newsletter] [--unsubscribe] [--tenant <tenant>]',
4504
+ ' 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)',
4505
+ ' groundmail emails send --to you@example.com --subject "Hello" --text "Hi" --watch watch it land: the receiving server, its reply and the time',
3884
4506
  " groundmail emails batch --file ./emails.json [--tenant <tenant>]",
3885
4507
  " groundmail emails list [--limit 25] [--cursor \u2026] [--test | --live] [--tenant <tenant>] [--stream transactional|product|newsletter]",
3886
4508
  " groundmail emails insights [--window 7d|30d]",
3887
- " groundmail emails get --id msg_\u2026",
4509
+ " groundmail emails get --id msg_\u2026 [--watch [--timeout 60] [--json]] --watch: follow an email you already sent until it lands",
3888
4510
  " groundmail emails cancel --id msg_\u2026",
3889
4511
  " groundmail emails resend --id msg_\u2026",
3890
4512
  ` groundmail events send --name user.created --email user@example.com --data '{"name":"Ada"}'`,
@@ -3939,6 +4561,8 @@ var USAGE_LINES = [
3939
4561
  " groundmail migrate sendgrid [--dry-run] [--only templates,suppressions,domains,webhooks] [--region eu] import from SendGrid (Beta)",
3940
4562
  " groundmail migrate postmark [--dry-run] [--only \u2026] import from Postmark (Beta)",
3941
4563
  " groundmail migrate mailgun [--dry-run] [--only \u2026] [--region eu] import from Mailgun (Beta)",
4564
+ " groundmail migrate resend [--dry-run] [--only \u2026] import from Resend (Beta; a Full access key)",
4565
+ " groundmail migrate ses --region us-east-1 [--dry-run] [--only \u2026] import one Amazon SES region (Beta)",
3942
4566
  " groundmail migrate list your recent imports",
3943
4567
  " groundmail migrate report <id> [--csv] an import report (JSON, or CSV to stdout)",
3944
4568
  " groundmail integrations twilio status",
@@ -3953,13 +4577,16 @@ var FOOTER_LINES = [
3953
4577
  " CLOUDFLARE_API_TOKEN=\u2026 (optional local apply; else uses dashboard Integrations)",
3954
4578
  " VERCEL_API_TOKEN=\u2026 VERCEL_TEAM_ID=\u2026 (optional local apply; else uses dashboard Integrations)",
3955
4579
  " GODADDY_PAT=\u2026 or GODADDY_API_KEY=\u2026 GODADDY_API_SECRET=\u2026 (domains apply --provider godaddy)",
3956
- " SENDGRID_API_KEY / POSTMARK_SERVER_TOKEN (+ POSTMARK_ACCOUNT_TOKEN) / MAILGUN_API_KEY (migrate; used once, never stored)",
4580
+ " SENDGRID_API_KEY / POSTMARK_SERVER_TOKEN (+ POSTMARK_ACCOUNT_TOKEN) / MAILGUN_API_KEY / RESEND_API_KEY (migrate; used once, never stored)",
4581
+ " AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ AWS_SESSION_TOKEN, AWS_REGION) (migrate ses; used once, never stored)",
3957
4582
  "",
3958
4583
  "Scopes: full, emails:send, emails:read, domains:read, domains:write, webhooks:write, templates:write,",
3959
4584
  " suppressions:write, account:read",
3960
4585
  "",
3961
4586
  "<tenant>: the tenant's external id (your id for that customer) or its Groundmail id.",
3962
4587
  "",
4588
+ "--watch exit codes: 0 delivered, 1 error, 3 bounced or failed, 4 still on its way at --timeout, 5 test send (never delivered).",
4589
+ "",
3963
4590
  "Add --help to any command for just its usage. --version prints the CLI version."
3964
4591
  ];
3965
4592
  var CLI_VERSION = version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groundmail-cli",
3
- "version": "0.30.1",
3
+ "version": "0.32.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.29.1"
41
+ "groundmail": "0.31.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "tsup": "^8.5.0",