@sendmux/cli 1.4.0 → 1.5.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 (37) hide show
  1. package/README.md +53 -7
  2. package/dist/agent-auth.d.ts +37 -0
  3. package/dist/agent-auth.d.ts.map +1 -0
  4. package/dist/agent-auth.js +348 -0
  5. package/dist/base-command.d.ts.map +1 -1
  6. package/dist/base-command.js +32 -4
  7. package/dist/commands/agent/invite-owner.d.ts +12 -0
  8. package/dist/commands/agent/invite-owner.d.ts.map +1 -0
  9. package/dist/commands/agent/invite-owner.js +36 -0
  10. package/dist/commands/agent/register.d.ts +16 -0
  11. package/dist/commands/agent/register.d.ts.map +1 -0
  12. package/dist/commands/agent/register.js +42 -0
  13. package/dist/commands/mailbox/get-connection.d.ts +26 -0
  14. package/dist/commands/mailbox/get-connection.d.ts.map +1 -0
  15. package/dist/commands/mailbox/get-connection.js +7 -0
  16. package/dist/commands/management/get-connection.d.ts +26 -0
  17. package/dist/commands/management/get-connection.d.ts.map +1 -0
  18. package/dist/commands/management/get-connection.js +7 -0
  19. package/dist/commands/profiles/list.d.ts.map +1 -1
  20. package/dist/commands/profiles/list.js +26 -8
  21. package/dist/commands/profiles/set.d.ts.map +1 -1
  22. package/dist/commands/profiles/set.js +12 -11
  23. package/dist/commands/profiles/show.d.ts.map +1 -1
  24. package/dist/commands/profiles/show.js +19 -7
  25. package/dist/commands/sending/get-connection.d.ts +26 -0
  26. package/dist/commands/sending/get-connection.d.ts.map +1 -0
  27. package/dist/commands/sending/get-connection.js +7 -0
  28. package/dist/generated/operations.d.ts +63 -0
  29. package/dist/generated/operations.d.ts.map +1 -1
  30. package/dist/generated/operations.js +69 -0
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/profiles.d.ts +38 -2
  34. package/dist/profiles.d.ts.map +1 -1
  35. package/dist/profiles.js +147 -4
  36. package/oclif.manifest.json +743 -150
  37. package/package.json +5 -2
package/README.md CHANGED
@@ -17,10 +17,11 @@ Agent-drivable command line interface for Sendmux.
17
17
 
18
18
  ## Requirements
19
19
 
20
+ - No existing Sendmux account or API key is required to register an agent inbox.
20
21
  - npm global installs, `npx`, or a downloaded release tarball.
21
22
  - A root `smx_root_*` key for Management commands.
22
- - A send-capable `smx_mbx_*` key or owner-approved Sending-resource `smx_agent_*` token for Sending commands.
23
- - A mailbox-scoped `smx_mbx_*` key or scoped `smx_agent_*` token for Mailbox commands. Agent tokens remain limited by server-side scopes; pre-claim self-registered agent tokens do not include `email.send`.
23
+ - A send-capable `smx_mbx_*` key or owner-approved agent profile for Sending commands.
24
+ - A mailbox-scoped `smx_mbx_*` key or registered agent profile for Mailbox commands.
24
25
 
25
26
  ## Installation
26
27
 
@@ -32,7 +33,41 @@ The package exposes the `sendmux` binary.
32
33
 
33
34
  ## Usage
34
35
 
35
- Create profiles for each key type before running API commands.
36
+ Register an agent inbox and save its durable read credential in a local profile:
37
+
38
+ ```sh
39
+ sendmux agent:register my-agent \
40
+ --mailbox-local-part my-agent \
41
+ --client-name "My agent" \
42
+ --default \
43
+ --json
44
+ ```
45
+
46
+ The registration result never prints the credential. The profile can read and receive mail without an expiry date, unless the registration is fully revoked. Inbox readiness may take a moment; the command waits for provisioning for up to 10 minutes and can be rerun safely with the same profile.
47
+
48
+ Read the inbox from any later process:
49
+
50
+ ```sh
51
+ sendmux mailbox:messages:list --profile my-agent --query limit=25 --json
52
+ ```
53
+
54
+ To enable sending, invite the inbox owner either during registration with `--owner-email` or afterward:
55
+
56
+ ```sh
57
+ sendmux agent:invite-owner owner@example.com --profile my-agent --json
58
+ ```
59
+
60
+ The owner must accept the invitation and approve sending. After approval, Sending API commands automatically exchange the durable read credential for a one-hour `email.send` token and reuse it until it approaches expiry:
61
+
62
+ ```sh
63
+ sendmux sending:send \
64
+ --profile my-agent \
65
+ --idempotency-key "$IDEMPOTENCY_KEY" \
66
+ --body '{"from":{"email":"sender@example.com"},"to":{"email":"recipient@example.com"},"subject":"Hello","text_body":"Hello"}' \
67
+ --json
68
+ ```
69
+
70
+ Existing Sendmux users can continue creating API-key profiles:
36
71
 
37
72
  ```sh
38
73
  sendmux profiles:set default --api-key smx_root_... --default
@@ -50,6 +85,16 @@ sendmux sending:send --profile sending --body '{"from":{"email":"sender@example.
50
85
 
51
86
  Commands reject mismatched key types before making a network request.
52
87
 
88
+ Check the selected profile's connection without sending an email:
89
+
90
+ ```sh
91
+ sendmux management:get-connection --profile default --json
92
+ sendmux mailbox:get-connection --profile mailbox --json
93
+ sendmux sending:get-connection --profile sending --json
94
+ ```
95
+
96
+ Each command returns the team, credential, connection label, permissions, and authorised mailboxes. Mailbox connection checks do not need a mailbox selector.
97
+
53
98
  ## Attachments And Events
54
99
 
55
100
  Send a mailbox message with a local file in one command:
@@ -98,11 +143,12 @@ sendmux mailbox:stream-events \
98
143
 
99
144
  ## Commands
100
145
 
101
- The CLI includes `97` generated API operation commands:
146
+ The CLI includes `104` generated API operation commands:
102
147
 
103
- - `41` Mailbox commands, including `mailbox:messages:list`, `mailbox:messages:get`, `mailbox:send-message`, and `mailbox:list-granted-mailboxes`.
104
- - `53` Management commands, including `management:domains:list`, `management:create-mailbox`, `management:get-spend-summary`, and `management:create-webhook`.
105
- - `3` Sending commands: `sending:get-open-api-spec`, `sending:send`, and `sending:send:batch`.
148
+ - `42` Mailbox commands, including `mailbox:get-connection`, `mailbox:messages:list`, `mailbox:send-message`, and `mailbox:list-granted-mailboxes`.
149
+ - `54` Management commands, including `management:get-connection`, `management:domains:list`, `management:create-mailbox`, and `management:create-webhook`.
150
+ - `8` Sending commands, including `sending:get-connection`, `sending:get-open-api-spec`, `sending:send`, `sending:send:batch`, and attachment upload commands.
151
+ - Agent onboarding commands: `agent:register` and `agent:invite-owner`.
106
152
  - Profile commands: `profiles:list`, `profiles:set`, and `profiles:show`.
107
153
 
108
154
  Use command-level help for required path, query, header, and body fields.
@@ -0,0 +1,37 @@
1
+ import { type ActiveAgentCliProfile, type CliConfig } from "./profiles.js";
2
+ interface RegisterAgentInput {
3
+ appOrigin?: string;
4
+ clientName?: string;
5
+ configDir: string;
6
+ makeDefault: boolean;
7
+ mailboxLocalPart?: string;
8
+ ownerEmail?: string;
9
+ profileName: string;
10
+ }
11
+ export interface SafeAgentRegistrationResult {
12
+ default: boolean;
13
+ mailbox_email: string;
14
+ name: string;
15
+ owner_invite_status?: "pending";
16
+ registration_id: string;
17
+ status: "active";
18
+ }
19
+ export declare function registerAgent(input: RegisterAgentInput): Promise<SafeAgentRegistrationResult>;
20
+ export declare function inviteAgentOwner({ configDir, email, profileName, }: {
21
+ configDir: string;
22
+ email: string;
23
+ profileName: string;
24
+ }): Promise<{
25
+ email: string;
26
+ status: "pending";
27
+ }>;
28
+ export declare function resolveAgentSendingToken({ config, configDir, profile, profileName, }: {
29
+ config: CliConfig;
30
+ configDir: string;
31
+ profile: ActiveAgentCliProfile;
32
+ profileName: string;
33
+ }): Promise<string>;
34
+ export declare function normaliseAppOrigin(value: string): string;
35
+ export declare function waitForMailbox(profile: ActiveAgentCliProfile, timeoutMs?: number): Promise<void>;
36
+ export {};
37
+ //# sourceMappingURL=agent-auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-auth.d.ts","sourceRoot":"","sources":["../src/agent-auth.ts"],"names":[],"mappings":"AAGA,OAAO,EAKL,KAAK,qBAAqB,EAE1B,KAAK,SAAS,EAGf,MAAM,eAAe,CAAC;AAUvB,UAAU,kBAAkB;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,OAAO,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;CACrB;AAWD,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,mBAAmB,CAAC,EAAE,SAAS,CAAC;IAChC,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,QAAQ,CAAC;CAClB;AAED,wBAAsB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,2BAA2B,CAAC,CAyGnG;AAED,wBAAsB,gBAAgB,CAAC,EACrC,SAAS,EACT,KAAK,EACL,WAAW,GACZ,EAAE;IACD,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,CAAA;CAAE,CAAC,CAiChD;AAED,wBAAsB,wBAAwB,CAAC,EAC7C,MAAM,EACN,SAAS,EACT,OAAO,EACP,WAAW,GACZ,EAAE;IACD,MAAM,EAAE,SAAS,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,qBAAqB,CAAC;IAC/B,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,MAAM,CAAC,CA2ClB;AA6DD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAYxD;AAaD,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,EAC9B,SAAS,SAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CA4Bf"}
@@ -0,0 +1,348 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+ import { clearAgentRegistrationIntent, isActiveAgentProfile, readCliConfig, reserveAgentRegistrationIntent, updateCliConfig, } from "./profiles.js";
4
+ const DEFAULT_APP_ORIGIN = "https://app.sendmux.ai";
5
+ const DEFAULT_SENDING_API_BASE_URL = "https://smtp.sendmux.ai/api/v1";
6
+ const SENDING_API_RESOURCE = "https://smtp.sendmux.ai/api/v1";
7
+ const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
8
+ const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
9
+ const READINESS_TIMEOUT_MS = 10 * 60 * 1_000;
10
+ const SENDING_TOKEN_SKEW_MS = 60 * 1_000;
11
+ export async function registerAgent(input) {
12
+ const initialConfig = await readCliConfig(input.configDir);
13
+ const existing = initialConfig.profiles[input.profileName];
14
+ let activeProfile;
15
+ if (existing && isActiveAgentProfile(existing)) {
16
+ activeProfile = await updateCliConfig(input.configDir, (config) => {
17
+ const current = config.profiles[input.profileName];
18
+ if (!current || !isActiveAgentProfile(current)) {
19
+ throw new Error(`Sendmux agent profile "${input.profileName}" is not active.`);
20
+ }
21
+ assertMatchingRegistration(current, input);
22
+ if (input.makeDefault || !config.defaultProfile)
23
+ config.defaultProfile = input.profileName;
24
+ return current;
25
+ });
26
+ await clearAgentRegistrationIntent(input.configDir, input.profileName);
27
+ }
28
+ else {
29
+ const candidate = registrationIntent({ existing, input });
30
+ const registering = await reserveAgentRegistrationIntent(input.configDir, input.profileName, candidate);
31
+ assertMatchingRegistration(registering, input);
32
+ const persisted = await updateCliConfig(input.configDir, (config) => {
33
+ const current = config.profiles[input.profileName];
34
+ if (current && isActiveAgentProfile(current)) {
35
+ assertMatchingRegistration(current, input);
36
+ if (input.makeDefault || !config.defaultProfile)
37
+ config.defaultProfile = input.profileName;
38
+ return { active: current };
39
+ }
40
+ if (current) {
41
+ if (current.type !== "agent" || current.state !== "registering") {
42
+ throw new Error(`Sendmux profile "${input.profileName}" already exists and is not an agent profile.`);
43
+ }
44
+ if (current.idempotencyKey !== registering.idempotencyKey) {
45
+ throw new Error(`Sendmux agent profile "${input.profileName}" belongs to a different registration request.`);
46
+ }
47
+ }
48
+ config.profiles[input.profileName] = registering;
49
+ if (input.makeDefault || !config.defaultProfile)
50
+ config.defaultProfile = input.profileName;
51
+ return { active: null };
52
+ });
53
+ if (persisted.active) {
54
+ activeProfile = persisted.active;
55
+ await clearAgentRegistrationIntent(input.configDir, input.profileName);
56
+ }
57
+ else {
58
+ const registration = await postJson(`${registering.authBaseUrl}/agent/identity`, {
59
+ headers: { "Idempotency-Key": registering.idempotencyKey },
60
+ body: {
61
+ type: "anonymous",
62
+ ...(registering.mailboxLocalPart ? { mailbox_local_part: registering.mailboxLocalPart } : {}),
63
+ ...(registering.clientName ? { client_name: registering.clientName } : {}),
64
+ idempotency_key: registering.idempotencyKey,
65
+ },
66
+ expectedStatuses: [200, 201],
67
+ });
68
+ assertRegistrationResponse(registration);
69
+ activeProfile = await updateCliConfig(input.configDir, (config) => {
70
+ const current = config.profiles[input.profileName];
71
+ if (current && isActiveAgentProfile(current)) {
72
+ if (current.idempotencyKey !== registering.idempotencyKey) {
73
+ throw new Error(`Sendmux agent profile "${input.profileName}" belongs to a different registration request.`);
74
+ }
75
+ return current;
76
+ }
77
+ if (!current ||
78
+ current.type !== "agent" ||
79
+ current.state !== "registering" ||
80
+ current.idempotencyKey !== registering.idempotencyKey) {
81
+ throw new Error(`Sendmux agent profile "${input.profileName}" belongs to a different registration request.`);
82
+ }
83
+ const activated = {
84
+ ...current,
85
+ accessToken: registration.access_token,
86
+ mailboxEmail: registration.mailbox.email,
87
+ registrationId: registration.registration_id,
88
+ state: "active",
89
+ };
90
+ config.profiles[input.profileName] = activated;
91
+ return activated;
92
+ });
93
+ await clearAgentRegistrationIntent(input.configDir, input.profileName);
94
+ }
95
+ }
96
+ const reloaded = await activeAgentProfile(input.configDir, input.profileName);
97
+ await waitForMailbox(reloaded);
98
+ let ownerInviteStatus;
99
+ if (input.ownerEmail) {
100
+ await inviteAgentOwner({ configDir: input.configDir, email: input.ownerEmail, profileName: input.profileName });
101
+ ownerInviteStatus = "pending";
102
+ }
103
+ const finalConfig = await readCliConfig(input.configDir);
104
+ return {
105
+ default: finalConfig.defaultProfile === input.profileName,
106
+ mailbox_email: activeProfile.mailboxEmail,
107
+ name: input.profileName,
108
+ ...(ownerInviteStatus ? { owner_invite_status: ownerInviteStatus } : {}),
109
+ registration_id: activeProfile.registrationId,
110
+ status: "active",
111
+ };
112
+ }
113
+ export async function inviteAgentOwner({ configDir, email, profileName, }) {
114
+ const invitation = await updateCliConfig(configDir, (config) => {
115
+ const profile = activeAgentProfileFromConfig(config, profileName);
116
+ const existingInvite = profile.ownerInvite;
117
+ const idempotencyKey = existingInvite?.email.toLowerCase() === email.toLowerCase() ? existingInvite.idempotencyKey : randomUUID();
118
+ const updatedProfile = {
119
+ ...profile,
120
+ ownerInvite: { email, idempotencyKey, status: "dispatching" },
121
+ };
122
+ config.profiles[profileName] = updatedProfile;
123
+ return { idempotencyKey, profile: updatedProfile };
124
+ });
125
+ await postJson(`${invitation.profile.authBaseUrl}/agent/identity/invite`, {
126
+ headers: {
127
+ Authorization: `Bearer ${invitation.profile.accessToken}`,
128
+ "Idempotency-Key": invitation.idempotencyKey,
129
+ },
130
+ body: { email, idempotency_key: invitation.idempotencyKey, requested_role: "owner" },
131
+ expectedStatuses: [200, 202],
132
+ });
133
+ await updateCliConfig(configDir, (config) => {
134
+ const current = activeAgentProfileFromConfig(config, profileName);
135
+ if (current.ownerInvite?.idempotencyKey === invitation.idempotencyKey) {
136
+ config.profiles[profileName] = {
137
+ ...current,
138
+ ownerInvite: { email, idempotencyKey: invitation.idempotencyKey, status: "pending" },
139
+ };
140
+ }
141
+ });
142
+ return { email, status: "pending" };
143
+ }
144
+ export async function resolveAgentSendingToken({ config, configDir, profile, profileName, }) {
145
+ if (profile.sendingToken && Date.parse(profile.sendingToken.expiresAt) > Date.now() + SENDING_TOKEN_SKEW_MS) {
146
+ return profile.sendingToken.accessToken;
147
+ }
148
+ const response = await fetch(`${profile.authBaseUrl}/oauth2/token`, {
149
+ body: new URLSearchParams({
150
+ grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
151
+ resource: SENDING_API_RESOURCE,
152
+ scope: "email.send",
153
+ subject_token: profile.accessToken,
154
+ subject_token_type: ACCESS_TOKEN_TYPE,
155
+ }),
156
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
157
+ method: "POST",
158
+ });
159
+ const body = await responseJson(response);
160
+ if (!response.ok) {
161
+ throw new Error(agentAuthFailureMessage(response.status, body));
162
+ }
163
+ const accessToken = requiredString(body, "access_token", "token exchange");
164
+ const expiresIn = body.expires_in;
165
+ if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
166
+ throw new Error("Sendmux token exchange returned an invalid expiry.");
167
+ }
168
+ const updatedProfile = await updateCliConfig(configDir, (latestConfig) => {
169
+ const latestProfile = activeAgentProfileFromConfig(latestConfig, profileName);
170
+ if (latestProfile.sendingToken && Date.parse(latestProfile.sendingToken.expiresAt) > Date.now() + SENDING_TOKEN_SKEW_MS) {
171
+ return latestProfile;
172
+ }
173
+ const updated = {
174
+ ...latestProfile,
175
+ sendingToken: {
176
+ accessToken,
177
+ expiresAt: new Date(Date.now() + expiresIn * 1_000).toISOString(),
178
+ },
179
+ };
180
+ latestConfig.profiles[profileName] = updated;
181
+ return updated;
182
+ });
183
+ config.profiles[profileName] = updatedProfile;
184
+ return updatedProfile.sendingToken.accessToken;
185
+ }
186
+ function registrationIntent({ existing, input, }) {
187
+ const urls = agentUrls(input.appOrigin);
188
+ if (existing?.type === "agent") {
189
+ if (existing.state !== "registering") {
190
+ throw new Error(`Sendmux agent profile "${input.profileName}" is already active.`);
191
+ }
192
+ assertMatchingRegistration(existing, input);
193
+ return existing;
194
+ }
195
+ if (existing) {
196
+ throw new Error(`Sendmux profile "${input.profileName}" already exists and is not an agent profile.`);
197
+ }
198
+ return {
199
+ ...urls,
200
+ ...(input.clientName ? { clientName: input.clientName } : {}),
201
+ idempotencyKey: randomUUID(),
202
+ ...(input.mailboxLocalPart ? { mailboxLocalPart: input.mailboxLocalPart } : {}),
203
+ state: "registering",
204
+ type: "agent",
205
+ };
206
+ }
207
+ function assertMatchingRegistration(profile, input) {
208
+ if ((input.appOrigin !== undefined && !registrationUrlsMatch(profile, agentUrls(input.appOrigin))) ||
209
+ (input.clientName !== undefined && profile.clientName !== input.clientName) ||
210
+ (input.mailboxLocalPart !== undefined && profile.mailboxLocalPart !== input.mailboxLocalPart)) {
211
+ throw new Error(`Sendmux agent profile "${input.profileName}" belongs to a different registration request.`);
212
+ }
213
+ }
214
+ function registrationUrlsMatch(profile, urls) {
215
+ return (profile.appApiBaseUrl === urls.appApiBaseUrl &&
216
+ profile.authBaseUrl === urls.authBaseUrl &&
217
+ profile.sendingApiBaseUrl === urls.sendingApiBaseUrl);
218
+ }
219
+ function agentUrls(appOriginInput) {
220
+ const customOrigin = appOriginInput ? normaliseAppOrigin(appOriginInput) : null;
221
+ const appOrigin = customOrigin ?? DEFAULT_APP_ORIGIN;
222
+ return {
223
+ appApiBaseUrl: `${appOrigin}/api/v1`,
224
+ authBaseUrl: `${appOrigin}/agent-auth`,
225
+ sendingApiBaseUrl: customOrigin ? `${customOrigin}/api/v1` : DEFAULT_SENDING_API_BASE_URL,
226
+ };
227
+ }
228
+ export function normaliseAppOrigin(value) {
229
+ const url = new URL(value);
230
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
231
+ throw new Error("--base-url must be an HTTP(S) origin without credentials, query, or fragment.");
232
+ }
233
+ if (url.pathname !== "/") {
234
+ throw new Error("--base-url must be an origin without a path.");
235
+ }
236
+ if (url.protocol === "http:" && !isLoopbackHttpOrigin(value)) {
237
+ throw new Error("--base-url must use HTTPS unless it is a canonical loopback origin.");
238
+ }
239
+ return url.origin;
240
+ }
241
+ function isLoopbackHttpOrigin(value) {
242
+ const authority = value.match(/^http:\/\/([^/?#]+)/i)?.[1];
243
+ if (!authority)
244
+ return false;
245
+ const rawHostname = authority.startsWith("[")
246
+ ? authority.slice(1, authority.indexOf("]"))
247
+ : authority.split(":", 1)[0];
248
+ if (!rawHostname)
249
+ return false;
250
+ if (rawHostname.toLowerCase() === "localhost" || rawHostname === "::1")
251
+ return true;
252
+ return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(rawHostname) && isIP(rawHostname) === 4 && rawHostname.split(".")[0] === "127";
253
+ }
254
+ export async function waitForMailbox(profile, timeoutMs = READINESS_TIMEOUT_MS) {
255
+ const deadline = Date.now() + timeoutMs;
256
+ while (true) {
257
+ const remainingBeforeFetch = deadline - Date.now();
258
+ if (remainingBeforeFetch <= 0)
259
+ throw mailboxReadinessTimeoutError();
260
+ let response;
261
+ try {
262
+ response = await fetch(`${profile.appApiBaseUrl}/mailbox/me`, {
263
+ headers: { Authorization: `Bearer ${profile.accessToken}` },
264
+ signal: AbortSignal.timeout(remainingBeforeFetch),
265
+ });
266
+ }
267
+ catch (error) {
268
+ if (isAbortError(error) || Date.now() >= deadline)
269
+ throw mailboxReadinessTimeoutError();
270
+ throw error;
271
+ }
272
+ if (response.ok)
273
+ return;
274
+ const body = await responseJson(response);
275
+ const provisioningUnavailable = response.status === 503 && (body.error === "service_unavailable" || body.error === "temporarily_unavailable");
276
+ if (!provisioningUnavailable) {
277
+ throw new Error(agentAuthFailureMessage(response.status, body));
278
+ }
279
+ const remainingBeforeRetry = deadline - Date.now();
280
+ if (remainingBeforeRetry <= 0)
281
+ throw mailboxReadinessTimeoutError();
282
+ const retryDelayMs = Math.min(Math.max(retryAfterSeconds(response, body) * 1_000, 1_000), remainingBeforeRetry);
283
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
284
+ }
285
+ }
286
+ function isAbortError(error) {
287
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
288
+ }
289
+ function mailboxReadinessTimeoutError() {
290
+ return new Error("Agent mailbox provisioning did not finish within the allowed time.");
291
+ }
292
+ async function activeAgentProfile(configDir, profileName) {
293
+ return activeAgentProfileFromConfig(await readCliConfig(configDir), profileName);
294
+ }
295
+ function activeAgentProfileFromConfig(config, profileName) {
296
+ const profile = config.profiles[profileName];
297
+ if (!profile || !isActiveAgentProfile(profile)) {
298
+ throw new Error(`Sendmux agent profile "${profileName}" is not active.`);
299
+ }
300
+ return profile;
301
+ }
302
+ async function postJson(url, options) {
303
+ const response = await fetch(url, {
304
+ body: JSON.stringify(options.body),
305
+ headers: { "Content-Type": "application/json", ...options.headers },
306
+ method: "POST",
307
+ });
308
+ const body = await responseJson(response);
309
+ if (!options.expectedStatuses.includes(response.status)) {
310
+ throw new Error(agentAuthFailureMessage(response.status, body));
311
+ }
312
+ return body;
313
+ }
314
+ async function responseJson(response) {
315
+ const body = await response.json().catch(() => null);
316
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
317
+ throw new Error(`Sendmux agent authentication returned HTTP ${response.status} without a JSON object.`);
318
+ }
319
+ return body;
320
+ }
321
+ function retryAfterSeconds(response, body) {
322
+ const value = Number(response.headers.get("Retry-After") ?? body.retry_after ?? 10);
323
+ return Number.isFinite(value) && value > 0 ? value : 1;
324
+ }
325
+ function agentAuthFailureMessage(status, body) {
326
+ if (status === 503 && body.error === "authorization_pending") {
327
+ return "Agent sending is awaiting owner acceptance or approval.";
328
+ }
329
+ if (status === 503 && (body.error === "service_unavailable" || body.error === "temporarily_unavailable")) {
330
+ return "Agent mailbox provisioning did not finish within the allowed time.";
331
+ }
332
+ const description = typeof body.error_description === "string" ? body.error_description : null;
333
+ return description ?? `Sendmux agent authentication failed with HTTP ${status}.`;
334
+ }
335
+ function assertRegistrationResponse(value) {
336
+ requiredString(value, "access_token", "agent registration");
337
+ requiredString(value, "registration_id", "agent registration");
338
+ if (!value.mailbox || typeof value.mailbox !== "object" || typeof value.mailbox.email !== "string") {
339
+ throw new Error("Sendmux agent registration did not return a mailbox email.");
340
+ }
341
+ }
342
+ function requiredString(record, field, source) {
343
+ const value = record[field];
344
+ if (typeof value !== "string" || value.length === 0) {
345
+ throw new Error(`Sendmux ${source} did not return ${field}.`);
346
+ }
347
+ return value;
348
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"base-command.d.ts","sourceRoot":"","sources":["../src/base-command.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAS,MAAM,aAAa,CAAC;AAE7C,OAAO,EAIL,KAAK,UAAU,EACf,KAAK,kBAAkB,EACxB,MAAM,eAAe,CAAC;AAGvB,MAAM,WAAW,SAAS;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,UAAU,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,SAAS;;;;CAcrB,CAAC;AAEF,8BAAsB,cAAe,SAAQ,OAAO;IAClD,MAAM,CAAC,cAAc,UAAQ;IAEvB,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,kBAAkB,GAAG,OAAO,CAAC,YAAY,CAAC;IAmD5F,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO;IASrC,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;IASxC,kBAAkB,CAAC,KAAK,EAAE,WAAW,GAAG,eAAe,GAAG,MAAM,GAAG,OAAO;IAa1E,OAAO,CAAC,aAAa;CA4BtB"}
1
+ {"version":3,"file":"base-command.d.ts","sourceRoot":"","sources":["../src/base-command.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAS,MAAM,aAAa,CAAC;AAE7C,OAAO,EAIL,KAAK,UAAU,EACf,KAAK,kBAAkB,EACxB,MAAM,eAAe,CAAC;AAQvB,MAAM,WAAW,SAAS;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,UAAU,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,SAAS;;;;CAcrB,CAAC;AAEF,8BAAsB,cAAe,SAAQ,OAAO;IAClD,MAAM,CAAC,cAAc,UAAQ;IAEvB,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,kBAAkB,GAAG,OAAO,CAAC,YAAY,CAAC;IAiF5F,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO;IASrC,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;IASxC,kBAAkB,CAAC,KAAK,EAAE,WAAW,GAAG,eAAe,GAAG,MAAM,GAAG,OAAO;IAa1E,OAAO,CAAC,aAAa;CA4BtB"}
@@ -1,6 +1,7 @@
1
1
  import { Command, Flags } from "@oclif/core";
2
2
  import { apiKeyKindLabel, inferApiKeyKind, isApiKeyCompatibleWithKind, } from "./key-kind.js";
3
- import { readCliConfig } from "./profiles.js";
3
+ import { resolveAgentSendingToken } from "./agent-auth.js";
4
+ import { isActiveAgentProfile, isAgentProfile, readCliConfig, } from "./profiles.js";
4
5
  export const authFlags = {
5
6
  "api-key": Flags.string({
6
7
  description: "Sendmux API key. Defaults to SENDMUX_API_KEY.",
@@ -19,8 +20,9 @@ export const authFlags = {
19
20
  export class SendmuxCommand extends Command {
20
21
  static enableJsonFlag = true;
21
22
  async resolveAuth(flags, expectedKind) {
22
- const envApiKey = process.env.SENDMUX_API_KEY;
23
- const envBaseUrl = process.env.SENDMUX_BASE_URL;
23
+ const envApiKey = process.env.SENDMUX_API_KEY || undefined;
24
+ const envBaseUrl = process.env.SENDMUX_BASE_URL || undefined;
25
+ const envProfile = process.env.SENDMUX_PROFILE || undefined;
24
26
  if (flags["api-key"] || envApiKey) {
25
27
  const apiKey = flags["api-key"] ?? envApiKey;
26
28
  if (!apiKey) {
@@ -38,7 +40,7 @@ export class SendmuxCommand extends Command {
38
40
  return this.assertKeyKind(input);
39
41
  }
40
42
  const config = await readCliConfig(this.config.configDir);
41
- const profileName = flags.profile ?? process.env.SENDMUX_PROFILE ?? config.defaultProfile;
43
+ const profileName = flags.profile ?? envProfile ?? config.defaultProfile;
42
44
  if (!profileName) {
43
45
  this.error("No Sendmux profile configured. Run `sendmux profiles:set <name> --api-key <key> --default` or pass --api-key.", {
44
46
  exit: 2,
@@ -50,6 +52,32 @@ export class SendmuxCommand extends Command {
50
52
  exit: 2,
51
53
  });
52
54
  }
55
+ if (isAgentProfile(profile)) {
56
+ if (!isActiveAgentProfile(profile)) {
57
+ this.error(`Sendmux agent profile "${profileName}" has not finished registration. Re-run \`sendmux agent:register ${profileName}\`.`, {
58
+ exit: 2,
59
+ });
60
+ }
61
+ if (expectedKind === "root") {
62
+ this.error(`Command requires a root API key, but profile "${profileName}" is an agent profile.`, { exit: 2 });
63
+ }
64
+ const apiKey = expectedKind === "sending"
65
+ ? await resolveAgentSendingToken({
66
+ config,
67
+ configDir: this.config.configDir,
68
+ profile,
69
+ profileName,
70
+ })
71
+ : profile.accessToken;
72
+ const profileBaseUrl = expectedKind === "sending" ? profile.sendingApiBaseUrl : profile.appApiBaseUrl;
73
+ const baseUrl = flags["base-url"] ?? envBaseUrl ?? profileBaseUrl;
74
+ return this.assertKeyKind({
75
+ apiKey,
76
+ baseUrl,
77
+ expectedKind,
78
+ source: `agent profile "${profileName}"`,
79
+ });
80
+ }
53
81
  const baseUrl = flags["base-url"] ?? envBaseUrl ?? profile.baseUrl;
54
82
  const input = {
55
83
  apiKey: profile.apiKey,
@@ -0,0 +1,12 @@
1
+ import { SendmuxCommand } from "../../base-command.js";
2
+ export default class AgentInviteOwner extends SendmuxCommand {
3
+ static args: {
4
+ email: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static flags: {
8
+ profile: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ };
10
+ run(): Promise<unknown>;
11
+ }
12
+ //# sourceMappingURL=invite-owner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invite-owner.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/invite-owner.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAEvD,MAAM,CAAC,OAAO,OAAO,gBAAiB,SAAQ,cAAc;IAC1D,MAAM,CAAC,IAAI;;MAKT;IACF,MAAM,CAAC,WAAW,SAA4D;IAC9E,MAAM,CAAC,KAAK;;MAMV;IAEI,GAAG,IAAI,OAAO,CAAC,OAAO,CAAC;CAkB9B"}
@@ -0,0 +1,36 @@
1
+ import { Args, Flags } from "@oclif/core";
2
+ import { inviteAgentOwner } from "../../agent-auth.js";
3
+ import { SendmuxCommand } from "../../base-command.js";
4
+ export default class AgentInviteOwner extends SendmuxCommand {
5
+ static args = {
6
+ email: Args.string({
7
+ description: "Owner email address.",
8
+ required: true,
9
+ }),
10
+ };
11
+ static description = "Invite an owner to approve sending for an agent inbox.";
12
+ static flags = {
13
+ profile: Flags.string({
14
+ char: "p",
15
+ description: "Registered agent profile name.",
16
+ required: true,
17
+ }),
18
+ };
19
+ async run() {
20
+ const { args, flags } = await this.parse(AgentInviteOwner);
21
+ const result = await inviteAgentOwner({
22
+ configDir: this.config.configDir,
23
+ email: args.email,
24
+ profileName: flags.profile,
25
+ });
26
+ return this.renderResult({
27
+ ok: true,
28
+ data: {
29
+ email: result.email,
30
+ profile: flags.profile,
31
+ status: result.status,
32
+ },
33
+ meta: {},
34
+ });
35
+ }
36
+ }
@@ -0,0 +1,16 @@
1
+ import { SendmuxCommand } from "../../base-command.js";
2
+ export default class AgentRegister extends SendmuxCommand {
3
+ static args: {
4
+ profile: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static flags: {
8
+ "base-url": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ "client-name": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ default: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ "mailbox-local-part": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ "owner-email": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ };
14
+ run(): Promise<unknown>;
15
+ }
16
+ //# sourceMappingURL=register.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"register.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/register.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAEvD,MAAM,CAAC,OAAO,OAAO,aAAc,SAAQ,cAAc;IACvD,MAAM,CAAC,IAAI;;MAKT;IACF,MAAM,CAAC,WAAW,SAA6C;IAC/D,MAAM,CAAC,KAAK;;;;;;MAgBV;IAEI,GAAG,IAAI,OAAO,CAAC,OAAO,CAAC;CAc9B"}