shipmail-cli 0.6.1 → 0.6.3

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 CHANGED
@@ -8,7 +8,22 @@ npx -y shipmail-cli --help
8
8
 
9
9
  ## Authentication
10
10
 
11
- Most commands require an API key. Create one in the Shipmail dashboard and export it:
11
+ Sign in once through the browser, with no API key to copy:
12
+
13
+ ```bash
14
+ shipmail login
15
+ ```
16
+
17
+ The CLI opens Shipmail, you choose the organizations it may reach, and it saves the login in the
18
+ macOS Keychain, in the Secret Service through `secret-tool` on Linux, or otherwise in
19
+ `~/.config/shipmail/credentials.json`, readable only by you. The login refreshes itself.
20
+ `shipmail login --no-browser` prints the URL without opening a browser. If the login can reach
21
+ several organizations, pick one with `--organization org_...` or `SHIPMAIL_ORGANIZATION`, or save a
22
+ default with `shipmail login --organization org_...`. `shipmail logout` revokes the login and
23
+ deletes it.
24
+
25
+ An API key still works and takes precedence over the login. Create one in the Shipmail dashboard
26
+ and export it:
12
27
 
13
28
  ```bash
14
29
  export SHIPMAIL_API_KEY="sm_live_..."
@@ -31,7 +46,7 @@ shipmail domains verify dom_123
31
46
  shipmail mailboxes create --domain-id dom_123 --address hello --password-file ./mailbox-password.txt --display-name "Hello"
32
47
  shipmail mailboxes suspend mbx_123
33
48
  shipmail mailboxes resume mbx_123
34
- shipmail mailboxes forwarding-add mbx_123 --destination owner@example.net
49
+ shipmail mailboxes forwarding-add mbx_123 --destination owner@example.net --recipient support@example.com
35
50
  shipmail mailboxes forwarding mbx_123
36
51
  shipmail mailboxes forwarding-remove mbx_123 fwd_123
37
52
  shipmail mailboxes rules mbx_123
@@ -122,7 +137,11 @@ shipmail newsletter-assets register-url https://cdn.shipmail.to/newsletter-image
122
137
  # Manage webhooks
123
138
  shipmail webhooks create \
124
139
  --url https://example.com/shipmail/webhook \
125
- --events message.received,message.bounced
140
+ --events message.received,message.bounced \
141
+ --authorization "Bearer your-endpoint-key"
142
+ shipmail webhooks update whk_123 --clear-authorization
143
+ shipmail webhooks update whk_123 --standard-webhooks-secret "whsec_..."
144
+ shipmail webhooks update whk_123 --clear-standard-webhooks-secret
126
145
  shipmail webhooks deliveries whk_123 --status failed
127
146
  shipmail webhooks replay-delivery whk_123 dlv_123 --idempotency-key replay-123
128
147
  ```
@@ -158,7 +177,7 @@ Use repeatable `--header "Name: value"` flags for request headers. Use `--output
158
177
  - `messages send|list|analytics|get|reply` (test keys also support `--sandbox-outcome`)
159
178
  - `scheduled-messages list|get|update|cancel`
160
179
  - `mailboxes sandbox-inbound`
161
- - `webhooks list|create|test|deliveries|replay-delivery`
180
+ - `webhooks list|create|update|test|deliveries|replay-delivery`
162
181
  - `suppressions list|remove`
163
182
  - `audiences list|create|get|update|delete`
164
183
  - `audiences feed get|update|rotate|revoke`
package/dist/cli.d.ts CHANGED
@@ -1,4 +1,27 @@
1
1
  #!/usr/bin/env node
2
+ import { z } from 'zod';
3
+
4
+ declare const storedLoginSchema: z.ZodObject<{
5
+ baseUrl: z.ZodString;
6
+ clientId: z.ZodString;
7
+ accessToken: z.ZodString;
8
+ refreshToken: z.ZodOptional<z.ZodString>;
9
+ expiresAt: z.ZodNumber;
10
+ tokenEndpoint: z.ZodString;
11
+ revocationEndpoint: z.ZodOptional<z.ZodString>;
12
+ organizationId: z.ZodOptional<z.ZodString>;
13
+ }, z.core.$strip>;
14
+ type StoredLogin = z.infer<typeof storedLoginSchema>;
15
+ type CredentialBackend = {
16
+ readonly kind: "keychain" | "secret-tool" | "file";
17
+ read(baseUrl: string): Promise<StoredLogin | null>;
18
+ write(login: StoredLogin): Promise<void>;
19
+ delete(baseUrl: string): Promise<void>;
20
+ };
21
+ type CredentialStore = CredentialBackend & {
22
+ withLock<T>(fn: () => Promise<T>): Promise<T>;
23
+ };
24
+
2
25
  type Output = {
3
26
  write(chunk: string): void;
4
27
  };
@@ -8,6 +31,9 @@ type RunCliOptions = {
8
31
  readonly stdout?: Output | undefined;
9
32
  readonly stderr?: Output | undefined;
10
33
  readonly fetch?: typeof fetch | undefined;
34
+ readonly credentialStore?: CredentialStore | undefined;
35
+ readonly openBrowser?: ((url: string) => void) | undefined;
36
+ readonly loginTimeoutMs?: number | undefined;
11
37
  };
12
38
  declare function runCli(argv: readonly string[], options?: RunCliOptions): Promise<number>;
13
39
  declare function isMainModule(moduleUrl?: string, argvPath?: string | undefined): boolean;
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { createHash } from "crypto";
4
+ import { createHash as createHash2 } from "crypto";
5
5
  import { realpathSync } from "fs";
6
- import { readFile, writeFile } from "fs/promises";
6
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
7
7
  import { env as processEnv, exit, stderr, stdout } from "process";
8
8
  import { fileURLToPath } from "url";
9
9
  import {
@@ -14,8 +14,642 @@ import {
14
14
  WEBHOOK_EVENT_TYPES
15
15
  } from "shipmail";
16
16
 
17
+ // src/credentials.ts
18
+ import { spawn } from "child_process";
19
+ import { randomBytes } from "crypto";
20
+ import { constants } from "fs";
21
+ import { access, chmod, link, mkdir, readFile, rename, unlink, writeFile } from "fs/promises";
22
+ import { homedir } from "os";
23
+ import { delimiter, dirname, join } from "path";
24
+ import { z } from "zod";
25
+ var KEYCHAIN_SERVICE = "shipmail-cli";
26
+ var storedLoginSchema = z.object({
27
+ baseUrl: z.string(),
28
+ clientId: z.string(),
29
+ accessToken: z.string(),
30
+ refreshToken: z.string().optional(),
31
+ expiresAt: z.number(),
32
+ tokenEndpoint: z.string(),
33
+ revocationEndpoint: z.string().optional(),
34
+ organizationId: z.string().optional()
35
+ });
36
+ var fileContentsSchema = z.object({ logins: z.record(z.string(), storedLoginSchema) });
37
+ function parseLogin(raw) {
38
+ try {
39
+ const parsed = storedLoginSchema.safeParse(JSON.parse(raw));
40
+ return parsed.success ? parsed.data : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ var runCommand = (command, args, input) => new Promise((resolve, reject) => {
46
+ const child = spawn(command, [...args], { stdio: ["pipe", "pipe", "ignore"] });
47
+ let stdout2 = "";
48
+ child.stdout.setEncoding("utf8");
49
+ child.stdout.on("data", (chunk) => {
50
+ stdout2 += chunk;
51
+ });
52
+ child.on("error", reject);
53
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout: stdout2 }));
54
+ child.stdin.end(input ?? "");
55
+ });
56
+ var SECURITY_MAX_LINE = 4e3;
57
+ var KEYCHAIN_ITEM_NOT_FOUND = 44;
58
+ function securityQuote(value) {
59
+ if (/[\r\n]/.test(value)) throw new Error("A Keychain value cannot contain a line break.");
60
+ return `"${value.replace(/["\\]/g, (character) => `\\${character}`)}"`;
61
+ }
62
+ function keychainStore(run) {
63
+ return {
64
+ kind: "keychain",
65
+ async read(baseUrl2) {
66
+ const result = await run("security", [
67
+ "find-generic-password",
68
+ "-s",
69
+ KEYCHAIN_SERVICE,
70
+ "-a",
71
+ baseUrl2,
72
+ "-w"
73
+ ]);
74
+ return result.code === 0 ? parseLogin(result.stdout.trim()) : null;
75
+ },
76
+ // The secret goes to security -i on stdin, never into argv, where ps could read it.
77
+ async write(login2) {
78
+ const command = [
79
+ "add-generic-password",
80
+ "-U",
81
+ "-s",
82
+ KEYCHAIN_SERVICE,
83
+ "-a",
84
+ login2.baseUrl,
85
+ "-l",
86
+ "Shipmail CLI",
87
+ "-w",
88
+ JSON.stringify(login2)
89
+ ].map(securityQuote).join(" ");
90
+ if (command.length > SECURITY_MAX_LINE) {
91
+ throw new Error("The login is too large for the macOS Keychain.");
92
+ }
93
+ const result = await run("security", ["-i"], `${command}
94
+ `);
95
+ if (result.code !== 0) throw new Error("Could not save the login to the macOS Keychain.");
96
+ },
97
+ async delete(baseUrl2) {
98
+ const result = await run("security", [
99
+ "delete-generic-password",
100
+ "-s",
101
+ KEYCHAIN_SERVICE,
102
+ "-a",
103
+ baseUrl2
104
+ ]);
105
+ if (result.code !== 0 && result.code !== KEYCHAIN_ITEM_NOT_FOUND) {
106
+ throw new Error("Could not delete the login from the macOS Keychain.");
107
+ }
108
+ }
109
+ };
110
+ }
111
+ function secretToolStore(run) {
112
+ const attributes = (baseUrl2) => ["service", KEYCHAIN_SERVICE, "account", baseUrl2];
113
+ return {
114
+ kind: "secret-tool",
115
+ async read(baseUrl2) {
116
+ const result = await run("secret-tool", ["lookup", ...attributes(baseUrl2)]);
117
+ return result.code === 0 ? parseLogin(result.stdout.trim()) : null;
118
+ },
119
+ async write(login2) {
120
+ const result = await run(
121
+ "secret-tool",
122
+ ["store", "--label=Shipmail CLI", ...attributes(login2.baseUrl)],
123
+ JSON.stringify(login2)
124
+ );
125
+ if (result.code !== 0) throw new Error("Could not save the login with secret-tool.");
126
+ },
127
+ // secret-tool clear exits 0 when nothing matched as well, so any other exit is a failure.
128
+ async delete(baseUrl2) {
129
+ const result = await run("secret-tool", ["clear", ...attributes(baseUrl2)]);
130
+ if (result.code !== 0) throw new Error("Could not delete the login with secret-tool.");
131
+ }
132
+ };
133
+ }
134
+ function credentialsFilePath(env) {
135
+ const configHome = env["XDG_CONFIG_HOME"] || join(env["HOME"] || homedir(), ".config");
136
+ return join(configHome, "shipmail", "credentials.json");
137
+ }
138
+ function fileStore(path) {
139
+ const directory = dirname(path);
140
+ async function readAll() {
141
+ let raw;
142
+ try {
143
+ raw = await readFile(path, "utf8");
144
+ } catch {
145
+ return {};
146
+ }
147
+ try {
148
+ const parsed = fileContentsSchema.safeParse(JSON.parse(raw));
149
+ return parsed.success ? parsed.data.logins : {};
150
+ } catch {
151
+ return {};
152
+ }
153
+ }
154
+ async function writeAll(logins) {
155
+ await mkdir(directory, { recursive: true, mode: 448 });
156
+ await chmod(directory, 448);
157
+ const temporary = `${path}.${process.pid}.tmp`;
158
+ await writeFile(temporary, `${JSON.stringify({ logins }, null, 2)}
159
+ `, { mode: 384 });
160
+ await chmod(temporary, 384);
161
+ await rename(temporary, path);
162
+ }
163
+ return {
164
+ kind: "file",
165
+ async read(baseUrl2) {
166
+ return (await readAll())[baseUrl2] ?? null;
167
+ },
168
+ async write(login2) {
169
+ await writeAll({ ...await readAll(), [login2.baseUrl]: login2 });
170
+ },
171
+ async delete(baseUrl2) {
172
+ const logins = await readAll();
173
+ if (!(baseUrl2 in logins)) return;
174
+ const rest = { ...logins };
175
+ delete rest[baseUrl2];
176
+ await writeAll(rest);
177
+ }
178
+ };
179
+ }
180
+ var LOCK_WAIT_MS = 6e4;
181
+ var LOCK_RETRY_MS = 50;
182
+ function errorCode(error) {
183
+ return error instanceof Error && "code" in error ? error.code : void 0;
184
+ }
185
+ function processAlive(pid) {
186
+ try {
187
+ process.kill(pid, 0);
188
+ return true;
189
+ } catch (error) {
190
+ return errorCode(error) === "EPERM";
191
+ }
192
+ }
193
+ async function readLock(path) {
194
+ return readFile(path, "utf8").catch(() => null);
195
+ }
196
+ function lockHolder(content) {
197
+ const match = /^(\d+) [0-9a-f]+$/.exec(content);
198
+ return match?.[1] ? Number(match[1]) : null;
199
+ }
200
+ async function tryCreateLock(path, owner) {
201
+ const staged = `${path}.${randomBytes(8).toString("hex")}.tmp`;
202
+ await writeFile(staged, owner, { mode: 384, flag: "wx" });
203
+ try {
204
+ await link(staged, path);
205
+ return true;
206
+ } catch (error) {
207
+ if (errorCode(error) === "EEXIST") return false;
208
+ throw error;
209
+ } finally {
210
+ await unlink(staged).catch(() => void 0);
211
+ }
212
+ }
213
+ function abandoned(content) {
214
+ const holder = lockHolder(content);
215
+ return holder === null || !processAlive(holder);
216
+ }
217
+ async function breakLock(path, observed, owner) {
218
+ const breaker = `${path}.break`;
219
+ if (!await tryCreateLock(breaker, owner)) {
220
+ const content = await readLock(breaker);
221
+ if (content !== null && abandoned(content) && await readLock(breaker) === content) {
222
+ await unlink(breaker).catch(() => void 0);
223
+ }
224
+ return;
225
+ }
226
+ try {
227
+ if (await readLock(path) === observed) await unlink(path).catch(() => void 0);
228
+ } finally {
229
+ if (await readLock(breaker) === owner) await unlink(breaker).catch(() => void 0);
230
+ }
231
+ }
232
+ async function withFileLock(path, fn, waitMs = LOCK_WAIT_MS) {
233
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
234
+ const owner = `${process.pid} ${randomBytes(16).toString("hex")}`;
235
+ const deadline = Date.now() + waitMs;
236
+ while (!await tryCreateLock(path, owner)) {
237
+ if (Date.now() > deadline) {
238
+ throw new Error(
239
+ `Another shipmail command is using the saved login. If none is running, delete ${path}.`
240
+ );
241
+ }
242
+ const observed = await readLock(path);
243
+ if (observed !== null && abandoned(observed)) await breakLock(path, observed, owner);
244
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
245
+ }
246
+ try {
247
+ return await fn();
248
+ } finally {
249
+ if (await readLock(path) === owner) await unlink(path).catch(() => void 0);
250
+ }
251
+ }
252
+ async function onPath(command, env) {
253
+ for (const directory of (env["PATH"] ?? "").split(delimiter)) {
254
+ if (!directory) continue;
255
+ try {
256
+ await access(join(directory, command), constants.X_OK);
257
+ return true;
258
+ } catch {
259
+ }
260
+ }
261
+ return false;
262
+ }
263
+ async function defaultCredentialStore(options) {
264
+ const path = credentialsFilePath(options.env);
265
+ const file = fileStore(path);
266
+ const lockPath = join(dirname(path), "credentials.lock");
267
+ const withLock = (fn) => withFileLock(lockPath, fn);
268
+ const fileNotice = () => options.notice(`Saved the login in ${path}, readable only by you. No system keychain found.`);
269
+ let native = null;
270
+ if (options.platform === "darwin") native = keychainStore(options.run);
271
+ else if (options.platform === "linux" && await onPath("secret-tool", options.env)) {
272
+ native = secretToolStore(options.run);
273
+ }
274
+ if (!native) {
275
+ return {
276
+ kind: "file",
277
+ read: file.read,
278
+ async write(login2) {
279
+ await file.write(login2);
280
+ fileNotice();
281
+ },
282
+ delete: file.delete,
283
+ withLock
284
+ };
285
+ }
286
+ const store = native;
287
+ return {
288
+ kind: store.kind,
289
+ async read(baseUrl2) {
290
+ return await file.read(baseUrl2) ?? await store.read(baseUrl2).catch(() => null);
291
+ },
292
+ async write(login2) {
293
+ try {
294
+ await store.write(login2);
295
+ } catch {
296
+ await file.write(login2);
297
+ fileNotice();
298
+ await store.delete(login2.baseUrl).catch(() => void 0);
299
+ return;
300
+ }
301
+ await file.delete(login2.baseUrl);
302
+ },
303
+ async delete(baseUrl2) {
304
+ const nativeError = await store.delete(baseUrl2).then(
305
+ () => null,
306
+ (error) => error instanceof Error ? error : new Error(String(error))
307
+ );
308
+ if (nativeError) throw nativeError;
309
+ await file.delete(baseUrl2);
310
+ },
311
+ withLock
312
+ };
313
+ }
314
+
315
+ // src/oauth.ts
316
+ import { spawn as spawn2 } from "child_process";
317
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
318
+ import { createServer } from "http";
319
+ import { z as z2 } from "zod";
320
+ var LOGIN_TIMEOUT_MS = 5 * 6e4;
321
+ var REQUEST_TIMEOUT_MS = 3e4;
322
+ var SCOPE = "openid profile email offline_access";
323
+ var REGISTERED_REDIRECT_URI = "http://127.0.0.1/callback";
324
+ var protectedResourceSchema = z2.object({
325
+ resource: z2.string(),
326
+ authorization_servers: z2.array(z2.string()).min(1)
327
+ });
328
+ var authorizationServerSchema = z2.object({
329
+ issuer: z2.string(),
330
+ authorization_endpoint: z2.string(),
331
+ token_endpoint: z2.string(),
332
+ registration_endpoint: z2.string().optional(),
333
+ revocation_endpoint: z2.string().optional(),
334
+ code_challenge_methods_supported: z2.array(z2.string()).optional()
335
+ });
336
+ var registrationSchema = z2.object({ client_id: z2.string().min(1) });
337
+ var tokenSchema = z2.object({
338
+ access_token: z2.string().min(1),
339
+ token_type: z2.string(),
340
+ expires_in: z2.number().optional(),
341
+ refresh_token: z2.string().optional()
342
+ });
343
+ var oauthErrorSchema = z2.object({
344
+ error: z2.string(),
345
+ error_description: z2.string().optional()
346
+ });
347
+ var OAuthError = class extends Error {
348
+ constructor(code, message) {
349
+ super(message);
350
+ this.code = code;
351
+ this.name = "OAuthError";
352
+ }
353
+ };
354
+ function trimSlash(value) {
355
+ return value.replace(/\/+$/, "");
356
+ }
357
+ async function getJson(fetchFn, url) {
358
+ const response = await fetchFn(url, {
359
+ headers: { Accept: "application/json" },
360
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
361
+ });
362
+ if (!response.ok)
363
+ throw new Error(`Sign-in discovery failed: ${url} returned ${response.status}.`);
364
+ return response.json();
365
+ }
366
+ function parseOrThrow(schema, value, what) {
367
+ const parsed = schema.safeParse(value);
368
+ if (!parsed.success) throw new Error(`The server sent an invalid ${what}.`);
369
+ return parsed.data;
370
+ }
371
+ function sameOrigin(url, origin, what) {
372
+ let parsed;
373
+ try {
374
+ parsed = new URL(url);
375
+ } catch {
376
+ throw new Error(`The server sent an invalid ${what}.`);
377
+ }
378
+ if (parsed.origin !== origin) throw new Error(`The ${what} is not on ${origin}.`);
379
+ return url;
380
+ }
381
+ async function discoverOAuthServer(apiBaseUrl, fetchFn) {
382
+ const origin = new URL(apiBaseUrl).origin;
383
+ const resource = `${origin}/api/v1`;
384
+ const protectedResource = parseOrThrow(
385
+ protectedResourceSchema,
386
+ await getJson(fetchFn, `${origin}/.well-known/oauth-protected-resource/api/v1`),
387
+ "protected resource document"
388
+ );
389
+ if (trimSlash(protectedResource.resource) !== resource) {
390
+ throw new Error(`The protected resource document is not for ${resource}.`);
391
+ }
392
+ const [issuer = ""] = protectedResource.authorization_servers;
393
+ const issuerUrl = new URL(sameOrigin(issuer, origin, "authorization server"));
394
+ const issuerPath = trimSlash(issuerUrl.pathname);
395
+ const metadata = parseOrThrow(
396
+ authorizationServerSchema,
397
+ await getJson(fetchFn, `${origin}/.well-known/oauth-authorization-server${issuerPath}`),
398
+ "authorization server document"
399
+ );
400
+ if (trimSlash(metadata.issuer) !== trimSlash(issuer)) {
401
+ throw new Error("The authorization server document names a different issuer.");
402
+ }
403
+ if (metadata.code_challenge_methods_supported && !metadata.code_challenge_methods_supported.includes("S256")) {
404
+ throw new Error("The authorization server does not support PKCE with S256.");
405
+ }
406
+ return {
407
+ resource,
408
+ authorizationEndpoint: sameOrigin(
409
+ metadata.authorization_endpoint,
410
+ origin,
411
+ "authorization endpoint"
412
+ ),
413
+ tokenEndpoint: sameOrigin(metadata.token_endpoint, origin, "token endpoint"),
414
+ registrationEndpoint: metadata.registration_endpoint === void 0 ? void 0 : sameOrigin(metadata.registration_endpoint, origin, "registration endpoint"),
415
+ revocationEndpoint: metadata.revocation_endpoint === void 0 ? void 0 : sameOrigin(metadata.revocation_endpoint, origin, "revocation endpoint")
416
+ };
417
+ }
418
+ async function oauthFailure(response, fallback) {
419
+ const body = await response.json().catch(() => null);
420
+ const parsed = oauthErrorSchema.safeParse(body);
421
+ if (!parsed.success)
422
+ return new OAuthError("server_error", `${fallback} (HTTP ${response.status}).`);
423
+ return new OAuthError(
424
+ parsed.data.error,
425
+ parsed.data.error_description ? `${fallback}: ${parsed.data.error_description}` : `${fallback}: ${parsed.data.error}`
426
+ );
427
+ }
428
+ async function registerClient(server, fetchFn) {
429
+ if (!server.registrationEndpoint) {
430
+ throw new Error("The authorization server does not allow client registration.");
431
+ }
432
+ const response = await fetchFn(server.registrationEndpoint, {
433
+ method: "POST",
434
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
435
+ body: JSON.stringify({
436
+ client_name: "Shipmail CLI",
437
+ application_type: "native",
438
+ redirect_uris: [REGISTERED_REDIRECT_URI],
439
+ grant_types: ["authorization_code", "refresh_token"],
440
+ response_types: ["code"],
441
+ token_endpoint_auth_method: "none"
442
+ }),
443
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
444
+ });
445
+ if (!response.ok) throw await oauthFailure(response, "Client registration failed");
446
+ return parseOrThrow(registrationSchema, await response.json(), "client registration").client_id;
447
+ }
448
+ async function tokenRequest(tokenEndpoint, fetchFn, params, failure) {
449
+ const response = await fetchFn(tokenEndpoint, {
450
+ method: "POST",
451
+ headers: {
452
+ "Content-Type": "application/x-www-form-urlencoded",
453
+ Accept: "application/json"
454
+ },
455
+ body: new URLSearchParams(params).toString(),
456
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
457
+ });
458
+ if (!response.ok) throw await oauthFailure(response, failure);
459
+ const tokens = parseOrThrow(tokenSchema, await response.json(), "token response");
460
+ if (tokens.token_type.toLowerCase() !== "bearer") {
461
+ throw new Error("The server issued an unsupported token type.");
462
+ }
463
+ return tokens;
464
+ }
465
+ function storedTokens(tokens, previousRefreshToken, now) {
466
+ const refreshToken = tokens.refresh_token ?? previousRefreshToken;
467
+ return {
468
+ accessToken: tokens.access_token,
469
+ ...refreshToken ? { refreshToken } : {},
470
+ expiresAt: now + (tokens.expires_in ?? 3600) * 1e3
471
+ };
472
+ }
473
+ async function refreshLogin(login2, fetchFn, now = Date.now()) {
474
+ if (!login2.refreshToken) {
475
+ throw new OAuthError("invalid_grant", "The login has expired and cannot be refreshed");
476
+ }
477
+ const tokens = await tokenRequest(
478
+ login2.tokenEndpoint,
479
+ fetchFn,
480
+ {
481
+ grant_type: "refresh_token",
482
+ refresh_token: login2.refreshToken,
483
+ client_id: login2.clientId,
484
+ resource: `${new URL(login2.baseUrl).origin}/api/v1`
485
+ },
486
+ "Token refresh failed"
487
+ );
488
+ return { ...login2, ...storedTokens(tokens, login2.refreshToken, now) };
489
+ }
490
+ async function revokeToken(revocationEndpoint, clientId, token, fetchFn) {
491
+ const response = await fetchFn(revocationEndpoint, {
492
+ method: "POST",
493
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
494
+ body: new URLSearchParams({
495
+ token,
496
+ token_type_hint: "refresh_token",
497
+ client_id: clientId
498
+ }).toString(),
499
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
500
+ });
501
+ if (!response.ok) throw await oauthFailure(response, "Token revocation failed");
502
+ }
503
+ function randomToken(bytes) {
504
+ return randomBytes2(bytes).toString("base64url");
505
+ }
506
+ function codeChallenge(verifier) {
507
+ return createHash("sha256").update(verifier).digest("base64url");
508
+ }
509
+ function callbackPage(message) {
510
+ return `<!doctype html><meta charset="utf-8"><title>Shipmail CLI</title><p>${message}</p>
511
+ `;
512
+ }
513
+ var LOOPBACK_ORIGIN = "http://127.0.0.1";
514
+ function callbackTarget(target) {
515
+ if (!target?.startsWith("/")) return null;
516
+ try {
517
+ const url = new URL(target, LOOPBACK_ORIGIN);
518
+ return url.origin === LOOPBACK_ORIGIN ? url : null;
519
+ } catch {
520
+ return null;
521
+ }
522
+ }
523
+ async function startLoopbackListener(expectedState, timeoutMs) {
524
+ let settle;
525
+ const code = new Promise((resolve, reject) => {
526
+ settle = { resolve, reject };
527
+ });
528
+ const server = createServer((request, response) => {
529
+ const plain = (status, message) => response.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" }).end(`${message}
530
+ `);
531
+ if (request.method !== "GET") {
532
+ plain(405, "Method not allowed");
533
+ return;
534
+ }
535
+ const url = callbackTarget(request.url);
536
+ if (!url || url.pathname !== "/callback") {
537
+ plain(404, "Not found");
538
+ return;
539
+ }
540
+ const reply = (status, message) => response.writeHead(status, { "Content-Type": "text/html; charset=utf-8", Connection: "close" }).end(callbackPage(message));
541
+ if (url.searchParams.get("state") !== expectedState) {
542
+ reply(400, "This sign-in link does not match the CLI. Run shipmail login again.");
543
+ return;
544
+ }
545
+ const error = url.searchParams.get("error");
546
+ if (error) {
547
+ const description = url.searchParams.get("error_description");
548
+ reply(400, "Sign-in was not completed. You can close this tab.");
549
+ settle?.reject(new OAuthError(error, `Sign-in failed: ${description ?? error}`));
550
+ return;
551
+ }
552
+ const authorizationCode = url.searchParams.get("code");
553
+ if (!authorizationCode) {
554
+ reply(400, "The sign-in response had no code. Run shipmail login again.");
555
+ settle?.reject(new Error("The sign-in response had no authorization code."));
556
+ return;
557
+ }
558
+ reply(200, "Signed in to the Shipmail CLI. You can close this tab.");
559
+ settle?.resolve(authorizationCode);
560
+ });
561
+ await new Promise((resolve, reject) => {
562
+ server.once("error", reject);
563
+ server.listen(0, "127.0.0.1", () => resolve());
564
+ });
565
+ const address = server.address();
566
+ if (address === null || typeof address === "string") {
567
+ server.close();
568
+ throw new Error("Could not open a local port for the sign-in callback.");
569
+ }
570
+ const { port } = address;
571
+ const timer = setTimeout(() => {
572
+ settle?.reject(
573
+ new Error("Timed out waiting for the browser sign-in. Run shipmail login again.")
574
+ );
575
+ }, timeoutMs);
576
+ return {
577
+ redirectUri: `http://127.0.0.1:${port}/callback`,
578
+ code,
579
+ close() {
580
+ clearTimeout(timer);
581
+ server.close();
582
+ server.closeAllConnections();
583
+ }
584
+ };
585
+ }
586
+ function openInBrowser(url) {
587
+ const [command, args, verbatim] = process.platform === "darwin" ? ["open", [url], false] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', `"${url}"`], true] : ["xdg-open", [url], false];
588
+ try {
589
+ const child = spawn2(command, args, {
590
+ detached: true,
591
+ stdio: "ignore",
592
+ windowsVerbatimArguments: verbatim
593
+ });
594
+ child.on("error", () => void 0);
595
+ child.unref();
596
+ } catch {
597
+ }
598
+ }
599
+ async function login(options) {
600
+ const server = await discoverOAuthServer(options.baseUrl, options.fetch);
601
+ const existing = await options.store.read(options.baseUrl);
602
+ const clientId = existing?.clientId ?? await registerClient(server, options.fetch);
603
+ const verifier = randomToken(32);
604
+ const state = randomToken(16);
605
+ const listener = await startLoopbackListener(state, options.timeoutMs);
606
+ try {
607
+ const authorizeUrl = new URL(server.authorizationEndpoint);
608
+ authorizeUrl.search = new URLSearchParams({
609
+ response_type: "code",
610
+ client_id: clientId,
611
+ redirect_uri: listener.redirectUri,
612
+ scope: SCOPE,
613
+ state,
614
+ code_challenge: codeChallenge(verifier),
615
+ code_challenge_method: "S256",
616
+ resource: server.resource
617
+ }).toString();
618
+ options.print("Open this URL to sign in to Shipmail:");
619
+ options.print(authorizeUrl.toString());
620
+ options.openBrowser?.(authorizeUrl.toString());
621
+ const code = await listener.code;
622
+ const tokens = await tokenRequest(
623
+ server.tokenEndpoint,
624
+ options.fetch,
625
+ {
626
+ grant_type: "authorization_code",
627
+ code,
628
+ redirect_uri: listener.redirectUri,
629
+ client_id: clientId,
630
+ code_verifier: verifier,
631
+ resource: server.resource
632
+ },
633
+ "Sign-in failed"
634
+ );
635
+ const organizationId = options.organizationId ?? existing?.organizationId;
636
+ const stored = {
637
+ baseUrl: options.baseUrl,
638
+ clientId,
639
+ ...storedTokens(tokens, void 0, Date.now()),
640
+ tokenEndpoint: server.tokenEndpoint,
641
+ ...server.revocationEndpoint ? { revocationEndpoint: server.revocationEndpoint } : {},
642
+ ...organizationId ? { organizationId } : {}
643
+ };
644
+ await options.store.withLock(() => options.store.write(stored));
645
+ return stored;
646
+ } finally {
647
+ listener.close();
648
+ }
649
+ }
650
+
17
651
  // src/version.ts
18
- var VERSION = "0.6.1";
652
+ var VERSION = "0.6.3";
19
653
 
20
654
  // src/cli.ts
21
655
  var DEFAULT_BASE_URL = "https://shipmail.to/api/v1";
@@ -166,29 +800,152 @@ function baseUrl(parsed, env) {
166
800
  if (!raw) return DEFAULT_BASE_URL;
167
801
  return validateBaseUrl(raw, env["SHIPMAIL_ALLOW_INSECURE_BASE_URL"] === "1");
168
802
  }
169
- function requireApiKey(parsed, env) {
803
+ var REFRESH_MARGIN_MS = 6e4;
804
+ function cliEnv(options) {
805
+ return options.env ?? processEnv;
806
+ }
807
+ async function credentialStore(options) {
808
+ if (options.credentialStore) return options.credentialStore;
809
+ const errOut = options.stderr ?? stderr;
810
+ return defaultCredentialStore({
811
+ env: cliEnv(options),
812
+ platform: process.platform,
813
+ run: runCommand,
814
+ notice: (message) => errOut.write(`${message}
815
+ `)
816
+ });
817
+ }
818
+ function isFresh(login2) {
819
+ return login2.expiresAt - Date.now() > REFRESH_MARGIN_MS;
820
+ }
821
+ function sameGeneration(a, b) {
822
+ return a.accessToken === b.accessToken && a.refreshToken === b.refreshToken;
823
+ }
824
+ async function freshLogin(stored, store, fetchFn) {
825
+ if (isFresh(stored)) return stored;
826
+ return store.withLock(async () => {
827
+ const current = await store.read(stored.baseUrl);
828
+ if (!current) throw new Error("You were signed out. Run shipmail login.");
829
+ if (isFresh(current)) return current;
830
+ try {
831
+ const refreshed = await refreshLogin(current, fetchFn);
832
+ const latest = await store.read(stored.baseUrl);
833
+ if (latest && sameGeneration(latest, current)) await store.write(refreshed);
834
+ return refreshed;
835
+ } catch (err) {
836
+ if (!(err instanceof OAuthError) || err.code !== "invalid_grant") throw err;
837
+ const latest = await store.read(stored.baseUrl);
838
+ if (latest && !sameGeneration(latest, current)) {
839
+ if (isFresh(latest)) return latest;
840
+ throw new Error(
841
+ "Your Shipmail login changed while it was refreshing. Run the command again.",
842
+ { cause: err }
843
+ );
844
+ }
845
+ if (latest) await store.delete(stored.baseUrl);
846
+ throw new Error("Your Shipmail login has expired or was revoked. Run shipmail login.", {
847
+ cause: err
848
+ });
849
+ }
850
+ });
851
+ }
852
+ async function resolveCredential(parsed, options) {
853
+ const env = cliEnv(options);
170
854
  const key = apiKey(parsed, env);
171
- if (!key) {
172
- throw new CliUsageError("Missing API key. Set SHIPMAIL_API_KEY or pass --api-key sm_live_...");
855
+ if (key) return { kind: "api-key", token: key };
856
+ const store = await credentialStore(options);
857
+ const stored = await store.read(baseUrl(parsed, env));
858
+ if (!stored) {
859
+ throw new CliUsageError(
860
+ "Not signed in. Run shipmail login, set SHIPMAIL_API_KEY, or pass --api-key sm_live_..."
861
+ );
173
862
  }
174
- return key;
863
+ const current = await freshLogin(stored, store, options.fetch ?? fetch);
864
+ return {
865
+ kind: "oauth",
866
+ token: current.accessToken,
867
+ organizationId: getString(parsed.options, "organization") ?? env["SHIPMAIL_ORGANIZATION"] ?? current.organizationId
868
+ };
869
+ }
870
+ function organizationHeader(credential) {
871
+ return credential.kind === "oauth" && credential.organizationId ? { "X-Shipmail-Organization": credential.organizationId } : {};
175
872
  }
176
- function createClient(parsed, options) {
177
- const env = options.env ?? processEnv;
873
+ async function createClient(parsed, options) {
874
+ const env = cliEnv(options);
875
+ const credential = await resolveCredential(parsed, options);
178
876
  const config = {
179
- apiKey: requireApiKey(parsed, env),
877
+ apiKey: credential.token,
180
878
  baseUrl: baseUrl(parsed, env),
879
+ defaultHeaders: organizationHeader(credential),
181
880
  ...options.fetch ? { fetch: options.fetch } : {}
182
881
  };
183
882
  return new ShipmailClient(config);
184
883
  }
884
+ async function runLogin(parsed, options, out) {
885
+ const env = cliEnv(options);
886
+ const errOut = options.stderr ?? stderr;
887
+ const base = baseUrl(parsed, env);
888
+ const noBrowser = getBoolean(parsed.options, "no-browser") ?? false;
889
+ const stored = await login({
890
+ baseUrl: base,
891
+ fetch: options.fetch ?? fetch,
892
+ store: await credentialStore(options),
893
+ openBrowser: noBrowser ? null : options.openBrowser ?? openInBrowser,
894
+ print: (line) => errOut.write(`${line}
895
+ `),
896
+ timeoutMs: options.loginTimeoutMs ?? LOGIN_TIMEOUT_MS,
897
+ organizationId: getString(parsed.options, "organization")
898
+ });
899
+ const organization = stored.organizationId ? ` Default organization: ${stored.organizationId}.` : "";
900
+ out.write(`Signed in to ${new URL(base).origin}.${organization}
901
+ `);
902
+ }
903
+ async function runLogout(parsed, options, out) {
904
+ const env = cliEnv(options);
905
+ const errOut = options.stderr ?? stderr;
906
+ const base = baseUrl(parsed, env);
907
+ const origin = new URL(base).origin;
908
+ const store = await credentialStore(options);
909
+ const signedOut = await store.withLock(async () => {
910
+ const stored = await store.read(base);
911
+ if (!stored) return false;
912
+ if (stored.revocationEndpoint && stored.refreshToken) {
913
+ try {
914
+ await revokeToken(
915
+ stored.revocationEndpoint,
916
+ stored.clientId,
917
+ stored.refreshToken,
918
+ options.fetch ?? fetch
919
+ );
920
+ } catch (err) {
921
+ const reason = err instanceof Error ? err.message : String(err);
922
+ errOut.write(`Could not revoke the login at the server: ${reason}
923
+ `);
924
+ }
925
+ }
926
+ const latest = await store.read(base);
927
+ if (latest && !sameGeneration(latest, stored)) {
928
+ throw new Error("A new login was saved while signing out. Run shipmail logout again.");
929
+ }
930
+ try {
931
+ await store.delete(base);
932
+ } catch (err) {
933
+ const reason = err instanceof Error ? err.message : String(err);
934
+ throw new Error(`${reason} The login is still saved on this computer.`, { cause: err });
935
+ }
936
+ return true;
937
+ });
938
+ out.write(signedOut ? `Signed out of ${origin}.
939
+ ` : `Not signed in to ${origin}.
940
+ `);
941
+ }
185
942
  function writeJson(out, value) {
186
943
  out.write(`${JSON.stringify(value, null, 2)}
187
944
  `);
188
945
  }
189
946
  async function readJsonInput(value) {
190
947
  if (value === void 0) return void 0;
191
- const raw = value.startsWith("@") ? await readFile(value.slice(1), "utf8") : value;
948
+ const raw = value.startsWith("@") ? await readFile2(value.slice(1), "utf8") : value;
192
949
  try {
193
950
  return JSON.parse(raw);
194
951
  } catch {
@@ -197,7 +954,7 @@ async function readJsonInput(value) {
197
954
  }
198
955
  async function readTextInput(value) {
199
956
  if (value === void 0) return void 0;
200
- if (value.startsWith("@")) return readFile(value.slice(1), "utf8");
957
+ if (value.startsWith("@")) return readFile2(value.slice(1), "utf8");
201
958
  return value;
202
959
  }
203
960
  function isJsonObject(value) {
@@ -353,7 +1110,9 @@ async function rawApiRequest(parsed, runOptions, method, path, body, authRequire
353
1110
  ...parseHeaders(getStringList(parsed.options, "header"))
354
1111
  };
355
1112
  if (authRequired) {
356
- headers["Authorization"] = `Bearer ${requireApiKey(parsed, env)}`;
1113
+ const credential = await resolveCredential(parsed, runOptions);
1114
+ headers["Authorization"] = `Bearer ${credential.token}`;
1115
+ Object.assign(headers, organizationHeader(credential));
357
1116
  } else {
358
1117
  const key = apiKey(parsed, env);
359
1118
  if (key) headers["Authorization"] = `Bearer ${key}`;
@@ -379,7 +1138,7 @@ async function rawApiRequest(parsed, runOptions, method, path, body, authRequire
379
1138
  async function writeRawResponse(parsed, io, response) {
380
1139
  const outputPath = getString(parsed.options, "output");
381
1140
  if (outputPath) {
382
- await writeFile(outputPath, Buffer.from(await response.arrayBuffer()));
1141
+ await writeFile2(outputPath, Buffer.from(await response.arrayBuffer()));
383
1142
  io.stdout.write(`${outputPath}
384
1143
  `);
385
1144
  return;
@@ -400,8 +1159,11 @@ function usage() {
400
1159
  return `shipmail-cli v${VERSION}
401
1160
 
402
1161
  Usage:
1162
+ shipmail login [--no-browser] [--organization org_...]
1163
+ shipmail logout
403
1164
  shipmail status
404
1165
  shipmail capabilities
1166
+ shipmail organization access
405
1167
  shipmail api <METHOD> <PATH> [--data JSON|@file] [--output file]
406
1168
  shipmail automations list
407
1169
  shipmail automations create --name "Daily brief" --definition JSON|@file [--idempotency-key key]
@@ -431,7 +1193,7 @@ Usage:
431
1193
  shipmail mailboxes identities <id>
432
1194
  shipmail mailboxes rules <id>
433
1195
  shipmail mailboxes forwarding <id>
434
- shipmail mailboxes forwarding-add <id> --destination owner@example.com [--sender newsletter@example.com]
1196
+ shipmail mailboxes forwarding-add <id> --destination owner@example.com [--sender newsletter@example.com] [--recipient support@example.com]
435
1197
  shipmail mailboxes forwarding-remove <id> <forwarding-id>
436
1198
  shipmail mailboxes delivery-routing <id>
437
1199
  shipmail mailboxes delivery-routing-set <id> --mode keep|fixed|round-robin [--destination-mailbox-id mbx_...] [--fallback-mailbox-id mbx_...]
@@ -465,7 +1227,8 @@ Usage:
465
1227
  shipmail reply-scans status <scan-id>
466
1228
  shipmail reply-scans results <scan-id> [--limit 25] [--cursor cur]
467
1229
  shipmail webhooks list
468
- shipmail webhooks create --url https://example.com/hook --events message.received,message.bounced
1230
+ shipmail webhooks create --url https://example.com/hook --events message.received,message.bounced [--authorization "Bearer key"] [--standard-webhooks-secret whsec_...]
1231
+ shipmail webhooks update <webhook-id> [--url https://example.com/hook] [--events message.received] [--description text] [--active true|false] [--authorization "Bearer key" | --clear-authorization] [--standard-webhooks-secret whsec_... | --clear-standard-webhooks-secret]
469
1232
  shipmail webhooks replay-delivery <webhook-id> <delivery-id> [--idempotency-key key]
470
1233
  shipmail suppressions list
471
1234
  shipmail suppressions remove <email>
@@ -541,18 +1304,44 @@ Newsletter block prose:
541
1304
  Newsletter feeds:
542
1305
  --feed-entry-url <url> Link this issue's Atom feed title to an http(s) URL instead of its Shipmail archive.
543
1306
 
1307
+ Sign-in:
1308
+ Run shipmail login once to sign in through the browser instead of using an API key.
1309
+ Run shipmail login --help for details.
1310
+
544
1311
  Global options:
545
- --api-key <key> API key. Defaults to SHIPMAIL_API_KEY.
1312
+ --api-key <key> API key. Defaults to SHIPMAIL_API_KEY, then the shipmail login session.
1313
+ --organization <id> Organization for a login that can reach several. Defaults to
1314
+ SHIPMAIL_ORGANIZATION, then the one saved by shipmail login.
546
1315
  --base-url <url> API base URL. Defaults to SHIPMAIL_BASE_URL or ${DEFAULT_BASE_URL}.
547
1316
  --header "K: V" Extra header for the generic api command. Repeatable.
548
1317
 
549
1318
  Environment:
550
1319
  SHIPMAIL_API_KEY API key for authenticated commands.
1320
+ SHIPMAIL_ORGANIZATION Organization ID sent with a shipmail login session.
551
1321
  SHIPMAIL_BASE_URL Optional API base URL. Must be https on shipmail.to.
552
1322
  SHIPMAIL_ALLOW_INSECURE_BASE_URL=1
553
1323
  Permit a non-https or non-shipmail.to base URL (development only).
554
1324
  `;
555
1325
  }
1326
+ function loginUsage() {
1327
+ return `Usage:
1328
+ shipmail login [--no-browser] [--organization org_...]
1329
+ shipmail logout
1330
+
1331
+ shipmail login opens Shipmail in your browser. Sign in, choose the organizations the CLI may
1332
+ reach, and approve. The CLI then runs commands without an API key.
1333
+
1334
+ --no-browser Print the sign-in URL without opening a browser.
1335
+ --organization <id> Save a default organization, for a login that can reach several.
1336
+
1337
+ The login is saved in the macOS Keychain, in the Secret Service through secret-tool on Linux,
1338
+ or otherwise in $XDG_CONFIG_HOME/shipmail/credentials.json (~/.config/shipmail/ by default),
1339
+ readable only by you. It refreshes itself. An API key from --api-key or SHIPMAIL_API_KEY takes
1340
+ precedence over the login.
1341
+
1342
+ shipmail logout revokes the login at Shipmail and deletes it from this computer.
1343
+ `;
1344
+ }
556
1345
  function requirePositional(positional, index, label) {
557
1346
  const value = positional[index];
558
1347
  if (!value) throw new CliUsageError(`Missing ${label}.`);
@@ -592,6 +1381,39 @@ function webhookEvents(options) {
592
1381
  }
593
1382
  return events;
594
1383
  }
1384
+ function webhookWriteOnlyOption(options, name) {
1385
+ const value = optionValue(options, name);
1386
+ if (value === void 0) return void 0;
1387
+ if (typeof value === "boolean") throw new CliUsageError(`Missing value for --${name}.`);
1388
+ return getString(options, name);
1389
+ }
1390
+ function webhookWriteOnlyUpdate(options, name) {
1391
+ const value = webhookWriteOnlyOption(options, name);
1392
+ const clear = getBoolean(options, `clear-${name}`) === true;
1393
+ if (value !== void 0 && clear) {
1394
+ throw new CliUsageError(`Use --${name} or --clear-${name}, not both.`);
1395
+ }
1396
+ return clear ? null : value;
1397
+ }
1398
+ function webhookUpdateParams(options) {
1399
+ const authorization = webhookWriteOnlyUpdate(options, "authorization");
1400
+ const standardWebhooksSecret = webhookWriteOnlyUpdate(options, "standard-webhooks-secret");
1401
+ const url = getString(options, "url");
1402
+ const description = getString(options, "description");
1403
+ const active = getBoolean(options, "active");
1404
+ const params = {
1405
+ ...url !== void 0 ? { url } : {},
1406
+ ...optionValue(options, "events") !== void 0 ? { events: webhookEvents(options) } : {},
1407
+ ...description !== void 0 ? { description } : {},
1408
+ ...active !== void 0 ? { active } : {},
1409
+ ...authorization !== void 0 ? { authorization } : {},
1410
+ ...standardWebhooksSecret !== void 0 ? { standard_webhooks_secret: standardWebhooksSecret } : {}
1411
+ };
1412
+ if (Object.keys(params).length === 0) {
1413
+ throw new CliUsageError("Nothing to update. Pass at least one webhook option.");
1414
+ }
1415
+ return params;
1416
+ }
595
1417
  function webhookDeliveryParams(options) {
596
1418
  const status = getString(options, "status");
597
1419
  const eventType = getString(options, "event-type");
@@ -775,7 +1597,7 @@ async function runMailboxes(client, parsed, out) {
775
1597
  if (!passwordValue && !passwordFile && !generatePassword) {
776
1598
  throw new CliUsageError("Missing --password, --password-file, or --generate-password.");
777
1599
  }
778
- const password = passwordFile ? (await readFile(passwordFile, "utf8")).replace(/\r?\n$/, "") : passwordValue;
1600
+ const password = passwordFile ? (await readFile2(passwordFile, "utf8")).replace(/\r?\n$/, "") : passwordValue;
779
1601
  const credentials = generatePassword ? { generate_password: true } : password ? { password } : void 0;
780
1602
  if (!credentials) {
781
1603
  throw new CliUsageError("Mailbox password must not be empty.");
@@ -912,7 +1734,8 @@ async function runMailboxes(client, parsed, out) {
912
1734
  requirePositional(parsed.positional, 2, "mailbox id"),
913
1735
  {
914
1736
  destination: getRequiredString(parsed.options, "destination"),
915
- sender: getString(parsed.options, "sender") ?? null
1737
+ sender: getString(parsed.options, "sender") ?? null,
1738
+ recipient: getString(parsed.options, "recipient") ?? null
916
1739
  }
917
1740
  )
918
1741
  );
@@ -976,7 +1799,7 @@ async function runMailboxes(client, parsed, out) {
976
1799
  if (subcommand === "stage-attachment") {
977
1800
  const mailboxId = requirePositional(parsed.positional, 2, "mailbox id");
978
1801
  const filePath = requirePositional(parsed.positional, 3, "file");
979
- const data = await readFile(realpathSync(filePath));
1802
+ const data = await readFile2(realpathSync(filePath));
980
1803
  writeJson(
981
1804
  out,
982
1805
  await client.mailboxes.stageAttachment(mailboxId, {
@@ -990,14 +1813,14 @@ async function runMailboxes(client, parsed, out) {
990
1813
  if (subcommand === "prepare-attachment-upload") {
991
1814
  const mailboxId = requirePositional(parsed.positional, 2, "mailbox id");
992
1815
  const filePath = requirePositional(parsed.positional, 3, "file");
993
- const data = await readFile(realpathSync(filePath));
1816
+ const data = await readFile2(realpathSync(filePath));
994
1817
  writeJson(
995
1818
  out,
996
1819
  await client.mailboxes.prepareStagedAttachmentUpload(mailboxId, {
997
1820
  filename: getString(parsed.options, "filename") ?? filePath.split(/[\\/]/).at(-1) ?? "attachment",
998
1821
  content_type: getString(parsed.options, "content-type") ?? "application/octet-stream",
999
1822
  size: data.byteLength,
1000
- sha256: createHash("sha256").update(data).digest("hex")
1823
+ sha256: createHash2("sha256").update(data).digest("hex")
1001
1824
  })
1002
1825
  );
1003
1826
  return;
@@ -1381,16 +2204,28 @@ async function runWebhooks(client, parsed, out) {
1381
2204
  }
1382
2205
  if (subcommand === "create") {
1383
2206
  const description = getString(parsed.options, "description");
2207
+ const authorization = webhookWriteOnlyOption(parsed.options, "authorization");
2208
+ const standardWebhooksSecret = webhookWriteOnlyOption(
2209
+ parsed.options,
2210
+ "standard-webhooks-secret"
2211
+ );
1384
2212
  writeJson(
1385
2213
  out,
1386
2214
  await client.webhooks.create({
1387
2215
  url: getRequiredString(parsed.options, "url"),
1388
2216
  events: webhookEvents(parsed.options),
1389
- ...description ? { description } : {}
2217
+ ...description ? { description } : {},
2218
+ ...authorization !== void 0 ? { authorization } : {},
2219
+ ...standardWebhooksSecret !== void 0 ? { standard_webhooks_secret: standardWebhooksSecret } : {}
1390
2220
  })
1391
2221
  );
1392
2222
  return;
1393
2223
  }
2224
+ if (subcommand === "update") {
2225
+ const webhookId = requirePositional(parsed.positional, 2, "webhook id");
2226
+ writeJson(out, await client.webhooks.update(webhookId, webhookUpdateParams(parsed.options)));
2227
+ return;
2228
+ }
1394
2229
  if (subcommand === "test") {
1395
2230
  writeJson(
1396
2231
  out,
@@ -1795,7 +2630,7 @@ async function runNewsletterAssets(client, parsed, out, fetchImpl) {
1795
2630
  }
1796
2631
  if (subcommand === "upload") {
1797
2632
  const filePath = requirePositional(parsed.positional, 2, "file");
1798
- const data = await readFile(realpathSync(filePath));
2633
+ const data = await readFile2(realpathSync(filePath));
1799
2634
  const filename = getString(parsed.options, "filename") ?? filePath.split(/[\\/]/).at(-1) ?? "newsletter-asset";
1800
2635
  const contentType = newsletterAssetContentType(
1801
2636
  filePath,
@@ -1806,7 +2641,7 @@ async function runNewsletterAssets(client, parsed, out, fetchImpl) {
1806
2641
  filename,
1807
2642
  content_type: contentType,
1808
2643
  size: data.byteLength,
1809
- sha256: createHash("sha256").update(data).digest("hex")
2644
+ sha256: createHash2("sha256").update(data).digest("hex")
1810
2645
  });
1811
2646
  await putPreparedNewsletterBytes(fetchImpl, prepared.upload_url, prepared.upload_headers, data);
1812
2647
  if (prepared.kind === "video") {
@@ -1869,7 +2704,7 @@ function newsletterAssetContentType(filePath, declared) {
1869
2704
  async function readNewsletterVideoThumbnail(options) {
1870
2705
  const thumbnailPath = getString(options, "thumbnail");
1871
2706
  if (!thumbnailPath) throw new CliUsageError("Video uploads require --thumbnail <jpeg-file>.");
1872
- const bytes = await readFile(realpathSync(thumbnailPath));
2707
+ const bytes = await readFile2(realpathSync(thumbnailPath));
1873
2708
  if (bytes.byteLength < 3 || bytes[0] !== 255 || bytes[1] !== 216 || bytes[2] !== 255) {
1874
2709
  throw new CliUsageError("--thumbnail must be a JPEG file.");
1875
2710
  }
@@ -2215,6 +3050,11 @@ ${JSON.stringify(err.body, null, 2)}
2215
3050
  const messageId = shipmailError instanceof ReconciliationRequiredError && shipmailError.messageId ? ` message_id=${shipmailError.messageId}` : "";
2216
3051
  errOut.write(`Error: ${shipmailError.message}${status}${requestId}${messageId}
2217
3052
  `);
3053
+ if (shipmailError.actionUrl) {
3054
+ const reason = shipmailError.reason ? ` (${shipmailError.reason})` : "";
3055
+ errOut.write(`Ask the organization owner to open${reason}: ${shipmailError.actionUrl}
3056
+ `);
3057
+ }
2218
3058
  return;
2219
3059
  }
2220
3060
  errOut.write(`Error: ${err instanceof Error ? err.message : String(err)}
@@ -2231,6 +3071,10 @@ async function runCli(argv, options = {}) {
2231
3071
  `);
2232
3072
  return 0;
2233
3073
  }
3074
+ if ((command === "login" || command === "logout") && optionValue(parsed.options, "help")) {
3075
+ out.write(loginUsage());
3076
+ return 0;
3077
+ }
2234
3078
  if (!command || command === "help" || command === "-h" || optionValue(parsed.options, "help")) {
2235
3079
  out.write(usage());
2236
3080
  return 0;
@@ -2260,8 +3104,21 @@ async function runCli(argv, options = {}) {
2260
3104
  await writeRawResponse(parsed, { stdout: out }, response);
2261
3105
  return 0;
2262
3106
  }
2263
- const client = createClient(parsed, options);
2264
- if (command === "automations") await runAutomations(client, parsed, out);
3107
+ if (command === "login") {
3108
+ await runLogin(parsed, options, out);
3109
+ return 0;
3110
+ }
3111
+ if (command === "logout") {
3112
+ await runLogout(parsed, options, out);
3113
+ return 0;
3114
+ }
3115
+ const client = await createClient(parsed, options);
3116
+ if (command === "organization") {
3117
+ if (parsed.positional[1] !== "access") {
3118
+ throw new CliUsageError("Unknown organization command.");
3119
+ }
3120
+ writeJson(out, await client.organization.access());
3121
+ } else if (command === "automations") await runAutomations(client, parsed, out);
2265
3122
  else if (command === "domains") await runDomains(client, parsed, out);
2266
3123
  else if (command === "mailboxes") await runMailboxes(client, parsed, out);
2267
3124
  else if (command === "members") await runMembers(client, parsed, out);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipmail-cli",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Official CLI for the Shipmail API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,8 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "shipmail": "0.6.2"
44
+ "shipmail": "0.6.4",
45
+ "zod": "^4.6.5"
45
46
  },
46
47
  "devDependencies": {
47
48
  "tsup": "^8.4.0",