fluxmail 0.4.0 → 0.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 (108) hide show
  1. package/README.md +7 -5
  2. package/dist/accounts/defaultGoogleOAuth.d.ts +4 -0
  3. package/dist/accounts/defaultGoogleOAuth.d.ts.map +1 -0
  4. package/dist/accounts/defaultGoogleOAuth.js +7 -0
  5. package/dist/accounts/defaultGoogleOAuth.js.map +1 -0
  6. package/dist/accounts/gmailConnection.js +2 -2
  7. package/dist/accounts/gmailConnection.js.map +1 -1
  8. package/dist/accounts/googleAuth.d.ts +10 -7
  9. package/dist/accounts/googleAuth.d.ts.map +1 -1
  10. package/dist/accounts/googleAuth.js +48 -17
  11. package/dist/accounts/googleAuth.js.map +1 -1
  12. package/dist/accounts/microsoftAuth.js +1 -1
  13. package/dist/accounts/microsoftAuth.js.map +1 -1
  14. package/dist/accounts/registry.d.ts +9 -8
  15. package/dist/accounts/registry.d.ts.map +1 -1
  16. package/dist/accounts/registry.js +166 -96
  17. package/dist/accounts/registry.js.map +1 -1
  18. package/dist/auth.d.ts +106 -0
  19. package/dist/auth.d.ts.map +1 -0
  20. package/dist/auth.js +452 -0
  21. package/dist/auth.js.map +1 -0
  22. package/dist/authorization.d.ts +9 -0
  23. package/dist/authorization.d.ts.map +1 -0
  24. package/dist/authorization.js +23 -0
  25. package/dist/authorization.js.map +1 -0
  26. package/dist/cli.d.ts +7 -1
  27. package/dist/cli.d.ts.map +1 -1
  28. package/dist/cli.js +734 -358
  29. package/dist/cli.js.map +1 -1
  30. package/dist/cliInstances.d.ts +37 -0
  31. package/dist/cliInstances.d.ts.map +1 -0
  32. package/dist/cliInstances.js +170 -0
  33. package/dist/cliInstances.js.map +1 -0
  34. package/dist/config.d.ts +16 -10
  35. package/dist/config.d.ts.map +1 -1
  36. package/dist/config.js +125 -35
  37. package/dist/config.js.map +1 -1
  38. package/dist/context.d.ts.map +1 -1
  39. package/dist/context.js +8 -4
  40. package/dist/context.js.map +1 -1
  41. package/dist/http/admin.d.ts +57 -3
  42. package/dist/http/admin.d.ts.map +1 -1
  43. package/dist/http/admin.js +188 -67
  44. package/dist/http/admin.js.map +1 -1
  45. package/dist/http/app.d.ts.map +1 -1
  46. package/dist/http/app.js +78 -54
  47. package/dist/http/app.js.map +1 -1
  48. package/dist/http/identity.d.ts +114 -0
  49. package/dist/http/identity.d.ts.map +1 -0
  50. package/dist/http/identity.js +931 -0
  51. package/dist/http/identity.js.map +1 -0
  52. package/dist/http/operationRoutes.d.ts +8 -0
  53. package/dist/http/operationRoutes.d.ts.map +1 -0
  54. package/dist/http/operationRoutes.js +13 -0
  55. package/dist/http/operationRoutes.js.map +1 -0
  56. package/dist/http/rest.d.ts +11 -3
  57. package/dist/http/rest.d.ts.map +1 -1
  58. package/dist/http/rest.js +138 -66
  59. package/dist/http/rest.js.map +1 -1
  60. package/dist/index.d.ts +4 -3
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +4 -3
  63. package/dist/index.js.map +1 -1
  64. package/dist/mcp/buildServer.d.ts +1 -1
  65. package/dist/mcp/buildServer.d.ts.map +1 -1
  66. package/dist/mcp/buildServer.js +14 -19
  67. package/dist/mcp/buildServer.js.map +1 -1
  68. package/dist/permissions.d.ts +1 -1
  69. package/dist/permissions.d.ts.map +1 -1
  70. package/dist/permissions.js +7 -1
  71. package/dist/permissions.js.map +1 -1
  72. package/dist/scheduler/sendScheduler.d.ts +1 -2
  73. package/dist/scheduler/sendScheduler.d.ts.map +1 -1
  74. package/dist/scheduler/sendScheduler.js +1 -2
  75. package/dist/scheduler/sendScheduler.js.map +1 -1
  76. package/dist/service/emailService.d.ts +8 -16
  77. package/dist/service/emailService.d.ts.map +1 -1
  78. package/dist/service/emailService.js +25 -38
  79. package/dist/service/emailService.js.map +1 -1
  80. package/dist/storage/adminAudit.d.ts +15 -2
  81. package/dist/storage/adminAudit.d.ts.map +1 -1
  82. package/dist/storage/adminAudit.js +22 -27
  83. package/dist/storage/adminAudit.js.map +1 -1
  84. package/dist/storage/apiKeys.d.ts +8 -8
  85. package/dist/storage/apiKeys.d.ts.map +1 -1
  86. package/dist/storage/apiKeys.js +16 -11
  87. package/dist/storage/apiKeys.js.map +1 -1
  88. package/dist/storage/db.d.ts +593 -31
  89. package/dist/storage/db.d.ts.map +1 -1
  90. package/dist/storage/db.js +330 -45
  91. package/dist/storage/db.js.map +1 -1
  92. package/dist/storage/fileLock.d.ts +7 -0
  93. package/dist/storage/fileLock.d.ts.map +1 -0
  94. package/dist/storage/fileLock.js +183 -0
  95. package/dist/storage/fileLock.js.map +1 -0
  96. package/dist/storage/gmailConnectionGrants.d.ts +3 -4
  97. package/dist/storage/gmailConnectionGrants.d.ts.map +1 -1
  98. package/dist/storage/gmailConnectionGrants.js +6 -6
  99. package/dist/storage/gmailConnectionGrants.js.map +1 -1
  100. package/dist/storage/members.d.ts +11 -0
  101. package/dist/storage/members.d.ts.map +1 -1
  102. package/dist/storage/members.js +70 -6
  103. package/dist/storage/members.js.map +1 -1
  104. package/dist/telemetry.d.ts +14 -0
  105. package/dist/telemetry.d.ts.map +1 -1
  106. package/dist/telemetry.js +22 -0
  107. package/dist/telemetry.js.map +1 -1
  108. package/package.json +9 -8
package/dist/cli.js CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from 'node:fs';
3
3
  import { emitKeypressEvents } from 'node:readline';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { hostname } from 'node:os';
4
6
  import path from 'node:path';
5
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
8
  import { Command } from 'commander';
7
9
  import { serve } from '@hono/node-server';
8
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
- import { EmailError } from '@fluxmail/core';
11
+ import { EmailError, isEmailError } from '@fluxmail/core';
10
12
  import { createContext } from './context.js';
11
13
  import { configFilePath, maskStoredConfigValue, readStoredConfig, resolveDataDir, setStoredConfig, unsetStoredConfig, } from './config.js';
12
14
  import { createApp } from './http/app.js';
@@ -14,29 +16,23 @@ import { buildMcpServer } from './mcp/buildServer.js';
14
16
  import { runLoopbackFlow } from './accounts/googleAuth.js';
15
17
  import { runMicrosoftLoopbackFlow } from './accounts/microsoftAuth.js';
16
18
  import { prepareHostedGmailConnection, prepareHostedOutlookConnection, selectGmailConnectionMode, validateAccountConnectionFlags, } from './accounts/gmailConnection.js';
17
- import { createApiKey, listApiKeys, revokeApiKey, updateApiKeyAccounts, updateApiKeyPermissions, } from './storage/apiKeys.js';
18
- import { addMember, findMember, listMembers, removeMember, setMemberRole } from './storage/members.js';
19
- import { activateLicense } from './licensing/activation.js';
20
- import { LICENSE_KEY_PATTERN, releaseLicense } from './licensing/client.js';
21
- import { licensePublicKeys, verifyLease } from './licensing/lease.js';
22
- import { checkLicenseState, clearLease, getEntitlements, readLeaseRow, } from './licensing/entitlements.js';
23
- import { loadInstanceId } from './licensing/refresher.js';
19
+ import { listApiKeys } from './storage/apiKeys.js';
20
+ import { findMember } from './storage/members.js';
21
+ import { LICENSE_KEY_PATTERN } from './licensing/client.js';
22
+ import { checkLicenseState, getEntitlements } from './licensing/entitlements.js';
24
23
  import { VERSION } from './version.js';
25
24
  import { customPermissionPolicy, ADMIN_CAPABILITIES, FULL_PERMISSION_POLICY, isNamedPermissionProfile, MCP_CAPABILITIES, NAMED_PERMISSION_PROFILES, permissionPolicyForProfile, } from './permissions.js';
26
- import { getTelemetry, installTelemetryStreamEndHandler, isTelemetryEnabled, setTelemetryEnabled, shutdownTelemetry, } from './telemetry.js';
25
+ import { captureOperation, getTelemetry, installTelemetryStreamEndHandler, isTelemetryEnabled, setTelemetryEnabled, shutdownTelemetry, } from './telemetry.js';
26
+ import { clearSessionToken, instanceClient, loadInstanceConfig, removeInstance, resolveInstance, saveLocalInstance, saveRemoteInstance, saveSessionToken, useInstance, } from './cliInstances.js';
27
+ import { authenticateBearer, recoverAdminPassword, setupInitialAdmin } from './auth.js';
28
+ import { recordAdminAuditEvent } from './storage/adminAudit.js';
29
+ import { canManageOwnedAccount } from './authorization.js';
27
30
  function collectOption(value, previous) {
28
31
  return [...previous, value];
29
32
  }
30
33
  function accountIdsFromRefs(ctx, refs) {
31
34
  return refs.length ? [...new Set(refs.map((ref) => ctx.registry.findAccount(ref).id))] : null;
32
35
  }
33
- function sharingMode(shared, sharedMemberIds) {
34
- if (shared)
35
- return 'all';
36
- if (sharedMemberIds.length)
37
- return 'selected';
38
- return 'private';
39
- }
40
36
  export function permissionPolicyFromOptions(opts, requireSelection = false) {
41
37
  const supplemental = opts.admin ?? [];
42
38
  if (opts.profile && opts.allow.length) {
@@ -126,6 +122,20 @@ async function hiddenPrompt(label) {
126
122
  process.stdin.on('keypress', onKeypress);
127
123
  });
128
124
  }
125
+ async function textPrompt(label) {
126
+ if (!process.stdin.isTTY)
127
+ throw new EmailError('invalid_request', `${label} must be supplied as an option.`);
128
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
129
+ try {
130
+ return (await prompt.question(`${label}: `)).trim();
131
+ }
132
+ finally {
133
+ prompt.close();
134
+ }
135
+ }
136
+ async function loginPassword(label = 'Password') {
137
+ return process.env.FLUXMAIL_PASSWORD ?? hiddenPrompt(label);
138
+ }
129
139
  async function accountSecret(envName, label) {
130
140
  if (!envName)
131
141
  return hiddenPrompt(label);
@@ -144,13 +154,43 @@ function warnLicense(db, log = console.error) {
144
154
  if (warning)
145
155
  log(`Warning: ${warning}`);
146
156
  }
157
+ const activeCliOperations = new WeakMap();
158
+ function finishCliOperation(program, outcome, errorCode) {
159
+ const active = activeCliOperations.get(program);
160
+ if (!active)
161
+ return;
162
+ activeCliOperations.delete(program);
163
+ captureOperation(active.telemetry, {
164
+ productSurface: 'cli',
165
+ operation: active.operation,
166
+ outcome,
167
+ durationMs: performance.now() - active.startedAt,
168
+ errorCode,
169
+ });
170
+ }
171
+ export function waitForServerListening(server) {
172
+ return new Promise((resolve, reject) => {
173
+ const onError = (error) => {
174
+ server.off('listening', onListening);
175
+ reject(error);
176
+ };
177
+ const onListening = () => {
178
+ server.off('error', onError);
179
+ resolve();
180
+ };
181
+ server.once('error', onError);
182
+ server.once('listening', onListening);
183
+ });
184
+ }
147
185
  /** Build the command tree without parsing arguments or running command actions. */
148
- export function createCliProgram() {
186
+ export function createCliProgram(options = {}) {
149
187
  const program = new Command();
150
188
  program
151
189
  .name('fluxmail')
152
190
  .description('Fluxmail, a self-hosted MCP server for your email')
191
+ .option('--instance <name>', 'Use a named local or remote instance')
153
192
  .version(VERSION, '-v, --version');
193
+ const selectedInstance = () => program.opts().instance;
154
194
  function commandPath(command) {
155
195
  const names = [];
156
196
  for (let current = command; current?.parent; current = current.parent)
@@ -193,47 +233,258 @@ export function createCliProgram() {
193
233
  // Respect the opt-out before creating a telemetry client or recording this command.
194
234
  if (command === 'telemetry disable')
195
235
  return;
196
- const dataDir = resolveDataDir();
197
- getTelemetry(dataDir).capture('cli command used', {
198
- product_surface: 'cli',
199
- command,
236
+ activeCliOperations.set(program, {
237
+ telemetry: options.telemetry ?? getTelemetry(resolveDataDir()),
238
+ operation: command,
239
+ startedAt: performance.now(),
240
+ initialExitCode: process.exitCode,
200
241
  });
201
242
  });
202
243
  program.hook('postAction', async (_command, actionCommand) => {
203
- if (actionCommand.name() !== 'serve' && actionCommand.name() !== 'stdio')
244
+ const active = activeCliOperations.get(program);
245
+ const failed = active !== undefined &&
246
+ process.exitCode !== active.initialExitCode &&
247
+ process.exitCode !== undefined &&
248
+ process.exitCode !== 0;
249
+ finishCliOperation(program, failed ? 'error' : 'success', failed ? 'command_failed' : undefined);
250
+ if (!options.telemetry && actionCommand.name() !== 'serve' && actionCommand.name() !== 'stdio') {
204
251
  await shutdownTelemetry();
252
+ }
253
+ });
254
+ program
255
+ .command('setup')
256
+ .description('Create the first local administrator or claim a migrated administrator')
257
+ .option('--name <name>', 'Administrator name')
258
+ .option('--email <email>', 'Administrator email')
259
+ .option('--existing-admin <member>', 'Existing administrator id or email for a migrated instance')
260
+ .action(async (opts) => {
261
+ try {
262
+ const name = opts.name ?? (opts.existingAdmin ? undefined : await textPrompt('Administrator name'));
263
+ const email = opts.email ?? (await textPrompt('Administrator email'));
264
+ const password = await loginPassword('New password');
265
+ const context = createContext();
266
+ const result = await setupInitialAdmin(context.db, {
267
+ ...(name ? { name } : {}),
268
+ email,
269
+ password,
270
+ ...(opts.existingAdmin ? { existingAdmin: opts.existingAdmin } : {}),
271
+ deviceName: `CLI on ${hostname()}`,
272
+ });
273
+ saveLocalInstance('local');
274
+ saveSessionToken('local', result.session.token);
275
+ recordAdminAuditEvent(context.db, {
276
+ operation: 'auth.setup',
277
+ outcome: 'success',
278
+ actorMemberId: result.member.id,
279
+ actorSessionId: result.session.info.id,
280
+ });
281
+ console.log(`Fluxmail is ready. Logged in to local as ${result.member.name}.`);
282
+ }
283
+ catch (err) {
284
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
285
+ process.exitCode = 1;
286
+ }
287
+ });
288
+ program
289
+ .command('login')
290
+ .description('Log in to a local or remote instance')
291
+ .option('--server <url>', 'Add or update a remote instance')
292
+ .option('--email <email>', 'Member email')
293
+ .option('--enroll', 'Use a one-time enrollment code')
294
+ .option('--reset', 'Use a one-time password reset code')
295
+ .action(async (opts) => {
296
+ try {
297
+ if (opts.enroll && opts.reset) {
298
+ throw new EmailError('invalid_request', '--enroll and --reset cannot be used together.');
299
+ }
300
+ const requestedInstance = selectedInstance();
301
+ if (!opts.server && requestedInstance === 'local' && !loadInstanceConfig().instances.local) {
302
+ saveLocalInstance('local');
303
+ }
304
+ const instanceName = requestedInstance ?? (opts.server ? 'remote' : resolveInstance().name);
305
+ if (opts.server)
306
+ saveRemoteInstance(instanceName, opts.server);
307
+ const client = instanceClient(instanceName, false);
308
+ const deviceName = `CLI on ${hostname()}`;
309
+ const result = opts.enroll || opts.reset
310
+ ? await client.json(opts.reset ? '/api/v1/auth/password-reset' : '/api/v1/auth/enroll', {
311
+ method: 'POST',
312
+ headers: { 'content-type': 'application/json' },
313
+ body: JSON.stringify({
314
+ token: (opts.reset ? process.env.FLUXMAIL_RESET_CODE : process.env.FLUXMAIL_INVITE_CODE) ??
315
+ (await hiddenPrompt(opts.reset ? 'Password reset code' : 'Enrollment code')),
316
+ password: await loginPassword('New password'),
317
+ deviceName,
318
+ }),
319
+ }, false)
320
+ : await client.json('/api/v1/auth/login', {
321
+ method: 'POST',
322
+ headers: { 'content-type': 'application/json' },
323
+ body: JSON.stringify({
324
+ email: opts.email ?? (await textPrompt('Email')),
325
+ password: await loginPassword(),
326
+ deviceName,
327
+ }),
328
+ }, false);
329
+ saveSessionToken(instanceName, result.token);
330
+ useInstance(instanceName);
331
+ console.log(`Logged in to ${instanceName} as ${result.member.name}.`);
332
+ }
333
+ catch (err) {
334
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
335
+ process.exitCode = 1;
336
+ }
337
+ });
338
+ program
339
+ .command('logout')
340
+ .description('Revoke the current CLI session')
341
+ .action(async () => {
342
+ try {
343
+ const selected = resolveInstance(selectedInstance());
344
+ const response = await instanceClient(selected.name).request('/api/v1/auth/logout', { method: 'POST' });
345
+ if (!response.ok && response.status !== 401) {
346
+ throw new EmailError('invalid_request', `Logout failed with HTTP ${response.status}.`);
347
+ }
348
+ clearSessionToken(selected.name);
349
+ console.log(`Logged out of ${selected.name}.`);
350
+ }
351
+ catch (err) {
352
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
353
+ process.exitCode = 1;
354
+ }
355
+ });
356
+ const instances = program.command('instances').description('Manage local and remote CLI instances');
357
+ instances
358
+ .command('list')
359
+ .description('List configured instances')
360
+ .action(() => {
361
+ const config = loadInstanceConfig();
362
+ for (const [name, profile] of Object.entries(config.instances)) {
363
+ console.log(`${name}${config.active === name ? ' *' : ''} ${profile.kind}${profile.kind === 'remote' ? ` ${profile.serverUrl}` : ''}`);
364
+ }
365
+ });
366
+ instances
367
+ .command('use')
368
+ .argument('<name>')
369
+ .description('Select the default instance')
370
+ .action((name) => {
371
+ try {
372
+ useInstance(name);
373
+ console.log(`Using ${name}.`);
374
+ }
375
+ catch (err) {
376
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
377
+ process.exitCode = 1;
378
+ }
379
+ });
380
+ instances
381
+ .command('remove')
382
+ .argument('<name>')
383
+ .description('Remove a CLI profile and its local session')
384
+ .action((name) => {
385
+ try {
386
+ removeInstance(name);
387
+ console.log(`Removed CLI instance ${name}. Server data was not changed.`);
388
+ }
389
+ catch (err) {
390
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
391
+ process.exitCode = 1;
392
+ }
393
+ });
394
+ const authCommand = program.command('auth').description('Manage interactive authentication');
395
+ authCommand
396
+ .command('recover-admin')
397
+ .argument('<member>', 'Administrator id or email')
398
+ .description('Reset an administrator password using local filesystem access')
399
+ .action(async (memberRef) => {
400
+ try {
401
+ const selected = resolveInstance(selectedInstance());
402
+ if (selected.profile.kind !== 'local') {
403
+ throw new EmailError('invalid_request', 'Administrator recovery is only available on the local instance host.');
404
+ }
405
+ const password = await loginPassword('New password');
406
+ const context = createContext();
407
+ const member = await recoverAdminPassword(context.db, memberRef, password);
408
+ recordAdminAuditEvent(context.db, {
409
+ operation: 'auth.recover_admin',
410
+ outcome: 'success',
411
+ actorMemberId: member.id,
412
+ resourceType: 'member',
413
+ resourceId: member.id,
414
+ });
415
+ console.log(`Reset ${member.name}'s password and revoked existing sessions.`);
416
+ }
417
+ catch (err) {
418
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
419
+ process.exitCode = 1;
420
+ }
421
+ });
422
+ authCommand
423
+ .command('sessions')
424
+ .description('List sessions for the current member')
425
+ .action(async () => {
426
+ try {
427
+ const sessions = await instanceClient(selectedInstance()).json('/api/v1/me/sessions');
428
+ for (const session of sessions) {
429
+ console.log(`${session.id}${session.current ? ' *' : ''} ${session.deviceName} created=${new Date(session.createdAt).toISOString()} expires=${new Date(session.expiresAt).toISOString()}`);
430
+ }
431
+ }
432
+ catch (err) {
433
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
434
+ process.exitCode = 1;
435
+ }
436
+ });
437
+ authCommand
438
+ .command('revoke-session')
439
+ .argument('<sessionId>')
440
+ .description("Revoke one of the current member's sessions")
441
+ .action(async (sessionId) => {
442
+ try {
443
+ await instanceClient(selectedInstance()).json(`/api/v1/me/sessions/${encodeURIComponent(sessionId)}`, {
444
+ method: 'DELETE',
445
+ });
446
+ console.log(`Revoked ${sessionId}.`);
447
+ }
448
+ catch (err) {
449
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
450
+ process.exitCode = 1;
451
+ }
205
452
  });
206
453
  program
207
454
  .command('serve')
208
455
  .description('Run the HTTP server (MCP at /mcp and REST at /api/v1)')
209
- .action(() => {
456
+ .action(async () => {
210
457
  installTelemetrySignalHandlers();
211
458
  const ctx = createContext();
212
459
  const app = createApp(ctx);
213
460
  ctx.scheduler.start();
214
- serve({ fetch: app.fetch, port: ctx.config.port }, () => {
461
+ const server = serve({ fetch: app.fetch, port: ctx.config.port }, () => {
215
462
  ctx.telemetry.capture('mcp server started', { product_surface: 'mcp', transport: 'http' });
216
463
  console.log(`Fluxmail listening on ${ctx.config.publicUrl}`);
217
464
  console.log(` MCP endpoint: ${ctx.config.publicUrl}/mcp`);
218
465
  console.log(` REST API: ${ctx.config.publicUrl}/api/v1`);
219
466
  console.log(` OpenAPI: ${ctx.config.publicUrl}/api/v1/openapi.json`);
220
- console.log(` Auth mode: ${ctx.config.authMode}`);
221
- if (ctx.config.authMode === 'apikey' && listApiKeys(ctx.db).length === 0) {
222
- console.log(' Note: no API keys exist yet. Run "fluxmail apikey create --name <name> --member <member>" to create one.');
223
- }
224
- const accounts = ctx.registry.listAccounts();
225
- console.log(accounts.length
226
- ? ` Accounts: ${accounts.map((a) => `${a.email} (${a.status})`).join(', ')}`
227
- : ' Accounts: none (run "fluxmail accounts add gmail --owner <member>", "fluxmail accounts add outlook --owner <member>", or the IMAP equivalent)');
467
+ if (listApiKeys(ctx.db).length === 0) {
468
+ console.log(' Note: no API keys exist yet. Run "fluxmail apikey create --name <name>" to create one.');
469
+ }
470
+ try {
471
+ const accounts = ctx.registry.listAccounts();
472
+ console.log(accounts.length
473
+ ? ` Accounts: ${accounts.map((a) => `${a.email} (${a.status})`).join(', ')}`
474
+ : ' Accounts: none (run "fluxmail accounts add gmail", "fluxmail accounts add outlook", or the IMAP equivalent)');
475
+ }
476
+ catch {
477
+ console.log(' Accounts: unavailable until an existing administrator claims this instance');
478
+ }
228
479
  console.log(` Plan: ${planLine(getEntitlements(ctx.db))}`);
229
480
  warnLicense(ctx.db, console.log);
230
481
  ctx.licenseController.start(console.log);
231
482
  });
483
+ await waitForServerListening(server);
232
484
  });
233
485
  program
234
486
  .command('stdio')
235
487
  .description('Run as a stdio MCP server (for Claude Desktop / Claude Code local config)')
236
- .requiredOption('--member <member>', 'Member (id or email) using this MCP process')
237
488
  .option('--account <account>', 'Limit access to one mailbox; repeat as needed', collectOption, [])
238
489
  .option('--profile <profile>', `Tool profile: ${NAMED_PERMISSION_PROFILES.join(', ')}`)
239
490
  .option('--allow <capability>', 'Allow one MCP capability; repeat as needed', collectOption, [])
@@ -242,9 +493,17 @@ export function createCliProgram() {
242
493
  installTelemetryStreamEndHandler(process.stdin);
243
494
  const ctx = createContext();
244
495
  const permissions = permissionPolicyFromOptions(opts);
245
- const member = findMember(ctx.db, opts.member);
496
+ const selected = resolveInstance(selectedInstance());
497
+ if (selected.profile.kind !== 'local') {
498
+ throw new EmailError('invalid_request', 'Stdio MCP only supports the local instance. Use the remote HTTP MCP endpoint with an API key.');
499
+ }
500
+ if (!selected.token)
501
+ throw new EmailError('permission_denied', 'Log in to the local instance before starting stdio MCP.');
502
+ const principal = authenticateBearer(ctx.db, selected.token);
503
+ if (!principal || principal.kind !== 'session')
504
+ throw new EmailError('permission_denied', 'The local CLI session has expired. Run "fluxmail login --instance local".');
246
505
  const accountIds = accountIdsFromRefs(ctx, opts.account);
247
- const scopedService = ctx.service.withScope({ memberId: member.id, role: member.role, accountIds });
506
+ const scopedService = ctx.service.withPrincipal({ ...principal, accountIds });
248
507
  const server = buildMcpServer(scopedService, {
249
508
  permissions,
250
509
  maxAttachmentBytes: ctx.config.maxAttachmentBytes,
@@ -267,10 +526,6 @@ export function createCliProgram() {
267
526
  .command('add')
268
527
  .argument('<provider>', 'Email provider: gmail, outlook, or imap')
269
528
  .option('--reauthorize <account-id>', 'Reconnect an existing account')
270
- .option('--owner <member>', 'Member (id or email) who owns the new mailbox')
271
- .option('--member <member>', 'Deprecated alias for --owner')
272
- .option('--shared', 'Share the mailbox with every member')
273
- .option('--share-with <member>', 'Share with one member; repeat as needed', collectOption, [])
274
529
  .option('--local', 'Use the local browser callback for OAuth')
275
530
  .option('--hosted', 'Use FLUXMAIL_PUBLIC_URL for the OAuth callback')
276
531
  .option('--email <address>', 'Mailbox address (required for IMAP)')
@@ -295,102 +550,146 @@ export function createCliProgram() {
295
550
  .action(async (providerInput, opts, command) => {
296
551
  const provider = providerInput === 'microsoft' ? 'outlook' : providerInput;
297
552
  if (provider !== 'gmail' && provider !== 'outlook' && provider !== 'imap') {
553
+ finishCliOperation(program, 'error', 'invalid_request');
298
554
  console.error(`Provider "${providerInput}" is not supported. Available: gmail, outlook, imap`);
299
555
  process.exitCode = 1;
300
556
  return;
301
557
  }
302
- const ctx = createContext();
303
- warnLicense(ctx.db);
558
+ const selected = resolveInstance(selectedInstance());
559
+ if (!selected.token) {
560
+ console.error(`Error: Log in to instance "${selected.name}" before connecting a mailbox.`);
561
+ process.exitCode = 1;
562
+ return;
563
+ }
304
564
  try {
305
565
  validateAccountConnectionFlags(provider, opts);
306
- if (opts.owner && opts.member) {
307
- throw new EmailError('invalid_request', '--owner and --member cannot be combined. Use --owner.');
566
+ }
567
+ catch (err) {
568
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
569
+ process.exitCode = 1;
570
+ return;
571
+ }
572
+ let useControlPlane = selected.profile.kind === 'remote' || provider === 'imap';
573
+ if (selected.profile.kind === 'local' && provider !== 'imap') {
574
+ try {
575
+ useControlPlane = selectGmailConnectionMode(createContext().config, opts) === 'hosted';
576
+ }
577
+ catch (err) {
578
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
579
+ process.exitCode = 1;
580
+ return;
581
+ }
582
+ }
583
+ if (useControlPlane) {
584
+ try {
585
+ if (selected.profile.kind === 'remote' && opts.local) {
586
+ throw new EmailError('invalid_request', '--local is only available for the local instance.');
587
+ }
588
+ let request = {
589
+ provider,
590
+ ...(opts.reauthorize ? { reauthorizeAccountId: opts.reauthorize } : {}),
591
+ };
592
+ if (provider === 'imap') {
593
+ const email = opts.email?.trim();
594
+ if (!email || !email.includes('@'))
595
+ throw new EmailError('invalid_request', '--email must be a mailbox address.');
596
+ if (!opts.imapHost)
597
+ throw new EmailError('invalid_request', '--imap-host is required for IMAP accounts.');
598
+ if (!opts.smtpHost)
599
+ throw new EmailError('invalid_request', '--smtp-host is required for IMAP accounts.');
600
+ const imapPassword = await accountSecret(opts.imapPasswordEnv, 'IMAP password');
601
+ const smtpPassword = opts.smtpPasswordEnv
602
+ ? await accountSecret(opts.smtpPasswordEnv, 'SMTP password')
603
+ : imapPassword;
604
+ const imapUser = opts.imapUser ?? email;
605
+ request = {
606
+ ...request,
607
+ email,
608
+ ...(opts.displayName ? { displayName: opts.displayName } : {}),
609
+ imap: {
610
+ host: opts.imapHost,
611
+ port: connectionPort(opts.imapPort, '--imap-port'),
612
+ security: connectionSecurity(opts.imapSecurity, '--imap-security'),
613
+ user: imapUser,
614
+ password: imapPassword,
615
+ },
616
+ smtp: {
617
+ host: opts.smtpHost,
618
+ port: connectionPort(opts.smtpPort, '--smtp-port'),
619
+ security: connectionSecurity(opts.smtpSecurity, '--smtp-security'),
620
+ user: opts.smtpUser ?? imapUser,
621
+ password: smtpPassword,
622
+ },
623
+ ...(command.getOptionValueSource('saveSent') !== 'default' ? { saveSent: opts.saveSent } : {}),
624
+ folderOverrides: {
625
+ ...(opts.sentFolder ? { sent: opts.sentFolder } : {}),
626
+ ...(opts.draftsFolder ? { drafts: opts.draftsFolder } : {}),
627
+ ...(opts.trashFolder ? { trash: opts.trashFolder } : {}),
628
+ ...(opts.archiveFolder ? { archive: opts.archiveFolder } : {}),
629
+ ...(opts.spamFolder ? { spam: opts.spamFolder } : {}),
630
+ },
631
+ };
632
+ }
633
+ const result = await instanceClient(selected.name).json('/api/v1/accounts/connections', {
634
+ method: 'POST',
635
+ headers: { 'content-type': 'application/json' },
636
+ body: JSON.stringify(request),
637
+ });
638
+ if (result.connectionUrl)
639
+ console.log(`Open this URL in your browser:\n\n ${result.connectionUrl}\n`);
640
+ if (result.account) {
641
+ console.log(`Connected ${result.account.email} (account id: ${result.account.id})`);
642
+ for (const warning of result.warnings ?? [])
643
+ console.log(`Warning: ${warning.message}.`);
644
+ }
308
645
  }
309
- if (opts.shared && opts.shareWith.length) {
310
- throw new EmailError('invalid_request', '--shared and --share-with cannot be combined.');
646
+ catch (err) {
647
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
648
+ process.exitCode = 1;
311
649
  }
312
- const ownerRef = opts.owner ?? opts.member;
313
- if (opts.member)
314
- console.error('Warning: --member is deprecated; use --owner.');
315
- if (opts.reauthorize && (ownerRef || opts.shared || opts.shareWith.length)) {
316
- throw new EmailError('invalid_request', 'Ownership and sharing options cannot be combined with --reauthorize. Use "fluxmail accounts assign" or "fluxmail accounts access".');
650
+ return;
651
+ }
652
+ const ctx = createContext();
653
+ warnLicense(ctx.db);
654
+ try {
655
+ const principal = authenticateBearer(ctx.db, selected.token);
656
+ if (!principal || principal.kind !== 'session') {
657
+ throw new EmailError('permission_denied', 'The local CLI session has expired. Run "fluxmail login --instance local".');
317
658
  }
318
- // Resolve before the OAuth flow so a typo fails fast.
319
- const owner = ownerRef ? findMember(ctx.db, ownerRef) : undefined;
659
+ const owner = findMember(ctx.db, principal.memberId);
320
660
  const existing = opts.reauthorize ? ctx.registry.getAccount(opts.reauthorize) : undefined;
661
+ if (existing && !canManageOwnedAccount(principal, existing)) {
662
+ throw new EmailError('permission_denied', 'Only the mailbox owner or an administrator can reauthorize it.');
663
+ }
321
664
  if (existing && existing.provider !== provider) {
322
665
  throw new EmailError('invalid_request', `Account ${existing.id} uses ${existing.provider}, not ${provider}.`);
323
666
  }
324
667
  if (!existing)
325
668
  ctx.registry.assertCanAddAccount();
326
- if (!existing && !owner) {
327
- throw new EmailError('invalid_request', '--owner <id-or-email> is required when connecting a mailbox.');
328
- }
329
- const sharedMemberIds = opts.shareWith.map((ref) => findMember(ctx.db, ref).id);
330
- const access = {
331
- sharingMode: sharingMode(opts.shared, sharedMemberIds),
332
- sharedMemberIds,
333
- };
334
- if (provider === 'imap') {
335
- const previousCredentials = existing ? ctx.registry.loadImapCredentials(existing.id) : undefined;
336
- const email = opts.email?.trim();
337
- if (!email || !email.includes('@'))
338
- throw new EmailError('invalid_request', '--email must be a mailbox address.');
339
- if (!opts.imapHost)
340
- throw new EmailError('invalid_request', '--imap-host is required for IMAP accounts.');
341
- if (!opts.smtpHost)
342
- throw new EmailError('invalid_request', '--smtp-host is required for IMAP accounts.');
343
- if (existing && existing.email !== email) {
344
- throw new EmailError('invalid_request', `Account ${existing.id} belongs to ${existing.email}, not ${email}.`);
669
+ const access = { sharedWithAll: false, grantedMemberIds: [] };
670
+ const auditConnection = (operation, accountId) => {
671
+ try {
672
+ recordAdminAuditEvent(ctx.db, {
673
+ operation,
674
+ outcome: 'success',
675
+ actorMemberId: principal.memberId,
676
+ actorSessionId: principal.sessionId,
677
+ ...(accountId ? { resourceType: 'account', resourceId: accountId } : {}),
678
+ });
345
679
  }
346
- const imapPassword = await accountSecret(opts.imapPasswordEnv, 'IMAP password');
347
- const smtpPassword = opts.smtpPasswordEnv
348
- ? await accountSecret(opts.smtpPasswordEnv, 'SMTP password')
349
- : imapPassword;
350
- const imapUser = opts.imapUser ?? email;
351
- const credentials = {
352
- imap: {
353
- host: opts.imapHost,
354
- port: connectionPort(opts.imapPort, '--imap-port'),
355
- security: connectionSecurity(opts.imapSecurity, '--imap-security'),
356
- user: imapUser,
357
- password: imapPassword,
358
- },
359
- smtp: {
360
- host: opts.smtpHost,
361
- port: connectionPort(opts.smtpPort, '--smtp-port'),
362
- security: connectionSecurity(opts.smtpSecurity, '--smtp-security'),
363
- user: opts.smtpUser ?? imapUser,
364
- password: smtpPassword,
365
- },
366
- saveSent: previousCredentials && command.getOptionValueSource('saveSent') === 'default'
367
- ? previousCredentials.saveSent
368
- : opts.saveSent,
369
- folderOverrides: {
370
- ...previousCredentials?.folderOverrides,
371
- ...(opts.sentFolder ? { sent: opts.sentFolder } : {}),
372
- ...(opts.draftsFolder ? { drafts: opts.draftsFolder } : {}),
373
- ...(opts.trashFolder ? { trash: opts.trashFolder } : {}),
374
- ...(opts.archiveFolder ? { archive: opts.archiveFolder } : {}),
375
- ...(opts.spamFolder ? { spam: opts.spamFolder } : {}),
376
- },
377
- };
378
- console.log('Checking IMAP and SMTP settings...');
379
- const warnings = await ctx.registry.testImapCredentials(email, credentials, opts.displayName);
380
- const account = ctx.registry.addImapAccount(email, credentials, opts.displayName, owner?.id, opts.reauthorize, access);
381
- console.log(`Connected ${account.email} (account id: ${account.id})`);
382
- for (const warning of warnings)
383
- console.log(`Warning: ${warning.message}.`);
384
- return;
385
- }
680
+ catch {
681
+ // Audit storage must not replace the connection result.
682
+ }
683
+ };
386
684
  if (provider === 'outlook') {
387
685
  const connectionMode = selectGmailConnectionMode(ctx.config, opts);
388
686
  if (connectionMode === 'hosted') {
389
687
  const { connectionUrl } = prepareHostedOutlookConnection(ctx.db, ctx.config, {
390
- ...(owner ? { memberId: owner.id } : {}),
688
+ ownerMemberId: owner.id,
391
689
  ...(existing ? { reauthorizeAccountId: existing.id } : {}),
392
- ...(!existing ? { sharingMode: access.sharingMode, sharedMemberIds: access.sharedMemberIds } : {}),
690
+ ...(!existing ? access : {}),
393
691
  });
692
+ auditConnection('account.connection.prepare', existing?.id);
394
693
  console.log('\nOpen this URL in your browser to connect Outlook:\n');
395
694
  console.log(` ${connectionUrl}\n`);
396
695
  console.log('This link expires in 10 minutes and can only be used once.');
@@ -401,23 +700,31 @@ export function createCliProgram() {
401
700
  console.log(` ${url}\n`);
402
701
  console.log('Waiting for Microsoft to redirect back...');
403
702
  }, (result) => {
703
+ const duplicate = ctx.registry
704
+ .listAccounts()
705
+ .find((candidate) => candidate.email.toLowerCase() === result.email.toLowerCase());
706
+ if (duplicate && !canManageOwnedAccount(principal, duplicate)) {
707
+ throw new EmailError('permission_denied', 'Only the mailbox owner or an administrator can reauthorize it.');
708
+ }
404
709
  if (existing && result.email.toLowerCase() !== existing.email.toLowerCase()) {
405
710
  throw new EmailError('invalid_request', `Microsoft authorized ${result.email}, but account ${existing.id} belongs to ${existing.email}. ` +
406
711
  'Try again and choose the matching Microsoft account.');
407
712
  }
408
- return ctx.registry.addOutlookAccount(result.email, result.credentials, result.displayName, owner?.id, existing?.id, access);
713
+ return ctx.registry.addOutlookAccount(result.email, result.credentials, result.displayName, owner.id, existing?.id, access);
409
714
  });
715
+ auditConnection('account.connection.complete', account.id);
410
716
  console.log(`\nConnected ${account.email} (account id: ${account.id})` +
411
- (owner && account.ownerId === owner.id ? ` for member ${owner.name}` : ''));
717
+ (account.ownerMemberId === owner.id ? ` for member ${owner.name}` : ''));
412
718
  return;
413
719
  }
414
720
  const gmailConnectionMode = selectGmailConnectionMode(ctx.config, opts);
415
721
  if (gmailConnectionMode === 'hosted') {
416
722
  const { connectionUrl } = prepareHostedGmailConnection(ctx.db, ctx.config, {
417
- ...(owner ? { memberId: owner.id } : {}),
723
+ ownerMemberId: owner.id,
418
724
  ...(existing ? { reauthorizeAccountId: existing.id } : {}),
419
- ...(!existing ? { sharingMode: access.sharingMode, sharedMemberIds: access.sharedMemberIds } : {}),
725
+ ...(!existing ? access : {}),
420
726
  });
727
+ auditConnection('account.connection.prepare', existing?.id);
421
728
  console.log('\nOpen this URL in your browser to connect Gmail:\n');
422
729
  console.log(` ${connectionUrl}\n`);
423
730
  console.log('This link expires in 10 minutes and can only be used once.');
@@ -428,21 +735,29 @@ export function createCliProgram() {
428
735
  console.log(` ${url}\n`);
429
736
  console.log('Waiting for Google to redirect back…');
430
737
  }, (result) => {
738
+ const duplicate = ctx.registry
739
+ .listAccounts()
740
+ .find((candidate) => candidate.email.toLowerCase() === result.email.toLowerCase());
741
+ if (duplicate && !canManageOwnedAccount(principal, duplicate)) {
742
+ throw new EmailError('permission_denied', 'Only the mailbox owner or an administrator can reauthorize it.');
743
+ }
431
744
  if (existing && result.email !== existing.email) {
432
745
  throw new EmailError('invalid_request', `Google authorized ${result.email}, but account ${existing.id} belongs to ${existing.email}. ` +
433
746
  'Try again and choose the matching Google account.');
434
747
  }
435
- return ctx.registry.addGmailAccount(result.email, result.tokens, result.displayName, owner?.id, access);
748
+ return ctx.registry.addGmailAccount(result.email, result.tokens, result.displayName, owner.id, access);
436
749
  });
750
+ auditConnection('account.connection.complete', account.id);
437
751
  console.log(`\nConnected ${account.email} (account id: ${account.id})` +
438
- (owner && account.ownerId === owner.id ? ` for member ${owner.name}` : ''));
439
- if (owner && account.ownerId !== owner.id) {
752
+ (account.ownerMemberId === owner.id ? ` for member ${owner.name}` : ''));
753
+ if (account.ownerMemberId !== owner.id) {
440
754
  // The mailbox was already connected; re-auth keeps its existing owner.
441
755
  console.log(`${account.email} was already connected, so its ownership is unchanged. ` +
442
756
  `To assign it to ${owner.name}, run "fluxmail accounts assign ${account.id} --owner ${owner.id}".`);
443
757
  }
444
758
  }
445
759
  catch (err) {
760
+ finishCliOperation(program, 'error', isEmailError(err) ? err.code : 'internal');
446
761
  console.error(`\nError: ${err instanceof Error ? err.message : String(err)}`);
447
762
  process.exitCode = 1;
448
763
  }
@@ -457,8 +772,6 @@ export function createCliProgram() {
457
772
  .option('--spam-folder <path>', 'Spam path, or auto to clear the override')
458
773
  .description('Set special folder paths for an IMAP account')
459
774
  .action(async (accountId, opts) => {
460
- const ctx = createContext();
461
- warnLicense(ctx.db);
462
775
  try {
463
776
  const changes = [
464
777
  ['sent', opts.sentFolder],
@@ -470,39 +783,19 @@ export function createCliProgram() {
470
783
  if (!changes.some(([, value]) => value !== undefined)) {
471
784
  throw new EmailError('invalid_request', 'Pass at least one folder option.');
472
785
  }
473
- const credentials = ctx.registry.loadImapCredentials(accountId);
474
- const provider = ctx.registry.getProvider(accountId);
475
- try {
476
- const existingPaths = new Set((await provider.listFolders()).map((folder) => folder.id));
477
- const overrides = { ...credentials.folderOverrides };
478
- for (const [role, value] of changes) {
479
- if (value === undefined)
480
- continue;
481
- if (value === 'auto')
482
- delete overrides[role];
483
- else {
484
- if (!existingPaths.has(value)) {
485
- throw new EmailError('not_found', `No selectable mailbox named "${value}".`);
486
- }
487
- overrides[role] = value;
488
- }
489
- }
490
- credentials.folderOverrides = overrides;
491
- ctx.registry.saveImapCredentials(accountId, credentials);
492
- const checked = ctx.registry.getProvider(accountId);
493
- try {
494
- console.log(`Updated folder settings for ${accountId}.`);
495
- for (const warning of (await checked.getFolderWarnings?.()) ?? []) {
496
- console.log(`Warning: ${warning.message}.`);
497
- }
498
- }
499
- finally {
500
- await checked.close?.();
501
- }
502
- }
503
- finally {
504
- await provider.close?.();
786
+ const patch = {};
787
+ for (const [role, value] of changes) {
788
+ if (value !== undefined)
789
+ patch[role] = value === 'auto' ? null : value;
505
790
  }
791
+ const result = await instanceClient(selectedInstance()).json(`/api/v1/accounts/${encodeURIComponent(accountId)}/imap/folders`, {
792
+ method: 'PATCH',
793
+ headers: { 'content-type': 'application/json' },
794
+ body: JSON.stringify(patch),
795
+ });
796
+ console.log(`Updated folder settings for ${accountId}.`);
797
+ for (const warning of result.warnings)
798
+ console.log(`Warning: ${warning.message}.`);
506
799
  }
507
800
  catch (err) {
508
801
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -512,65 +805,56 @@ export function createCliProgram() {
512
805
  accounts
513
806
  .command('list')
514
807
  .description('List connected accounts')
515
- .action(() => {
516
- const ctx = createContext();
517
- warnLicense(ctx.db);
518
- const all = ctx.registry.listAccounts();
519
- if (!all.length) {
520
- console.log('No accounts connected. Run "fluxmail accounts add gmail --owner <member>", "fluxmail accounts add outlook --owner <member>", or the IMAP equivalent.');
521
- return;
808
+ .action(async () => {
809
+ try {
810
+ const all = await instanceClient(selectedInstance()).json('/api/v1/accounts');
811
+ if (!all.length) {
812
+ console.log('No accounts connected. Run "fluxmail accounts add <provider>".');
813
+ return;
814
+ }
815
+ for (const a of all) {
816
+ const access = a.sharedWithAll
817
+ ? 'all'
818
+ : a.grantedMemberIds.length
819
+ ? `selected:${a.grantedMemberIds.join(',')}`
820
+ : 'owner-only';
821
+ console.log(`${a.id} ${a.provider} ${a.email} [${a.status}] owner=${a.ownerMemberId} access=${access}`);
822
+ }
522
823
  }
523
- const memberNames = new Map(listMembers(ctx.db).map((m) => [m.id, m.name]));
524
- for (const a of all) {
525
- const owner = a.ownerId ? (memberNames.get(a.ownerId) ?? a.ownerId) : '-';
526
- const access = a.sharingMode === 'selected'
527
- ? `selected:${a.sharedMemberIds.map((id) => memberNames.get(id) ?? id).join(',')}`
528
- : a.sharingMode;
529
- console.log(`${a.id} ${a.provider} ${a.email} [${a.status}] owner=${owner} access=${access}`);
824
+ catch (err) {
825
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
826
+ process.exitCode = 1;
530
827
  }
531
828
  });
532
829
  accounts
533
830
  .command('remove')
534
831
  .argument('<accountId>')
535
832
  .description('Disconnect an account and delete its stored tokens')
536
- .action((accountId) => {
537
- const ctx = createContext();
538
- warnLicense(ctx.db);
539
- ctx.registry.removeAccount(accountId);
540
- console.log(`Removed ${accountId}`);
833
+ .action(async (accountId) => {
834
+ try {
835
+ await instanceClient(selectedInstance()).json(`/api/v1/accounts/${encodeURIComponent(accountId)}/connection`, {
836
+ method: 'DELETE',
837
+ });
838
+ console.log(`Removed ${accountId}`);
839
+ }
840
+ catch (err) {
841
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
842
+ process.exitCode = 1;
843
+ }
541
844
  });
542
845
  accounts
543
846
  .command('assign')
544
847
  .argument('<accountId>')
545
- .option('--owner <member>', 'Member (id or email) to own the mailbox')
546
- .option('--member <member>', 'Deprecated alias for --owner')
547
- .option('--shared', 'Deprecated alias for sharing with all members')
848
+ .requiredOption('--owner <member>', 'Member id or email to own the mailbox')
548
849
  .description('Change mailbox ownership')
549
- .action((accountId, opts) => {
550
- const ctx = createContext();
551
- warnLicense(ctx.db);
850
+ .action(async (accountId, opts) => {
552
851
  try {
553
- if (opts.owner && opts.member) {
554
- throw new EmailError('invalid_request', '--owner and --member cannot be combined. Use --owner.');
555
- }
556
- const ownerRef = opts.owner ?? opts.member;
557
- if (!ownerRef && !opts.shared) {
558
- throw new EmailError('invalid_request', 'Pass --owner <member>.');
559
- }
560
- if (ownerRef && opts.shared) {
561
- throw new EmailError('invalid_request', '--owner and --shared cannot be combined.');
562
- }
563
- if (opts.member)
564
- console.error('Warning: --member is deprecated; use --owner.');
565
- if (opts.shared) {
566
- console.error('Warning: "accounts assign --shared" is deprecated; use "accounts access --shared".');
567
- const account = ctx.registry.setAccountAccess(accountId, { sharingMode: 'all' });
568
- console.log(`${account.email} is now shared with every member. Its owner is unchanged.`);
569
- return;
570
- }
571
- const owner = findMember(ctx.db, ownerRef);
572
- const account = ctx.registry.assignAccountOwner(accountId, owner.id);
573
- console.log(`${account.email} is now owned by ${owner.name}.`);
852
+ const account = await instanceClient(selectedInstance()).json(`/api/v1/admin/accounts/${encodeURIComponent(accountId)}`, {
853
+ method: 'PATCH',
854
+ headers: { 'content-type': 'application/json' },
855
+ body: JSON.stringify({ ownerMemberId: opts.owner }),
856
+ });
857
+ console.log(`${account.email} is now owned by ${account.ownerMemberId}.`);
574
858
  }
575
859
  catch (err) {
576
860
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -584,20 +868,18 @@ export function createCliProgram() {
584
868
  .option('--shared', 'Share the mailbox with every member')
585
869
  .option('--share-with <member>', 'Replace selected access with this member; repeat as needed', collectOption, [])
586
870
  .description('Set who can access a mailbox')
587
- .action((accountId, opts) => {
588
- const ctx = createContext();
589
- warnLicense(ctx.db);
871
+ .action(async (accountId, opts) => {
590
872
  try {
591
873
  const choices = Number(Boolean(opts.ownerOnly)) + Number(Boolean(opts.shared)) + Number(opts.shareWith.length > 0);
592
874
  if (choices !== 1) {
593
875
  throw new EmailError('invalid_request', 'Pass exactly one of --owner-only, --shared, or one or more --share-with options.');
594
876
  }
595
- const sharedMemberIds = opts.shareWith.map((ref) => findMember(ctx.db, ref).id);
596
- const account = ctx.registry.setAccountAccess(accountId, {
597
- sharingMode: sharingMode(opts.shared, sharedMemberIds),
598
- sharedMemberIds,
877
+ const account = await instanceClient(selectedInstance()).json(`/api/v1/admin/accounts/${encodeURIComponent(accountId)}`, {
878
+ method: 'PATCH',
879
+ headers: { 'content-type': 'application/json' },
880
+ body: JSON.stringify({ sharedWithAll: opts.shared === true, grantedMemberIds: opts.shareWith }),
599
881
  });
600
- console.log(`Updated access for ${account.email}: ${account.sharingMode}.`);
882
+ console.log(`Updated access for ${account.email}: ${account.sharedWithAll ? 'all members' : `${account.grantedMemberIds.length} explicit grant(s)`}.`);
601
883
  }
602
884
  catch (err) {
603
885
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -608,22 +890,22 @@ export function createCliProgram() {
608
890
  membersCmd
609
891
  .command('add')
610
892
  .requiredOption('--name <name>', 'Member name')
611
- .option('--email <email>', 'Member email, usable as a shorthand in --member flags')
893
+ .requiredOption('--email <email>', 'Member login email')
612
894
  .option('--role <role>', 'Member role: admin or member')
613
895
  .description('Add a member (subject to the plan seat limit)')
614
- .action((opts) => {
615
- const ctx = createContext();
616
- warnLicense(ctx.db);
896
+ .action(async (opts) => {
617
897
  try {
618
898
  if (opts.role && opts.role !== 'admin' && opts.role !== 'member') {
619
899
  throw new EmailError('invalid_request', '--role must be "admin" or "member".');
620
900
  }
621
- const member = addMember(ctx.db, {
622
- name: opts.name,
623
- ...(opts.email ? { email: opts.email } : {}),
624
- ...(opts.role ? { role: opts.role } : {}),
901
+ const member = await instanceClient(selectedInstance()).json('/api/v1/admin/members', {
902
+ method: 'POST',
903
+ headers: { 'content-type': 'application/json' },
904
+ body: JSON.stringify({ name: opts.name, email: opts.email, role: opts.role ?? 'member' }),
625
905
  });
626
- console.log(`Added member ${member.name} (id: ${member.id}, role: ${member.role})`);
906
+ console.log(`Added ${member.name} (id: ${member.id}, role: ${member.role}).`);
907
+ console.log(`Enrollment code (shown once): ${member.invitation.token}`);
908
+ console.log(`Expires: ${new Date(member.invitation.expiresAt).toISOString()}`);
627
909
  }
628
910
  catch (err) {
629
911
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -633,28 +915,32 @@ export function createCliProgram() {
633
915
  membersCmd
634
916
  .command('list')
635
917
  .description('List members with their mailbox and API key counts')
636
- .action(() => {
637
- const ctx = createContext();
638
- warnLicense(ctx.db);
639
- const all = listMembers(ctx.db);
640
- if (!all.length) {
641
- console.log('No members. Run "fluxmail members add --name <name>".');
642
- return;
918
+ .action(async () => {
919
+ try {
920
+ const all = await instanceClient(selectedInstance()).json('/api/v1/admin/members');
921
+ if (!all.length) {
922
+ console.log('No members. Run "fluxmail members add --name <name>".');
923
+ return;
924
+ }
925
+ for (const m of all) {
926
+ console.log(`${m.id} ${m.name} role=${m.role} status=${m.status} email=${m.email ?? '-'} mailboxes=${m.accountCount} keys=${m.apiKeyCount}`);
927
+ }
643
928
  }
644
- for (const m of all) {
645
- console.log(`${m.id} ${m.name} role=${m.role} email=${m.email ?? '-'} mailboxes=${m.accountCount} keys=${m.apiKeyCount}`);
929
+ catch (err) {
930
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
931
+ process.exitCode = 1;
646
932
  }
647
933
  });
648
934
  membersCmd
649
935
  .command('remove')
650
936
  .argument('<memberId>')
651
937
  .description('Remove a member after reassigning or removing their mailboxes')
652
- .action((memberId) => {
653
- const ctx = createContext();
654
- warnLicense(ctx.db);
938
+ .action(async (memberId) => {
655
939
  try {
656
- const { name, revokedApiKeys } = removeMember(ctx.db, memberId);
657
- console.log(`Removed ${name} and revoked ${revokedApiKeys} API key(s).`);
940
+ await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberId)}`, {
941
+ method: 'DELETE',
942
+ });
943
+ console.log(`Removed ${memberId}.`);
658
944
  }
659
945
  catch (err) {
660
946
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -666,14 +952,16 @@ export function createCliProgram() {
666
952
  .argument('<member>', 'Member id or email')
667
953
  .argument('<role>', 'admin or member')
668
954
  .description('Change a member role')
669
- .action((memberRef, role) => {
670
- const ctx = createContext();
671
- warnLicense(ctx.db);
955
+ .action(async (memberRef, role) => {
672
956
  try {
673
957
  if (role !== 'admin' && role !== 'member') {
674
958
  throw new EmailError('invalid_request', 'Role must be "admin" or "member".');
675
959
  }
676
- const member = setMemberRole(ctx.db, memberRef, role);
960
+ const member = await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberRef)}`, {
961
+ method: 'PATCH',
962
+ headers: { 'content-type': 'application/json' },
963
+ body: JSON.stringify({ role }),
964
+ });
677
965
  console.log(`${member.name} now has the ${member.role} role.`);
678
966
  }
679
967
  catch (err) {
@@ -681,6 +969,79 @@ export function createCliProgram() {
681
969
  process.exitCode = 1;
682
970
  }
683
971
  });
972
+ membersCmd
973
+ .command('status')
974
+ .argument('<member>', 'Member id or email')
975
+ .argument('<status>', 'active or suspended')
976
+ .description('Activate or suspend a member')
977
+ .action(async (memberRef, status) => {
978
+ try {
979
+ if (status !== 'active' && status !== 'suspended') {
980
+ throw new EmailError('invalid_request', 'Status must be "active" or "suspended".');
981
+ }
982
+ const member = await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberRef)}`, {
983
+ method: 'PATCH',
984
+ headers: { 'content-type': 'application/json' },
985
+ body: JSON.stringify({ status }),
986
+ });
987
+ console.log(`${member.name} is now ${member.status}.`);
988
+ }
989
+ catch (err) {
990
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
991
+ process.exitCode = 1;
992
+ }
993
+ });
994
+ for (const [commandName, endpoint, label] of [
995
+ ['invite', 'invitation', 'Enrollment code'],
996
+ ['password-reset', 'password-reset', 'Password reset code'],
997
+ ]) {
998
+ membersCmd
999
+ .command(commandName)
1000
+ .argument('<member>', 'Member id or email')
1001
+ .description(commandName === 'invite' ? 'Issue a new enrollment code' : 'Issue a password reset code')
1002
+ .action(async (memberRef) => {
1003
+ try {
1004
+ const token = await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberRef)}/${endpoint}`, { method: 'POST' });
1005
+ console.log(`${label} (shown once): ${token.token}`);
1006
+ console.log(`Expires: ${new Date(token.expiresAt).toISOString()}`);
1007
+ }
1008
+ catch (err) {
1009
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1010
+ process.exitCode = 1;
1011
+ }
1012
+ });
1013
+ }
1014
+ membersCmd
1015
+ .command('sessions')
1016
+ .argument('<member>', 'Member id or email')
1017
+ .description('List sessions for a member')
1018
+ .action(async (memberRef) => {
1019
+ try {
1020
+ const sessions = await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberRef)}/sessions`);
1021
+ for (const session of sessions) {
1022
+ console.log(`${session.id} ${session.deviceName} expires=${new Date(session.expiresAt).toISOString()}`);
1023
+ }
1024
+ }
1025
+ catch (err) {
1026
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1027
+ process.exitCode = 1;
1028
+ }
1029
+ });
1030
+ membersCmd
1031
+ .command('revoke-session')
1032
+ .argument('<member>', 'Member id or email')
1033
+ .argument('<sessionId>')
1034
+ .description('Revoke a member session')
1035
+ .action(async (memberRef, sessionId) => {
1036
+ try {
1037
+ await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberRef)}/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
1038
+ console.log(`Revoked ${sessionId}.`);
1039
+ }
1040
+ catch (err) {
1041
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1042
+ process.exitCode = 1;
1043
+ }
1044
+ });
684
1045
  const apikey = program.command('apikey').description('Manage API keys for the HTTP MCP and REST APIs');
685
1046
  apikey
686
1047
  .command('capabilities')
@@ -692,24 +1053,44 @@ export function createCliProgram() {
692
1053
  apikey
693
1054
  .command('create')
694
1055
  .requiredOption('--name <name>', 'Human-readable key name')
695
- .requiredOption('--member <member>', 'Member (id or email) the key is issued to')
1056
+ .option('--member <member>', 'Admin only: issue the key to another member')
696
1057
  .option('--account <account>', 'Limit the key to one mailbox; repeat as needed', collectOption, [])
697
1058
  .option('--profile <profile>', `Tool profile: ${NAMED_PERMISSION_PROFILES.join(', ')}`)
698
1059
  .option('--allow <capability>', 'Allow one capability in a custom policy; repeat as needed', collectOption, [])
699
1060
  .option('--admin <capability>', 'Add one admin capability to a named profile; repeat as needed', collectOption, [])
700
1061
  .description('Create an API key (shown once)')
701
- .action((opts) => {
702
- const ctx = createContext();
703
- warnLicense(ctx.db);
1062
+ .action(async (opts) => {
704
1063
  try {
705
- const member = findMember(ctx.db, opts.member);
706
- const accountIds = accountIdsFromRefs(ctx, opts.account);
707
1064
  const permissions = permissionPolicyFromOptions(opts);
708
- const { key, info } = createApiKey(ctx.db, opts.name, member.id, permissions, accountIds);
709
- console.log(`Created API key "${info.name}" (id: ${info.id}, profile: ${info.permissionProfile})` +
710
- ` for member ${member.name}` +
711
- '\n');
712
- console.log(` ${key}\n`);
1065
+ const body = {
1066
+ name: opts.name,
1067
+ ...(opts.member ? { member: opts.member } : {}),
1068
+ accounts: opts.account.length ? opts.account : null,
1069
+ ...(permissions.profile === 'custom'
1070
+ ? { customCapabilities: permissions.capabilities }
1071
+ : {
1072
+ permissionProfile: permissions.profile,
1073
+ supplementalCapabilities: permissions.supplementalCapabilities,
1074
+ }),
1075
+ };
1076
+ const created = await instanceClient(selectedInstance()).json(opts.member ? '/api/v1/admin/api-keys' : '/api/v1/me/api-keys', {
1077
+ method: 'POST',
1078
+ headers: { 'content-type': 'application/json' },
1079
+ body: JSON.stringify(opts.member
1080
+ ? body
1081
+ : {
1082
+ name: body.name,
1083
+ accountIds: body.accounts,
1084
+ ...(permissions.profile === 'custom'
1085
+ ? { capabilities: permissions.capabilities }
1086
+ : {
1087
+ permissionProfile: permissions.profile,
1088
+ supplementalCapabilities: permissions.supplementalCapabilities,
1089
+ }),
1090
+ }),
1091
+ });
1092
+ console.log(`Created API key "${created.name}" (id: ${created.id}, profile: ${created.permissionProfile}).\n`);
1093
+ console.log(` ${created.key}\n`);
713
1094
  console.log('Store it now; it cannot be shown again.');
714
1095
  }
715
1096
  catch (err) {
@@ -720,23 +1101,27 @@ export function createCliProgram() {
720
1101
  apikey
721
1102
  .command('list')
722
1103
  .description('List API keys')
723
- .action(() => {
724
- const ctx = createContext();
725
- warnLicense(ctx.db);
726
- const keys = listApiKeys(ctx.db);
727
- if (!keys.length) {
728
- console.log('No API keys. Run "fluxmail apikey create --name <name> --member <member>".');
729
- return;
1104
+ .action(async () => {
1105
+ try {
1106
+ const client = instanceClient(selectedInstance());
1107
+ const me = await client.json('/api/v1/me');
1108
+ const keys = await client.json(me.role === 'admin' ? '/api/v1/admin/api-keys' : '/api/v1/me/api-keys');
1109
+ if (!keys.length) {
1110
+ console.log('No API keys. Run "fluxmail apikey create --name <name>".');
1111
+ return;
1112
+ }
1113
+ for (const k of keys) {
1114
+ const lastUsed = k.lastUsedAt ? new Date(k.lastUsedAt).toISOString() : 'never';
1115
+ const accountScope = k.accountIds === null ? 'all granted' : k.accountIds.join(',') || 'none';
1116
+ console.log(`${k.id} ${k.name} member=${k.memberId} accounts=${accountScope} profile=${k.permissionProfile}` +
1117
+ (k.permissionProfile === 'custom' ? ` allows=${k.capabilities.join(',')}` : '') +
1118
+ (k.supplementalCapabilities.length ? ` admin=${k.supplementalCapabilities.join(',')}` : '') +
1119
+ ` created=${new Date(k.createdAt).toISOString()} lastUsed=${lastUsed}`);
1120
+ }
730
1121
  }
731
- const memberNames = new Map(listMembers(ctx.db).map((m) => [m.id, m.name]));
732
- for (const k of keys) {
733
- const lastUsed = k.lastUsedAt ? new Date(k.lastUsedAt).toISOString() : 'never';
734
- const member = k.memberId ? (memberNames.get(k.memberId) ?? k.memberId) : 'system';
735
- const accountScope = k.accountIds === null ? 'all granted' : k.accountIds.join(',') || 'none';
736
- console.log(`${k.id} ${k.name} member=${member} accounts=${accountScope} profile=${k.permissionProfile}` +
737
- (k.permissionProfile === 'custom' ? ` allows=${k.capabilities.join(',')}` : '') +
738
- (k.supplementalCapabilities.length ? ` admin=${k.supplementalCapabilities.join(',')}` : '') +
739
- ` created=${new Date(k.createdAt).toISOString()} lastUsed=${lastUsed}`);
1122
+ catch (err) {
1123
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1124
+ process.exitCode = 1;
740
1125
  }
741
1126
  });
742
1127
  apikey
@@ -745,17 +1130,17 @@ export function createCliProgram() {
745
1130
  .option('--account <account>', 'Replace the allowlist with this mailbox; repeat as needed', collectOption, [])
746
1131
  .option('--all-accounts', 'Clear the allowlist')
747
1132
  .description('Replace or clear an API key mailbox allowlist')
748
- .action((keyId, opts) => {
749
- const ctx = createContext();
750
- warnLicense(ctx.db);
1133
+ .action(async (keyId, opts) => {
751
1134
  try {
752
1135
  if (Boolean(opts.allAccounts) === Boolean(opts.account.length)) {
753
1136
  throw new EmailError('invalid_request', 'Pass --all-accounts or at least one --account.');
754
1137
  }
755
- const accountIds = opts.allAccounts ? null : accountIdsFromRefs(ctx, opts.account);
756
- if (!updateApiKeyAccounts(ctx.db, keyId, accountIds)) {
757
- throw new EmailError('not_found', `No key with id ${keyId}.`);
758
- }
1138
+ const accountIds = opts.allAccounts ? null : opts.account;
1139
+ await instanceClient(selectedInstance()).json(`/api/v1/admin/api-keys/${encodeURIComponent(keyId)}`, {
1140
+ method: 'PATCH',
1141
+ headers: { 'content-type': 'application/json' },
1142
+ body: JSON.stringify({ accounts: accountIds }),
1143
+ });
759
1144
  console.log(accountIds === null
760
1145
  ? `Cleared the mailbox allowlist for ${keyId}.`
761
1146
  : `Updated ${keyId} to ${accountIds.length} mailbox(es).`);
@@ -772,22 +1157,26 @@ export function createCliProgram() {
772
1157
  .option('--allow <capability>', 'Allow one capability in a custom policy; repeat as needed', collectOption, [])
773
1158
  .option('--admin <capability>', 'Add one admin capability to a named profile; repeat as needed', collectOption, [])
774
1159
  .description('Change the permissions for an API key')
775
- .action((keyId, opts) => {
776
- const ctx = createContext();
777
- warnLicense(ctx.db);
1160
+ .action(async (keyId, opts) => {
778
1161
  try {
779
- const existing = listApiKeys(ctx.db).find((key) => key.id === keyId);
1162
+ const client = instanceClient(selectedInstance());
1163
+ const existing = (await client.json('/api/v1/admin/api-keys')).find((key) => key.id === keyId);
780
1164
  if (!existing) {
781
1165
  console.error(`No key with id ${keyId}`);
782
1166
  process.exitCode = 1;
783
1167
  return;
784
1168
  }
785
1169
  const permissions = permissionPolicyForUpdate(opts, existing);
786
- if (!updateApiKeyPermissions(ctx.db, keyId, permissions)) {
787
- console.error(`No key with id ${keyId}`);
788
- process.exitCode = 1;
789
- return;
790
- }
1170
+ await client.json(`/api/v1/admin/api-keys/${encodeURIComponent(keyId)}`, {
1171
+ method: 'PATCH',
1172
+ headers: { 'content-type': 'application/json' },
1173
+ body: JSON.stringify(permissions.profile === 'custom'
1174
+ ? { customCapabilities: permissions.capabilities }
1175
+ : {
1176
+ permissionProfile: permissions.profile,
1177
+ supplementalCapabilities: permissions.supplementalCapabilities,
1178
+ }),
1179
+ });
791
1180
  console.log(`Updated ${keyId} to profile ${permissions.profile}`);
792
1181
  }
793
1182
  catch (err) {
@@ -799,10 +1188,20 @@ export function createCliProgram() {
799
1188
  .command('revoke')
800
1189
  .argument('<keyId>')
801
1190
  .description('Revoke an API key')
802
- .action((keyId) => {
803
- const ctx = createContext();
804
- warnLicense(ctx.db);
805
- console.log(revokeApiKey(ctx.db, keyId) ? `Revoked ${keyId}` : `No key with id ${keyId}`);
1191
+ .action(async (keyId) => {
1192
+ try {
1193
+ const client = instanceClient(selectedInstance());
1194
+ const me = await client.json('/api/v1/me');
1195
+ const path = me.role === 'admin'
1196
+ ? `/api/v1/admin/api-keys/${encodeURIComponent(keyId)}`
1197
+ : `/api/v1/me/api-keys/${encodeURIComponent(keyId)}`;
1198
+ await client.json(path, { method: 'DELETE' });
1199
+ console.log(`Revoked ${keyId}`);
1200
+ }
1201
+ catch (err) {
1202
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1203
+ process.exitCode = 1;
1204
+ }
806
1205
  });
807
1206
  const license = program.command('license').description('Manage the paid-tier license');
808
1207
  license
@@ -815,92 +1214,62 @@ export function createCliProgram() {
815
1214
  process.exitCode = 1;
816
1215
  return;
817
1216
  }
818
- const dataDir = resolveDataDir();
819
- const overridingKey = process.env.FLUXMAIL_LICENSE_KEY?.trim();
820
- if (overridingKey && overridingKey !== key) {
821
- console.error('FLUXMAIL_LICENSE_KEY is already set by the shell or a .env file, which takes precedence over stored settings. Remove it before activating a different key.');
822
- process.exitCode = 1;
823
- return;
824
- }
825
- const ctx = createContext();
826
- const result = await activateLicense(ctx.db, {
827
- licenseKey: key,
828
- serverUrl: ctx.config.licenseServerUrl,
829
- dataDir,
830
- });
831
- if (result.outcome === 'refreshed') {
832
- const { lease } = result;
833
- console.log(`License activated: ${lease.plan} plan, up to ${lease.maxAccounts} mailboxes and ` +
834
- `${lease.maxMembers} member${lease.maxMembers === 1 ? '' : 's'}.`);
835
- console.log(`The lease is valid until ${lease.expiresAt} and renews automatically while the server runs.`);
836
- return;
837
- }
838
- console.error(result.message);
839
- if (result.outcome === 'outage') {
840
- console.error('The key is saved; validation will be retried automatically once the server can reach it.');
1217
+ try {
1218
+ const result = await instanceClient(selectedInstance()).json('/api/v1/admin/license/activate', {
1219
+ method: 'POST',
1220
+ headers: { 'content-type': 'application/json' },
1221
+ body: JSON.stringify({ licenseKey: key }),
1222
+ });
1223
+ if (result.lease) {
1224
+ console.log(`License activated: ${result.lease.plan} plan, up to ${result.lease.maxAccounts} mailboxes and ` +
1225
+ `${result.lease.maxMembers} member${result.lease.maxMembers === 1 ? '' : 's'}.`);
1226
+ console.log(`The lease is valid until ${result.lease.expiresAt} and renews automatically while the server runs.`);
1227
+ }
1228
+ else {
1229
+ console.log('The license key was saved. Validation will retry when the instance can reach the license server.');
1230
+ }
841
1231
  }
842
- else {
1232
+ catch (err) {
1233
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
843
1234
  process.exitCode = 1;
844
1235
  }
845
1236
  });
846
1237
  license
847
1238
  .command('status')
848
1239
  .description('Show the configured license and cached lease')
849
- .action(() => {
850
- const ctx = createContext();
851
- if (ctx.config.licenseKey) {
852
- console.log(`License key: ${maskStoredConfigValue('FLUXMAIL_LICENSE_KEY', ctx.config.licenseKey)}`);
853
- }
854
- else {
855
- console.log('No license key configured. Run "fluxmail license activate <key>" after purchasing one.');
856
- }
857
- const row = readLeaseRow(ctx.db);
858
- if (row) {
859
- console.log(`Last validated: ${new Date(row.updatedAt).toISOString()}`);
860
- try {
861
- const lease = verifyLease(row.token, licensePublicKeys(), new Date(), { allowExpired: true });
862
- const expired = Date.parse(lease.expiresAt) <= Date.now();
863
- console.log(`Lease ${expired ? 'expired' : 'valid until'} ${lease.expiresAt}: ${lease.plan} plan, ` +
864
- `up to ${lease.maxAccounts} mailboxes and ${lease.maxMembers} member${lease.maxMembers === 1 ? '' : 's'}.`);
865
- }
866
- catch (err) {
867
- console.log(`Cached lease is not usable (${err instanceof Error ? err.message : String(err)}).`);
868
- }
1240
+ .action(async () => {
1241
+ try {
1242
+ const status = await instanceClient(selectedInstance()).json('/api/v1/admin/license');
1243
+ console.log(status.configured
1244
+ ? `License configured from ${status.source}.`
1245
+ : 'No license key configured. Run "fluxmail license activate <key>" after purchasing one.');
1246
+ console.log(`Current plan: ${planLine(status.entitlements)}`);
1247
+ console.log(`Usage: ${status.usage.accounts} mailbox(es), ${status.usage.members} member(s)` +
1248
+ (status.usage.overQuota ? ' (over plan limits)' : ''));
1249
+ if (status.lastValidatedAt)
1250
+ console.log(`Last validated: ${status.lastValidatedAt}`);
1251
+ if (status.warning)
1252
+ console.log(`Warning: ${status.warning}`);
869
1253
  }
870
- else {
871
- console.log('No cached lease.');
1254
+ catch (err) {
1255
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1256
+ process.exitCode = 1;
872
1257
  }
873
- console.log(`Current plan: ${planLine(getEntitlements(ctx.db))}`);
874
- warnLicense(ctx.db, console.log);
875
1258
  });
876
1259
  license
877
1260
  .command('deactivate')
878
1261
  .description('Release the license from this instance and remove the stored key and cached lease')
879
1262
  .action(async () => {
880
- const dataDir = resolveDataDir();
881
- // Captured before createContext() merges the stored config into the
882
- // environment: truthy only when the key comes from the shell or a .env file.
883
- const envLicenseKey = process.env.FLUXMAIL_LICENSE_KEY?.trim();
884
- const ctx = createContext();
885
- // Best effort: free the server-side instance binding so the key can be
886
- // activated elsewhere. Local deactivation proceeds either way.
887
- if (ctx.config.licenseKey) {
888
- const released = await releaseLicense({
889
- serverUrl: ctx.config.licenseServerUrl,
890
- licenseKey: ctx.config.licenseKey,
891
- instanceId: loadInstanceId(dataDir),
892
- });
893
- console.log(released
894
- ? 'Released the license from this instance; it can now be activated elsewhere.'
895
- : 'Could not reach the license server to release this instance; unused bindings are released automatically after a while.');
1263
+ try {
1264
+ const result = await instanceClient(selectedInstance()).json('/api/v1/admin/license', { method: 'DELETE' });
1265
+ console.log(result.released
1266
+ ? 'Released the license from this instance.'
1267
+ : 'The local license was removed. The remote binding, if any, will expire automatically.');
1268
+ console.log('This instance is back to Personal plan limits.');
896
1269
  }
897
- const removed = unsetStoredConfig(dataDir, 'FLUXMAIL_LICENSE_KEY');
898
- clearLease(ctx.db);
899
- console.log(removed
900
- ? 'License removed; this instance is back to Personal-plan limits.'
901
- : 'No stored license key; cleared any cached lease.');
902
- if (envLicenseKey) {
903
- console.log('Note: FLUXMAIL_LICENSE_KEY is still set in the environment or a .env file.');
1270
+ catch (err) {
1271
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1272
+ process.exitCode = 1;
904
1273
  }
905
1274
  });
906
1275
  const configCmd = program
@@ -973,19 +1342,26 @@ export function createCliProgram() {
973
1342
  });
974
1343
  program
975
1344
  .command('status')
976
- .description('Show accounts, members, entitlements, and provider availability')
1345
+ .description('Show mailbox and provider status for the selected instance')
977
1346
  .action(async () => {
978
- const ctx = createContext();
979
- warnLicense(ctx.db);
980
- console.log(JSON.stringify(await ctx.service.status(), null, 2));
1347
+ try {
1348
+ const status = await instanceClient(selectedInstance()).json('/api/v1/status');
1349
+ console.log(JSON.stringify(status, null, 2));
1350
+ }
1351
+ catch (err) {
1352
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1353
+ process.exitCode = 1;
1354
+ }
981
1355
  });
982
1356
  return program;
983
1357
  }
984
1358
  export async function runCli(argv = process.argv) {
1359
+ const program = createCliProgram();
985
1360
  try {
986
- await createCliProgram().parseAsync([...argv]);
1361
+ await program.parseAsync([...argv]);
987
1362
  }
988
1363
  catch (err) {
1364
+ finishCliOperation(program, 'error', isEmailError(err) ? err.code : 'internal');
989
1365
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
990
1366
  process.exitCode = 1;
991
1367
  await shutdownTelemetry();