fluxmail 0.4.1 → 0.6.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 (147) hide show
  1. package/README.md +6 -4
  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 +16 -9
  9. package/dist/accounts/googleAuth.d.ts.map +1 -1
  10. package/dist/accounts/googleAuth.js +52 -20
  11. package/dist/accounts/googleAuth.js.map +1 -1
  12. package/dist/accounts/microsoftAuth.d.ts +5 -2
  13. package/dist/accounts/microsoftAuth.d.ts.map +1 -1
  14. package/dist/accounts/microsoftAuth.js +25 -22
  15. package/dist/accounts/microsoftAuth.js.map +1 -1
  16. package/dist/accounts/registry.d.ts +11 -9
  17. package/dist/accounts/registry.d.ts.map +1 -1
  18. package/dist/accounts/registry.js +169 -98
  19. package/dist/accounts/registry.js.map +1 -1
  20. package/dist/auth.d.ts +106 -0
  21. package/dist/auth.d.ts.map +1 -0
  22. package/dist/auth.js +455 -0
  23. package/dist/auth.js.map +1 -0
  24. package/dist/authorization.d.ts +9 -0
  25. package/dist/authorization.d.ts.map +1 -0
  26. package/dist/authorization.js +23 -0
  27. package/dist/authorization.js.map +1 -0
  28. package/dist/cli.d.ts +15 -1
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +1096 -403
  31. package/dist/cli.js.map +1 -1
  32. package/dist/cliInstances.d.ts +51 -0
  33. package/dist/cliInstances.d.ts.map +1 -0
  34. package/dist/cliInstances.js +237 -0
  35. package/dist/cliInstances.js.map +1 -0
  36. package/dist/cliMail.d.ts +9 -0
  37. package/dist/cliMail.d.ts.map +1 -0
  38. package/dist/cliMail.js +450 -0
  39. package/dist/cliMail.js.map +1 -0
  40. package/dist/config.d.ts +95 -118
  41. package/dist/config.d.ts.map +1 -1
  42. package/dist/config.js +351 -286
  43. package/dist/config.js.map +1 -1
  44. package/dist/configMigration.d.ts +8 -0
  45. package/dist/configMigration.d.ts.map +1 -0
  46. package/dist/configMigration.js +234 -0
  47. package/dist/configMigration.js.map +1 -0
  48. package/dist/context.d.ts +8 -2
  49. package/dist/context.d.ts.map +1 -1
  50. package/dist/context.js +29 -8
  51. package/dist/context.js.map +1 -1
  52. package/dist/deploymentConfig.d.ts +190 -0
  53. package/dist/deploymentConfig.d.ts.map +1 -0
  54. package/dist/deploymentConfig.js +539 -0
  55. package/dist/deploymentConfig.js.map +1 -0
  56. package/dist/http/admin.d.ts +75 -3
  57. package/dist/http/admin.d.ts.map +1 -1
  58. package/dist/http/admin.js +347 -79
  59. package/dist/http/admin.js.map +1 -1
  60. package/dist/http/app.d.ts +4 -1
  61. package/dist/http/app.d.ts.map +1 -1
  62. package/dist/http/app.js +119 -63
  63. package/dist/http/app.js.map +1 -1
  64. package/dist/http/identity.d.ts +114 -0
  65. package/dist/http/identity.d.ts.map +1 -0
  66. package/dist/http/identity.js +936 -0
  67. package/dist/http/identity.js.map +1 -0
  68. package/dist/http/operationRoutes.d.ts +8 -0
  69. package/dist/http/operationRoutes.d.ts.map +1 -0
  70. package/dist/http/operationRoutes.js +13 -0
  71. package/dist/http/operationRoutes.js.map +1 -0
  72. package/dist/http/rest.d.ts +16 -4
  73. package/dist/http/rest.d.ts.map +1 -1
  74. package/dist/http/rest.js +213 -71
  75. package/dist/http/rest.js.map +1 -1
  76. package/dist/index.d.ts +5 -3
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +5 -3
  79. package/dist/index.js.map +1 -1
  80. package/dist/instanceConfig.d.ts +36 -0
  81. package/dist/instanceConfig.d.ts.map +1 -0
  82. package/dist/instanceConfig.js +157 -0
  83. package/dist/instanceConfig.js.map +1 -0
  84. package/dist/licensing/activation.d.ts +2 -1
  85. package/dist/licensing/activation.d.ts.map +1 -1
  86. package/dist/licensing/activation.js +2 -3
  87. package/dist/licensing/activation.js.map +1 -1
  88. package/dist/licensing/entitlements.d.ts.map +1 -1
  89. package/dist/licensing/entitlements.js +2 -2
  90. package/dist/licensing/entitlements.js.map +1 -1
  91. package/dist/licensing/refresher.d.ts +6 -1
  92. package/dist/licensing/refresher.d.ts.map +1 -1
  93. package/dist/licensing/refresher.js +18 -7
  94. package/dist/licensing/refresher.js.map +1 -1
  95. package/dist/logging.d.ts +87 -0
  96. package/dist/logging.d.ts.map +1 -0
  97. package/dist/logging.js +612 -0
  98. package/dist/logging.js.map +1 -0
  99. package/dist/mcp/buildServer.d.ts +3 -1
  100. package/dist/mcp/buildServer.d.ts.map +1 -1
  101. package/dist/mcp/buildServer.js +27 -24
  102. package/dist/mcp/buildServer.js.map +1 -1
  103. package/dist/permissions.d.ts +1 -1
  104. package/dist/permissions.d.ts.map +1 -1
  105. package/dist/permissions.js +10 -4
  106. package/dist/permissions.js.map +1 -1
  107. package/dist/scheduler/sendScheduler.d.ts +4 -3
  108. package/dist/scheduler/sendScheduler.d.ts.map +1 -1
  109. package/dist/scheduler/sendScheduler.js +15 -3
  110. package/dist/scheduler/sendScheduler.js.map +1 -1
  111. package/dist/service/emailService.d.ts +10 -17
  112. package/dist/service/emailService.d.ts.map +1 -1
  113. package/dist/service/emailService.js +28 -38
  114. package/dist/service/emailService.js.map +1 -1
  115. package/dist/storage/adminAudit.d.ts +15 -2
  116. package/dist/storage/adminAudit.d.ts.map +1 -1
  117. package/dist/storage/adminAudit.js +22 -27
  118. package/dist/storage/adminAudit.js.map +1 -1
  119. package/dist/storage/apiKeys.d.ts +8 -8
  120. package/dist/storage/apiKeys.d.ts.map +1 -1
  121. package/dist/storage/apiKeys.js +16 -11
  122. package/dist/storage/apiKeys.js.map +1 -1
  123. package/dist/storage/db.d.ts +596 -31
  124. package/dist/storage/db.d.ts.map +1 -1
  125. package/dist/storage/db.js +383 -45
  126. package/dist/storage/db.js.map +1 -1
  127. package/dist/storage/fileLock.d.ts +7 -0
  128. package/dist/storage/fileLock.d.ts.map +1 -0
  129. package/dist/storage/fileLock.js +183 -0
  130. package/dist/storage/fileLock.js.map +1 -0
  131. package/dist/storage/gmailConnectionGrants.d.ts +3 -4
  132. package/dist/storage/gmailConnectionGrants.d.ts.map +1 -1
  133. package/dist/storage/gmailConnectionGrants.js +6 -6
  134. package/dist/storage/gmailConnectionGrants.js.map +1 -1
  135. package/dist/storage/members.d.ts +11 -0
  136. package/dist/storage/members.d.ts.map +1 -1
  137. package/dist/storage/members.js +70 -6
  138. package/dist/storage/members.js.map +1 -1
  139. package/dist/telemetry.d.ts +17 -2
  140. package/dist/telemetry.d.ts.map +1 -1
  141. package/dist/telemetry.js +34 -19
  142. package/dist/telemetry.js.map +1 -1
  143. package/dist/updateNotifier.d.ts +18 -0
  144. package/dist/updateNotifier.d.ts.map +1 -0
  145. package/dist/updateNotifier.js +128 -0
  146. package/dist/updateNotifier.js.map +1 -0
  147. package/package.json +38 -5
package/dist/cli.js CHANGED
@@ -1,42 +1,43 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from 'node:fs';
2
+ import { existsSync, readFileSync, 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
- import { configFilePath, maskStoredConfigValue, readStoredConfig, resolveDataDir, setStoredConfig, unsetStoredConfig, } from './config.js';
13
+ import { configTomlPath, createConfigurationService, deploymentToml, readLoggingSettings, resolveDataDir, resolveStoreLocation, writeDeploymentConfig, } from './config.js';
14
+ import { migrateConfigurationFile, parseEnvContent, recognizedMigrationSettings } from './configMigration.js';
12
15
  import { createApp } from './http/app.js';
13
16
  import { buildMcpServer } from './mcp/buildServer.js';
14
17
  import { runLoopbackFlow } from './accounts/googleAuth.js';
15
18
  import { runMicrosoftLoopbackFlow } from './accounts/microsoftAuth.js';
16
19
  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';
20
+ import { listApiKeys } from './storage/apiKeys.js';
21
+ import { findMember } from './storage/members.js';
22
+ import { LICENSE_KEY_PATTERN } from './licensing/client.js';
23
+ import { checkLicenseState, getEntitlements } from './licensing/entitlements.js';
24
24
  import { VERSION } from './version.js';
25
+ import { inspectStoreCompatibility, MIN_SUPPORTED_STORE_FORMAT, openDb, openReadonlyDb, } from './storage/db.js';
25
26
  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';
27
+ import { captureOperation, getTelemetry, installTelemetryStreamEndHandler, isTelemetryEnabled, setTelemetryEnabled, shutdownTelemetry, telemetryDisabledInAnyEnvironment, } from './telemetry.js';
28
+ import { clearSessionToken, instanceClient, loadInstanceConfig, removeInstance, resolveInstance, saveLocalInstance, saveRemoteInstance, saveSessionToken, useInstance, } from './cliInstances.js';
29
+ import { authenticateBearer, normalizeAndValidatePassword, recoverAdminPassword, setupInitialAdmin } from './auth.js';
30
+ import { recordAdminAuditEvent } from './storage/adminAudit.js';
31
+ import { canManageOwnedAccount } from './authorization.js';
32
+ import { createCliUpdateNotifier } from './updateNotifier.js';
33
+ import { registerMailCommands } from './cliMail.js';
34
+ import { flushLogging, escapeLogTextForConsole, getLogger, logCodedFailure, logFailure, readLocalLogs, shutdownLogging, } from './logging.js';
27
35
  function collectOption(value, previous) {
28
36
  return [...previous, value];
29
37
  }
30
38
  function accountIdsFromRefs(ctx, refs) {
31
39
  return refs.length ? [...new Set(refs.map((ref) => ctx.registry.findAccount(ref).id))] : null;
32
40
  }
33
- function sharingMode(shared, sharedMemberIds) {
34
- if (shared)
35
- return 'all';
36
- if (sharedMemberIds.length)
37
- return 'selected';
38
- return 'private';
39
- }
40
41
  export function permissionPolicyFromOptions(opts, requireSelection = false) {
41
42
  const supplemental = opts.admin ?? [];
42
43
  if (opts.profile && opts.allow.length) {
@@ -85,11 +86,11 @@ function connectionSecurity(value, option) {
85
86
  }
86
87
  async function hiddenPrompt(label) {
87
88
  if (!process.stdin.isTTY || !process.stdin.setRawMode) {
88
- throw new EmailError('invalid_request', `${label} must be supplied through a password environment variable.`);
89
+ throw new EmailError('invalid_request', `${label} requires an interactive terminal or an explicit non-interactive input.`);
89
90
  }
91
+ const wasFlowing = process.stdin.readableFlowing === true;
90
92
  emitKeypressEvents(process.stdin);
91
93
  const wasRaw = process.stdin.isRaw;
92
- const wasPaused = process.stdin.isPaused();
93
94
  process.stdout.write(`${label}: `);
94
95
  process.stdin.setRawMode(true);
95
96
  process.stdin.resume();
@@ -98,7 +99,7 @@ async function hiddenPrompt(label) {
98
99
  const finish = (error) => {
99
100
  process.stdin.off('keypress', onKeypress);
100
101
  process.stdin.setRawMode(wasRaw);
101
- if (wasPaused)
102
+ if (!wasFlowing)
102
103
  process.stdin.pause();
103
104
  process.stdout.write('\n');
104
105
  if (error)
@@ -126,6 +127,39 @@ async function hiddenPrompt(label) {
126
127
  process.stdin.on('keypress', onKeypress);
127
128
  });
128
129
  }
130
+ async function textPrompt(label) {
131
+ if (!process.stdin.isTTY)
132
+ throw new EmailError('invalid_request', `${label} must be supplied as an option.`);
133
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
134
+ try {
135
+ return (await prompt.question(`${label}: `)).trim();
136
+ }
137
+ finally {
138
+ prompt.close();
139
+ }
140
+ }
141
+ async function loginPassword(label = 'Password') {
142
+ return process.env.FLUXMAIL_PASSWORD ?? hiddenPrompt(label);
143
+ }
144
+ async function newPassword(label, member, prompt) {
145
+ return withNewPassword(label, member, prompt, async (password) => password);
146
+ }
147
+ async function withNewPassword(label, member, prompt, submit) {
148
+ const configured = process.env.FLUXMAIL_PASSWORD;
149
+ if (configured !== undefined)
150
+ return submit(normalizeAndValidatePassword(configured, member));
151
+ for (;;) {
152
+ const password = await prompt(label);
153
+ try {
154
+ return await submit(normalizeAndValidatePassword(password, member));
155
+ }
156
+ catch (error) {
157
+ if (!(error instanceof EmailError) || error.code !== 'invalid_request')
158
+ throw error;
159
+ console.error(`Error: ${error.message}`);
160
+ }
161
+ }
162
+ }
129
163
  async function accountSecret(envName, label) {
130
164
  if (!envName)
131
165
  return hiddenPrompt(label);
@@ -134,6 +168,15 @@ async function accountSecret(envName, label) {
134
168
  throw new EmailError('invalid_request', `${envName} is not set or is empty.`);
135
169
  return value;
136
170
  }
171
+ function readSecretFile(file) {
172
+ const value = readFileSync(file === '-' ? 0 : file, 'utf8').replace(/\r?\n$/, '');
173
+ if (!value)
174
+ throw new EmailError('invalid_request', 'The secret input is empty.');
175
+ return value;
176
+ }
177
+ async function secretInput(file, label) {
178
+ return file === undefined ? hiddenPrompt(label) : readSecretFile(file);
179
+ }
137
180
  function planLine(ent) {
138
181
  const members = `${ent.maxMembers} member${ent.maxMembers === 1 ? '' : 's'}`;
139
182
  return `${ent.plan} (up to ${ent.maxAccounts} mailboxes, ${members})`;
@@ -144,13 +187,86 @@ function warnLicense(db, log = console.error) {
144
187
  if (warning)
145
188
  log(`Warning: ${warning}`);
146
189
  }
190
+ const activeCliOperations = new WeakMap();
191
+ const activeUpdateNotifiers = new WeakMap();
192
+ function finishCliOperation(program, outcome, errorCode, error) {
193
+ const active = activeCliOperations.get(program);
194
+ if (!active)
195
+ return;
196
+ activeCliOperations.delete(program);
197
+ if (outcome === 'error' && !active.errorLogged) {
198
+ const context = { productSurface: 'cli', operation: active.operation, skipConsole: true };
199
+ if (error !== undefined)
200
+ logFailure(active.logger, 'cli.operation_failed', error, context);
201
+ else
202
+ logCodedFailure(active.logger, 'cli.operation_failed', errorCode ?? 'command_failed', 'CLI command failed', context);
203
+ }
204
+ captureOperation(active.telemetry, {
205
+ productSurface: 'cli',
206
+ operation: active.operation,
207
+ outcome,
208
+ durationMs: performance.now() - active.startedAt,
209
+ errorCode,
210
+ });
211
+ }
212
+ function logActiveCliError(program, error) {
213
+ const active = activeCliOperations.get(program);
214
+ if (!active || active.errorLogged)
215
+ return;
216
+ active.errorLogged = true;
217
+ logFailure(active.logger, 'cli.operation_failed', error, {
218
+ productSurface: 'cli',
219
+ operation: active.operation,
220
+ skipConsole: true,
221
+ });
222
+ }
223
+ export function waitForServerListening(server) {
224
+ return new Promise((resolve, reject) => {
225
+ const onError = (error) => {
226
+ server.off('listening', onListening);
227
+ reject(error);
228
+ };
229
+ const onListening = () => {
230
+ server.off('error', onError);
231
+ resolve();
232
+ };
233
+ server.once('error', onError);
234
+ server.once('listening', onListening);
235
+ });
236
+ }
237
+ async function shutdownTelemetryThenLogging() {
238
+ try {
239
+ await shutdownTelemetry();
240
+ }
241
+ finally {
242
+ await shutdownLogging();
243
+ }
244
+ }
245
+ export async function shutdownTelemetryAndLogging() {
246
+ const initialLogFlush = flushLogging().catch(() => { });
247
+ try {
248
+ await shutdownTelemetry();
249
+ }
250
+ finally {
251
+ await initialLogFlush;
252
+ await shutdownLogging();
253
+ }
254
+ }
255
+ export function installStdioShutdownHandler(stream) {
256
+ installTelemetryStreamEndHandler(stream, shutdownTelemetryThenLogging);
257
+ }
147
258
  /** Build the command tree without parsing arguments or running command actions. */
148
- export function createCliProgram() {
259
+ export function createCliProgram(options = {}) {
149
260
  const program = new Command();
150
261
  program
151
262
  .name('fluxmail')
152
- .description('Fluxmail, a self-hosted MCP server for your email')
263
+ .description('Fluxmail, a self-hosted email API with MCP, REST, and CLI access')
264
+ .option('--instance <name>', 'Use a named local or remote instance')
265
+ .option('-a, --mail-account <id-or-email>', 'Use an email account by ID or address')
266
+ .option('--no-update-notifier', 'Skip the automatic update check for this command')
153
267
  .version(VERSION, '-v, --version');
268
+ const selectedInstance = () => program.opts().instance;
269
+ const selectedAccount = () => program.opts().mailAccount;
154
270
  function commandPath(command) {
155
271
  const names = [];
156
272
  for (let current = command; current?.parent; current = current.parent)
@@ -159,7 +275,7 @@ export function createCliProgram() {
159
275
  }
160
276
  let telemetrySignalHandlersInstalled = false;
161
277
  const TELEMETRY_SIGNAL_SHUTDOWN_TIMEOUT_MS = 1_000;
162
- /** Flush queued telemetry before restoring Node's default signal behavior. */
278
+ /** Flush queued telemetry and local logs before restoring Node's default signal behavior. */
163
279
  function installTelemetrySignalHandlers() {
164
280
  if (telemetrySignalHandlersInstalled)
165
281
  return;
@@ -176,7 +292,7 @@ export function createCliProgram() {
176
292
  process.kill(process.pid, signal);
177
293
  };
178
294
  const timeout = setTimeout(resignal, TELEMETRY_SIGNAL_SHUTDOWN_TIMEOUT_MS);
179
- void shutdownTelemetry().finally(() => {
295
+ void shutdownTelemetryAndLogging().finally(() => {
180
296
  clearTimeout(timeout);
181
297
  resignal();
182
298
  });
@@ -190,72 +306,367 @@ export function createCliProgram() {
190
306
  }
191
307
  program.hook('preAction', (_command, actionCommand) => {
192
308
  const command = commandPath(actionCommand);
309
+ const updateNotifierEnabled = program.opts().updateNotifier;
310
+ if (updateNotifierEnabled && actionCommand.name() !== 'stdio') {
311
+ try {
312
+ const notifier = options.updateNotifierFactory?.();
313
+ if (notifier)
314
+ activeUpdateNotifiers.set(program, notifier);
315
+ }
316
+ catch {
317
+ // Update checks must not affect command execution.
318
+ }
319
+ }
193
320
  // Respect the opt-out before creating a telemetry client or recording this command.
194
321
  if (command === 'telemetry disable')
195
322
  return;
196
- const dataDir = resolveDataDir();
197
- getTelemetry(dataDir).capture('cli command used', {
198
- product_surface: 'cli',
199
- command,
323
+ let telemetryEnvironment;
324
+ let telemetryDataDir;
325
+ if (command === 'config migrate') {
326
+ const migration = actionCommand.opts();
327
+ if (migration.from) {
328
+ try {
329
+ const values = parseEnvContent(readFileSync(path.resolve(migration.from), 'utf8'));
330
+ if (values.FLUXMAIL_DATA_DIR !== undefined) {
331
+ telemetryDataDir = resolveDataDir({
332
+ env: { ...process.env, FLUXMAIL_DATA_DIR: values.FLUXMAIL_DATA_DIR },
333
+ });
334
+ }
335
+ if (telemetryDisabledInAnyEnvironment(process.env, values)) {
336
+ telemetryEnvironment = { ...process.env, FLUXMAIL_TELEMETRY: '0' };
337
+ }
338
+ }
339
+ catch {
340
+ // The command action reports file and parsing errors.
341
+ }
342
+ }
343
+ }
344
+ const dataDir = telemetryDataDir ?? resolveDataDir();
345
+ let logging;
346
+ try {
347
+ logging = readLoggingSettings(dataDir);
348
+ }
349
+ catch {
350
+ // Keep recovery and remote commands available when logging configuration is invalid.
351
+ logging = { level: 'info', destination: 'both' };
352
+ }
353
+ const mode = actionCommand.name() === 'serve' ? 'serve' : actionCommand.name() === 'stdio' ? 'stdio' : 'cli';
354
+ activeCliOperations.set(program, {
355
+ telemetry: options.telemetry ?? getTelemetry(dataDir, telemetryEnvironment),
356
+ operation: command,
357
+ startedAt: performance.now(),
358
+ initialExitCode: process.exitCode,
359
+ logger: getLogger(dataDir, mode, logging),
200
360
  });
201
361
  });
202
362
  program.hook('postAction', async (_command, actionCommand) => {
203
- if (actionCommand.name() !== 'serve' && actionCommand.name() !== 'stdio')
204
- await shutdownTelemetry();
363
+ const active = activeCliOperations.get(program);
364
+ const failed = active !== undefined &&
365
+ process.exitCode !== active.initialExitCode &&
366
+ process.exitCode !== undefined &&
367
+ process.exitCode !== 0;
368
+ finishCliOperation(program, failed ? 'error' : 'success', failed ? 'command_failed' : undefined);
369
+ try {
370
+ if (actionCommand.name() !== 'serve' && actionCommand.name() !== 'stdio') {
371
+ await Promise.all([options.telemetry ? Promise.resolve() : shutdownTelemetry(), shutdownLogging()]);
372
+ }
373
+ }
374
+ finally {
375
+ const notifier = activeUpdateNotifiers.get(program);
376
+ activeUpdateNotifiers.delete(program);
377
+ try {
378
+ notifier?.notify();
379
+ }
380
+ catch {
381
+ // Update notices must not affect the command outcome.
382
+ }
383
+ }
384
+ });
385
+ program
386
+ .command('setup')
387
+ .description('Create the first local administrator or claim a migrated administrator')
388
+ .option('--name <name>', 'Administrator name')
389
+ .option('--email <email>', 'Administrator email')
390
+ .option('--existing-admin <member>', 'Existing administrator id or email for a migrated instance')
391
+ .action(async (opts) => {
392
+ try {
393
+ const name = opts.name ?? (opts.existingAdmin ? undefined : await textPrompt('Administrator name'));
394
+ const email = opts.email ?? (await textPrompt('Administrator email'));
395
+ const context = createContext();
396
+ const passwordMember = opts.existingAdmin
397
+ ? { ...findMember(context.db, opts.existingAdmin), email }
398
+ : { name: name ?? '', email };
399
+ const password = await newPassword('New password', passwordMember, options.passwordPrompt ?? hiddenPrompt);
400
+ const result = await setupInitialAdmin(context.db, {
401
+ ...(name ? { name } : {}),
402
+ email,
403
+ password,
404
+ ...(opts.existingAdmin ? { existingAdmin: opts.existingAdmin } : {}),
405
+ deviceName: `CLI on ${hostname()}`,
406
+ });
407
+ saveLocalInstance('local');
408
+ saveSessionToken('local', result.session.token);
409
+ recordAdminAuditEvent(context.db, {
410
+ operation: 'auth.setup',
411
+ outcome: 'success',
412
+ actorMemberId: result.member.id,
413
+ actorSessionId: result.session.info.id,
414
+ });
415
+ console.log(`Fluxmail is ready. Logged in to local as ${result.member.name}.`);
416
+ }
417
+ catch (err) {
418
+ logActiveCliError(program, err);
419
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
420
+ process.exitCode = 1;
421
+ }
422
+ });
423
+ program
424
+ .command('login')
425
+ .description('Log in to a local or remote instance')
426
+ .option('--server <url>', 'Add or update a remote instance')
427
+ .option('--email <email>', 'Member email')
428
+ .option('--enroll', 'Use a one-time enrollment code')
429
+ .option('--reset', 'Use a one-time password reset code')
430
+ .action(async (opts) => {
431
+ try {
432
+ if (opts.enroll && opts.reset) {
433
+ throw new EmailError('invalid_request', '--enroll and --reset cannot be used together.');
434
+ }
435
+ const requestedInstance = selectedInstance();
436
+ if (!opts.server && requestedInstance === 'local' && !loadInstanceConfig().instances.local) {
437
+ saveLocalInstance('local');
438
+ }
439
+ const instanceName = requestedInstance ?? (opts.server ? 'remote' : resolveInstance().name);
440
+ if (opts.server)
441
+ saveRemoteInstance(instanceName, opts.server);
442
+ const client = instanceClient(instanceName, false);
443
+ const deviceName = `CLI on ${hostname()}`;
444
+ const result = opts.enroll || opts.reset
445
+ ? await (async () => {
446
+ const token = (opts.reset ? process.env.FLUXMAIL_RESET_CODE : process.env.FLUXMAIL_INVITE_CODE) ??
447
+ (await hiddenPrompt(opts.reset ? 'Password reset code' : 'Enrollment code'));
448
+ return withNewPassword('New password', undefined, options.passwordPrompt ?? hiddenPrompt, (password) => client.json(opts.reset ? '/api/v1/auth/password-reset' : '/api/v1/auth/enroll', {
449
+ method: 'POST',
450
+ headers: { 'content-type': 'application/json' },
451
+ body: JSON.stringify({ token, password, deviceName }),
452
+ }, false));
453
+ })()
454
+ : await client.json('/api/v1/auth/login', {
455
+ method: 'POST',
456
+ headers: { 'content-type': 'application/json' },
457
+ body: JSON.stringify({
458
+ email: opts.email ?? (await textPrompt('Email')),
459
+ password: await loginPassword(),
460
+ deviceName,
461
+ }),
462
+ }, false);
463
+ saveSessionToken(instanceName, result.token);
464
+ useInstance(instanceName);
465
+ console.log(`Logged in to ${instanceName} as ${result.member.name}.`);
466
+ }
467
+ catch (err) {
468
+ logActiveCliError(program, err);
469
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
470
+ process.exitCode = 1;
471
+ }
472
+ });
473
+ program
474
+ .command('logout')
475
+ .description('Revoke the current CLI session')
476
+ .action(async () => {
477
+ try {
478
+ const selected = resolveInstance(selectedInstance());
479
+ const response = await instanceClient(selected.name).request('/api/v1/auth/logout', { method: 'POST' });
480
+ if (!response.ok && response.status !== 401) {
481
+ throw new EmailError('invalid_request', `Logout failed with HTTP ${response.status}.`);
482
+ }
483
+ clearSessionToken(selected.name);
484
+ console.log(`Logged out of ${selected.name}.`);
485
+ }
486
+ catch (err) {
487
+ logActiveCliError(program, err);
488
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
489
+ process.exitCode = 1;
490
+ }
491
+ });
492
+ const instances = program.command('instances').description('Manage local and remote CLI instances');
493
+ instances
494
+ .command('list')
495
+ .description('List configured instances')
496
+ .action(() => {
497
+ const config = loadInstanceConfig();
498
+ for (const [name, profile] of Object.entries(config.instances)) {
499
+ console.log(`${name}${config.active === name ? ' *' : ''} ${profile.kind}${profile.kind === 'remote' ? ` ${profile.serverUrl}` : ''}`);
500
+ }
501
+ });
502
+ instances
503
+ .command('use')
504
+ .argument('<name>')
505
+ .description('Select the default instance')
506
+ .action((name) => {
507
+ try {
508
+ useInstance(name);
509
+ console.log(`Using ${name}.`);
510
+ }
511
+ catch (err) {
512
+ logActiveCliError(program, err);
513
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
514
+ process.exitCode = 1;
515
+ }
516
+ });
517
+ instances
518
+ .command('remove')
519
+ .argument('<name>')
520
+ .description('Remove a CLI profile and its local session')
521
+ .action((name) => {
522
+ try {
523
+ removeInstance(name);
524
+ console.log(`Removed CLI instance ${name}. Server data was not changed.`);
525
+ }
526
+ catch (err) {
527
+ logActiveCliError(program, err);
528
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
529
+ process.exitCode = 1;
530
+ }
531
+ });
532
+ const authCommand = program.command('auth').description('Manage interactive authentication');
533
+ authCommand
534
+ .command('recover-admin')
535
+ .argument('<member>', 'Administrator id or email')
536
+ .description('Reset an administrator password using local filesystem access')
537
+ .action(async (memberRef) => {
538
+ try {
539
+ const selected = resolveInstance(selectedInstance());
540
+ if (selected.profile.kind !== 'local') {
541
+ throw new EmailError('invalid_request', 'Administrator recovery is only available on the local instance host.');
542
+ }
543
+ const context = createContext();
544
+ const target = findMember(context.db, memberRef);
545
+ const password = await newPassword('New password', target, options.passwordPrompt ?? hiddenPrompt);
546
+ const member = await recoverAdminPassword(context.db, memberRef, password);
547
+ recordAdminAuditEvent(context.db, {
548
+ operation: 'auth.recover_admin',
549
+ outcome: 'success',
550
+ actorMemberId: member.id,
551
+ resourceType: 'member',
552
+ resourceId: member.id,
553
+ });
554
+ console.log(`Reset ${member.name}'s password and revoked existing sessions.`);
555
+ }
556
+ catch (err) {
557
+ logActiveCliError(program, err);
558
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
559
+ process.exitCode = 1;
560
+ }
561
+ });
562
+ authCommand
563
+ .command('sessions')
564
+ .description('List sessions for the current member')
565
+ .action(async () => {
566
+ try {
567
+ const sessions = await instanceClient(selectedInstance()).json('/api/v1/me/sessions');
568
+ for (const session of sessions) {
569
+ console.log(`${session.id}${session.current ? ' *' : ''} ${session.deviceName} created=${new Date(session.createdAt).toISOString()} expires=${new Date(session.expiresAt).toISOString()}`);
570
+ }
571
+ }
572
+ catch (err) {
573
+ logActiveCliError(program, err);
574
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
575
+ process.exitCode = 1;
576
+ }
577
+ });
578
+ authCommand
579
+ .command('revoke-session')
580
+ .argument('<sessionId>')
581
+ .description("Revoke one of the current member's sessions")
582
+ .action(async (sessionId) => {
583
+ try {
584
+ await instanceClient(selectedInstance()).json(`/api/v1/me/sessions/${encodeURIComponent(sessionId)}`, {
585
+ method: 'DELETE',
586
+ });
587
+ console.log(`Revoked ${sessionId}.`);
588
+ }
589
+ catch (err) {
590
+ logActiveCliError(program, err);
591
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
592
+ process.exitCode = 1;
593
+ }
205
594
  });
206
595
  program
207
596
  .command('serve')
208
597
  .description('Run the HTTP server (MCP at /mcp and REST at /api/v1)')
209
- .action(() => {
598
+ .action(async () => {
210
599
  installTelemetrySignalHandlers();
211
600
  const ctx = createContext();
212
601
  const app = createApp(ctx);
213
602
  ctx.scheduler.start();
214
- serve({ fetch: app.fetch, port: ctx.config.port }, () => {
603
+ const server = serve({ fetch: app.fetch, port: ctx.config.port }, () => {
604
+ ctx.logger.info('server.started', 'Fluxmail HTTP server started', {
605
+ details: { port: ctx.config.port },
606
+ skipConsole: true,
607
+ });
215
608
  ctx.telemetry.capture('mcp server started', { product_surface: 'mcp', transport: 'http' });
216
609
  console.log(`Fluxmail listening on ${ctx.config.publicUrl}`);
217
610
  console.log(` MCP endpoint: ${ctx.config.publicUrl}/mcp`);
218
611
  console.log(` REST API: ${ctx.config.publicUrl}/api/v1`);
219
612
  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)');
613
+ if (listApiKeys(ctx.db).length === 0) {
614
+ console.log(' Note: no API keys exist yet. Run "fluxmail apikey create --name <name>" to create one.');
615
+ }
616
+ try {
617
+ const accounts = ctx.registry.listAccounts();
618
+ console.log(accounts.length
619
+ ? ` Accounts: ${accounts.map((a) => `${a.email} (${a.status})`).join(', ')}`
620
+ : ' Accounts: none (run "fluxmail accounts add gmail", "fluxmail accounts add outlook", or the IMAP equivalent)');
621
+ }
622
+ catch {
623
+ console.log(' Accounts: unavailable until an existing administrator claims this instance');
624
+ }
228
625
  console.log(` Plan: ${planLine(getEntitlements(ctx.db))}`);
229
626
  warnLicense(ctx.db, console.log);
230
- ctx.licenseController.start(console.log);
627
+ ctx.licenseController.start();
231
628
  });
629
+ await waitForServerListening(server);
232
630
  });
233
631
  program
234
632
  .command('stdio')
235
633
  .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
634
  .option('--account <account>', 'Limit access to one mailbox; repeat as needed', collectOption, [])
238
635
  .option('--profile <profile>', `Tool profile: ${NAMED_PERMISSION_PROFILES.join(', ')}`)
239
636
  .option('--allow <capability>', 'Allow one MCP capability; repeat as needed', collectOption, [])
240
637
  .action(async (opts) => {
241
638
  installTelemetrySignalHandlers();
242
- installTelemetryStreamEndHandler(process.stdin);
639
+ installStdioShutdownHandler(process.stdin);
243
640
  const ctx = createContext();
244
641
  const permissions = permissionPolicyFromOptions(opts);
245
- const member = findMember(ctx.db, opts.member);
642
+ const selected = resolveInstance(selectedInstance());
643
+ if (selected.profile.kind !== 'local') {
644
+ throw new EmailError('invalid_request', 'Stdio MCP only supports the local instance. Use the remote HTTP MCP endpoint with an API key.');
645
+ }
646
+ if (!selected.token)
647
+ throw new EmailError('permission_denied', 'Log in to the local instance before starting stdio MCP.');
648
+ const principal = authenticateBearer(ctx.db, selected.token);
649
+ if (!principal || principal.kind !== 'session')
650
+ throw new EmailError('permission_denied', 'The local CLI session has expired. Run "fluxmail login --instance local".');
246
651
  const accountIds = accountIdsFromRefs(ctx, opts.account);
247
- const scopedService = ctx.service.withScope({ memberId: member.id, role: member.role, accountIds });
652
+ const scopedService = ctx.service.withPrincipal({ ...principal, accountIds });
248
653
  const server = buildMcpServer(scopedService, {
249
654
  permissions,
250
655
  maxAttachmentBytes: ctx.config.maxAttachmentBytes,
251
656
  telemetry: ctx.telemetry,
252
657
  transport: 'stdio',
658
+ logger: ctx.logger,
253
659
  });
254
660
  ctx.scheduler.start();
255
661
  await server.connect(new StdioServerTransport());
256
662
  ctx.telemetry.capture('mcp server started', { product_surface: 'mcp', transport: 'stdio' });
663
+ ctx.logger.info('server.started', 'Fluxmail stdio MCP server started', {
664
+ productSurface: 'mcp',
665
+ details: { transport: 'stdio' },
666
+ skipConsole: true,
667
+ });
257
668
  // stdout belongs to the MCP protocol; log to stderr only.
258
- ctx.licenseController.start(console.error);
669
+ ctx.licenseController.start();
259
670
  console.error('Fluxmail MCP server running on stdio');
260
671
  warnLicense(ctx.db);
261
672
  const pending = scopedService.listScheduled().filter((send) => send.status === 'pending').length;
@@ -267,10 +678,6 @@ export function createCliProgram() {
267
678
  .command('add')
268
679
  .argument('<provider>', 'Email provider: gmail, outlook, or imap')
269
680
  .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
681
  .option('--local', 'Use the local browser callback for OAuth')
275
682
  .option('--hosted', 'Use FLUXMAIL_PUBLIC_URL for the OAuth callback')
276
683
  .option('--email <address>', 'Mailbox address (required for IMAP)')
@@ -295,102 +702,149 @@ export function createCliProgram() {
295
702
  .action(async (providerInput, opts, command) => {
296
703
  const provider = providerInput === 'microsoft' ? 'outlook' : providerInput;
297
704
  if (provider !== 'gmail' && provider !== 'outlook' && provider !== 'imap') {
705
+ finishCliOperation(program, 'error', 'invalid_request');
298
706
  console.error(`Provider "${providerInput}" is not supported. Available: gmail, outlook, imap`);
299
707
  process.exitCode = 1;
300
708
  return;
301
709
  }
302
- const ctx = createContext();
303
- warnLicense(ctx.db);
710
+ const selected = resolveInstance(selectedInstance());
711
+ if (!selected.token) {
712
+ console.error(`Error: Log in to instance "${selected.name}" before connecting a mailbox.`);
713
+ process.exitCode = 1;
714
+ return;
715
+ }
304
716
  try {
305
717
  validateAccountConnectionFlags(provider, opts);
306
- if (opts.owner && opts.member) {
307
- throw new EmailError('invalid_request', '--owner and --member cannot be combined. Use --owner.');
718
+ }
719
+ catch (err) {
720
+ logActiveCliError(program, err);
721
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
722
+ process.exitCode = 1;
723
+ return;
724
+ }
725
+ let useControlPlane = selected.profile.kind === 'remote' || provider === 'imap';
726
+ if (selected.profile.kind === 'local' && provider !== 'imap') {
727
+ try {
728
+ useControlPlane = selectGmailConnectionMode(createContext().config, opts) === 'hosted';
729
+ }
730
+ catch (err) {
731
+ logActiveCliError(program, err);
732
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
733
+ process.exitCode = 1;
734
+ return;
735
+ }
736
+ }
737
+ if (useControlPlane) {
738
+ try {
739
+ if (selected.profile.kind === 'remote' && opts.local) {
740
+ throw new EmailError('invalid_request', '--local is only available for the local instance.');
741
+ }
742
+ let request = {
743
+ provider,
744
+ ...(opts.reauthorize ? { reauthorizeAccountId: opts.reauthorize } : {}),
745
+ };
746
+ if (provider === 'imap') {
747
+ const email = opts.email?.trim();
748
+ if (!email || !email.includes('@'))
749
+ throw new EmailError('invalid_request', '--email must be a mailbox address.');
750
+ if (!opts.imapHost)
751
+ throw new EmailError('invalid_request', '--imap-host is required for IMAP accounts.');
752
+ if (!opts.smtpHost)
753
+ throw new EmailError('invalid_request', '--smtp-host is required for IMAP accounts.');
754
+ const imapPassword = await accountSecret(opts.imapPasswordEnv, 'IMAP password');
755
+ const smtpPassword = opts.smtpPasswordEnv
756
+ ? await accountSecret(opts.smtpPasswordEnv, 'SMTP password')
757
+ : imapPassword;
758
+ const imapUser = opts.imapUser ?? email;
759
+ request = {
760
+ ...request,
761
+ email,
762
+ ...(opts.displayName ? { displayName: opts.displayName } : {}),
763
+ imap: {
764
+ host: opts.imapHost,
765
+ port: connectionPort(opts.imapPort, '--imap-port'),
766
+ security: connectionSecurity(opts.imapSecurity, '--imap-security'),
767
+ user: imapUser,
768
+ password: imapPassword,
769
+ },
770
+ smtp: {
771
+ host: opts.smtpHost,
772
+ port: connectionPort(opts.smtpPort, '--smtp-port'),
773
+ security: connectionSecurity(opts.smtpSecurity, '--smtp-security'),
774
+ user: opts.smtpUser ?? imapUser,
775
+ password: smtpPassword,
776
+ },
777
+ ...(command.getOptionValueSource('saveSent') !== 'default' ? { saveSent: opts.saveSent } : {}),
778
+ folderOverrides: {
779
+ ...(opts.sentFolder ? { sent: opts.sentFolder } : {}),
780
+ ...(opts.draftsFolder ? { drafts: opts.draftsFolder } : {}),
781
+ ...(opts.trashFolder ? { trash: opts.trashFolder } : {}),
782
+ ...(opts.archiveFolder ? { archive: opts.archiveFolder } : {}),
783
+ ...(opts.spamFolder ? { spam: opts.spamFolder } : {}),
784
+ },
785
+ };
786
+ }
787
+ const result = await instanceClient(selected.name).json('/api/v1/accounts/connections', {
788
+ method: 'POST',
789
+ headers: { 'content-type': 'application/json' },
790
+ body: JSON.stringify(request),
791
+ });
792
+ if (result.connectionUrl)
793
+ console.log(`Open this URL in your browser:\n\n ${result.connectionUrl}\n`);
794
+ if (result.account) {
795
+ console.log(`Connected ${result.account.email} (account id: ${result.account.id})`);
796
+ for (const warning of result.warnings ?? [])
797
+ console.log(`Warning: ${warning.message}.`);
798
+ }
308
799
  }
309
- if (opts.shared && opts.shareWith.length) {
310
- throw new EmailError('invalid_request', '--shared and --share-with cannot be combined.');
800
+ catch (err) {
801
+ logActiveCliError(program, err);
802
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
803
+ process.exitCode = 1;
311
804
  }
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".');
805
+ return;
806
+ }
807
+ const ctx = createContext();
808
+ warnLicense(ctx.db);
809
+ try {
810
+ const principal = authenticateBearer(ctx.db, selected.token);
811
+ if (!principal || principal.kind !== 'session') {
812
+ throw new EmailError('permission_denied', 'The local CLI session has expired. Run "fluxmail login --instance local".');
317
813
  }
318
- // Resolve before the OAuth flow so a typo fails fast.
319
- const owner = ownerRef ? findMember(ctx.db, ownerRef) : undefined;
814
+ const owner = findMember(ctx.db, principal.memberId);
320
815
  const existing = opts.reauthorize ? ctx.registry.getAccount(opts.reauthorize) : undefined;
816
+ if (existing && !canManageOwnedAccount(principal, existing)) {
817
+ throw new EmailError('permission_denied', 'Only the mailbox owner or an administrator can reauthorize it.');
818
+ }
321
819
  if (existing && existing.provider !== provider) {
322
820
  throw new EmailError('invalid_request', `Account ${existing.id} uses ${existing.provider}, not ${provider}.`);
323
821
  }
324
822
  if (!existing)
325
823
  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}.`);
824
+ const access = { sharedWithAll: false, grantedMemberIds: [] };
825
+ const auditConnection = (operation, accountId) => {
826
+ try {
827
+ recordAdminAuditEvent(ctx.db, {
828
+ operation,
829
+ outcome: 'success',
830
+ actorMemberId: principal.memberId,
831
+ actorSessionId: principal.sessionId,
832
+ ...(accountId ? { resourceType: 'account', resourceId: accountId } : {}),
833
+ });
345
834
  }
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
- }
835
+ catch {
836
+ // Audit storage must not replace the connection result.
837
+ }
838
+ };
386
839
  if (provider === 'outlook') {
387
840
  const connectionMode = selectGmailConnectionMode(ctx.config, opts);
388
841
  if (connectionMode === 'hosted') {
389
842
  const { connectionUrl } = prepareHostedOutlookConnection(ctx.db, ctx.config, {
390
- ...(owner ? { memberId: owner.id } : {}),
843
+ ownerMemberId: owner.id,
391
844
  ...(existing ? { reauthorizeAccountId: existing.id } : {}),
392
- ...(!existing ? { sharingMode: access.sharingMode, sharedMemberIds: access.sharedMemberIds } : {}),
845
+ ...(!existing ? access : {}),
393
846
  });
847
+ auditConnection('account.connection.prepare', existing?.id);
394
848
  console.log('\nOpen this URL in your browser to connect Outlook:\n');
395
849
  console.log(` ${connectionUrl}\n`);
396
850
  console.log('This link expires in 10 minutes and can only be used once.');
@@ -401,23 +855,31 @@ export function createCliProgram() {
401
855
  console.log(` ${url}\n`);
402
856
  console.log('Waiting for Microsoft to redirect back...');
403
857
  }, (result) => {
858
+ const duplicate = ctx.registry
859
+ .listAccounts()
860
+ .find((candidate) => candidate.email.toLowerCase() === result.email.toLowerCase());
861
+ if (duplicate && !canManageOwnedAccount(principal, duplicate)) {
862
+ throw new EmailError('permission_denied', 'Only the mailbox owner or an administrator can reauthorize it.');
863
+ }
404
864
  if (existing && result.email.toLowerCase() !== existing.email.toLowerCase()) {
405
865
  throw new EmailError('invalid_request', `Microsoft authorized ${result.email}, but account ${existing.id} belongs to ${existing.email}. ` +
406
866
  'Try again and choose the matching Microsoft account.');
407
867
  }
408
- return ctx.registry.addOutlookAccount(result.email, result.credentials, result.displayName, owner?.id, existing?.id, access);
868
+ return ctx.registry.addOutlookAccount(result.email, result.credentials, result.displayName, owner.id, existing?.id, access);
409
869
  });
870
+ auditConnection('account.connection.complete', account.id);
410
871
  console.log(`\nConnected ${account.email} (account id: ${account.id})` +
411
- (owner && account.ownerId === owner.id ? ` for member ${owner.name}` : ''));
872
+ (account.ownerMemberId === owner.id ? ` for member ${owner.name}` : ''));
412
873
  return;
413
874
  }
414
875
  const gmailConnectionMode = selectGmailConnectionMode(ctx.config, opts);
415
876
  if (gmailConnectionMode === 'hosted') {
416
877
  const { connectionUrl } = prepareHostedGmailConnection(ctx.db, ctx.config, {
417
- ...(owner ? { memberId: owner.id } : {}),
878
+ ownerMemberId: owner.id,
418
879
  ...(existing ? { reauthorizeAccountId: existing.id } : {}),
419
- ...(!existing ? { sharingMode: access.sharingMode, sharedMemberIds: access.sharedMemberIds } : {}),
880
+ ...(!existing ? access : {}),
420
881
  });
882
+ auditConnection('account.connection.prepare', existing?.id);
421
883
  console.log('\nOpen this URL in your browser to connect Gmail:\n');
422
884
  console.log(` ${connectionUrl}\n`);
423
885
  console.log('This link expires in 10 minutes and can only be used once.');
@@ -428,21 +890,29 @@ export function createCliProgram() {
428
890
  console.log(` ${url}\n`);
429
891
  console.log('Waiting for Google to redirect back…');
430
892
  }, (result) => {
893
+ const duplicate = ctx.registry
894
+ .listAccounts()
895
+ .find((candidate) => candidate.email.toLowerCase() === result.email.toLowerCase());
896
+ if (duplicate && !canManageOwnedAccount(principal, duplicate)) {
897
+ throw new EmailError('permission_denied', 'Only the mailbox owner or an administrator can reauthorize it.');
898
+ }
431
899
  if (existing && result.email !== existing.email) {
432
900
  throw new EmailError('invalid_request', `Google authorized ${result.email}, but account ${existing.id} belongs to ${existing.email}. ` +
433
901
  'Try again and choose the matching Google account.');
434
902
  }
435
- return ctx.registry.addGmailAccount(result.email, result.tokens, result.displayName, owner?.id, access);
903
+ return ctx.registry.addGmailAccount(result.email, result.tokens, result.displayName, owner.id, access, result.oauthClient);
436
904
  });
905
+ auditConnection('account.connection.complete', account.id);
437
906
  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) {
907
+ (account.ownerMemberId === owner.id ? ` for member ${owner.name}` : ''));
908
+ if (account.ownerMemberId !== owner.id) {
440
909
  // The mailbox was already connected; re-auth keeps its existing owner.
441
910
  console.log(`${account.email} was already connected, so its ownership is unchanged. ` +
442
911
  `To assign it to ${owner.name}, run "fluxmail accounts assign ${account.id} --owner ${owner.id}".`);
443
912
  }
444
913
  }
445
914
  catch (err) {
915
+ finishCliOperation(program, 'error', isEmailError(err) ? err.code : 'internal', err);
446
916
  console.error(`\nError: ${err instanceof Error ? err.message : String(err)}`);
447
917
  process.exitCode = 1;
448
918
  }
@@ -457,8 +927,6 @@ export function createCliProgram() {
457
927
  .option('--spam-folder <path>', 'Spam path, or auto to clear the override')
458
928
  .description('Set special folder paths for an IMAP account')
459
929
  .action(async (accountId, opts) => {
460
- const ctx = createContext();
461
- warnLicense(ctx.db);
462
930
  try {
463
931
  const changes = [
464
932
  ['sent', opts.sentFolder],
@@ -470,41 +938,22 @@ export function createCliProgram() {
470
938
  if (!changes.some(([, value]) => value !== undefined)) {
471
939
  throw new EmailError('invalid_request', 'Pass at least one folder option.');
472
940
  }
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?.();
941
+ const patch = {};
942
+ for (const [role, value] of changes) {
943
+ if (value !== undefined)
944
+ patch[role] = value === 'auto' ? null : value;
505
945
  }
946
+ const result = await instanceClient(selectedInstance()).json(`/api/v1/accounts/${encodeURIComponent(accountId)}/imap/folders`, {
947
+ method: 'PATCH',
948
+ headers: { 'content-type': 'application/json' },
949
+ body: JSON.stringify(patch),
950
+ });
951
+ console.log(`Updated folder settings for ${accountId}.`);
952
+ for (const warning of result.warnings)
953
+ console.log(`Warning: ${warning.message}.`);
506
954
  }
507
955
  catch (err) {
956
+ logActiveCliError(program, err);
508
957
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
509
958
  process.exitCode = 1;
510
959
  }
@@ -512,67 +961,61 @@ export function createCliProgram() {
512
961
  accounts
513
962
  .command('list')
514
963
  .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;
964
+ .action(async () => {
965
+ try {
966
+ const all = await instanceClient(selectedInstance()).json('/api/v1/accounts');
967
+ if (!all.length) {
968
+ console.log('No accounts connected. Run "fluxmail accounts add <provider>".');
969
+ return;
970
+ }
971
+ for (const a of all) {
972
+ const access = a.sharedWithAll
973
+ ? 'all'
974
+ : a.grantedMemberIds.length
975
+ ? `selected:${a.grantedMemberIds.join(',')}`
976
+ : 'owner-only';
977
+ console.log(`${a.id} ${a.provider} ${a.email} [${a.status}] owner=${a.ownerMemberId} access=${access}`);
978
+ }
522
979
  }
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}`);
980
+ catch (err) {
981
+ logActiveCliError(program, err);
982
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
983
+ process.exitCode = 1;
530
984
  }
531
985
  });
532
986
  accounts
533
987
  .command('remove')
534
988
  .argument('<accountId>')
535
989
  .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}`);
990
+ .action(async (accountId) => {
991
+ try {
992
+ await instanceClient(selectedInstance()).json(`/api/v1/accounts/${encodeURIComponent(accountId)}/connection`, {
993
+ method: 'DELETE',
994
+ });
995
+ console.log(`Removed ${accountId}`);
996
+ }
997
+ catch (err) {
998
+ logActiveCliError(program, err);
999
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1000
+ process.exitCode = 1;
1001
+ }
541
1002
  });
542
1003
  accounts
543
1004
  .command('assign')
544
1005
  .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')
1006
+ .requiredOption('--owner <member>', 'Member id or email to own the mailbox')
548
1007
  .description('Change mailbox ownership')
549
- .action((accountId, opts) => {
550
- const ctx = createContext();
551
- warnLicense(ctx.db);
1008
+ .action(async (accountId, opts) => {
552
1009
  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}.`);
1010
+ const account = await instanceClient(selectedInstance()).json(`/api/v1/admin/accounts/${encodeURIComponent(accountId)}`, {
1011
+ method: 'PATCH',
1012
+ headers: { 'content-type': 'application/json' },
1013
+ body: JSON.stringify({ ownerMemberId: opts.owner }),
1014
+ });
1015
+ console.log(`${account.email} is now owned by ${account.ownerMemberId}.`);
574
1016
  }
575
1017
  catch (err) {
1018
+ logActiveCliError(program, err);
576
1019
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
577
1020
  process.exitCode = 1;
578
1021
  }
@@ -584,22 +1027,21 @@ export function createCliProgram() {
584
1027
  .option('--shared', 'Share the mailbox with every member')
585
1028
  .option('--share-with <member>', 'Replace selected access with this member; repeat as needed', collectOption, [])
586
1029
  .description('Set who can access a mailbox')
587
- .action((accountId, opts) => {
588
- const ctx = createContext();
589
- warnLicense(ctx.db);
1030
+ .action(async (accountId, opts) => {
590
1031
  try {
591
1032
  const choices = Number(Boolean(opts.ownerOnly)) + Number(Boolean(opts.shared)) + Number(opts.shareWith.length > 0);
592
1033
  if (choices !== 1) {
593
1034
  throw new EmailError('invalid_request', 'Pass exactly one of --owner-only, --shared, or one or more --share-with options.');
594
1035
  }
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,
1036
+ const account = await instanceClient(selectedInstance()).json(`/api/v1/admin/accounts/${encodeURIComponent(accountId)}`, {
1037
+ method: 'PATCH',
1038
+ headers: { 'content-type': 'application/json' },
1039
+ body: JSON.stringify({ sharedWithAll: opts.shared === true, grantedMemberIds: opts.shareWith }),
599
1040
  });
600
- console.log(`Updated access for ${account.email}: ${account.sharingMode}.`);
1041
+ console.log(`Updated access for ${account.email}: ${account.sharedWithAll ? 'all members' : `${account.grantedMemberIds.length} explicit grant(s)`}.`);
601
1042
  }
602
1043
  catch (err) {
1044
+ logActiveCliError(program, err);
603
1045
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
604
1046
  process.exitCode = 1;
605
1047
  }
@@ -608,24 +1050,25 @@ export function createCliProgram() {
608
1050
  membersCmd
609
1051
  .command('add')
610
1052
  .requiredOption('--name <name>', 'Member name')
611
- .option('--email <email>', 'Member email, usable as a shorthand in --member flags')
1053
+ .requiredOption('--email <email>', 'Member login email')
612
1054
  .option('--role <role>', 'Member role: admin or member')
613
1055
  .description('Add a member (subject to the plan seat limit)')
614
- .action((opts) => {
615
- const ctx = createContext();
616
- warnLicense(ctx.db);
1056
+ .action(async (opts) => {
617
1057
  try {
618
1058
  if (opts.role && opts.role !== 'admin' && opts.role !== 'member') {
619
1059
  throw new EmailError('invalid_request', '--role must be "admin" or "member".');
620
1060
  }
621
- const member = addMember(ctx.db, {
622
- name: opts.name,
623
- ...(opts.email ? { email: opts.email } : {}),
624
- ...(opts.role ? { role: opts.role } : {}),
1061
+ const member = await instanceClient(selectedInstance()).json('/api/v1/admin/members', {
1062
+ method: 'POST',
1063
+ headers: { 'content-type': 'application/json' },
1064
+ body: JSON.stringify({ name: opts.name, email: opts.email, role: opts.role ?? 'member' }),
625
1065
  });
626
- console.log(`Added member ${member.name} (id: ${member.id}, role: ${member.role})`);
1066
+ console.log(`Added ${member.name} (id: ${member.id}, role: ${member.role}).`);
1067
+ console.log(`Enrollment code (shown once): ${member.invitation.token}`);
1068
+ console.log(`Expires: ${new Date(member.invitation.expiresAt).toISOString()}`);
627
1069
  }
628
1070
  catch (err) {
1071
+ logActiveCliError(program, err);
629
1072
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
630
1073
  process.exitCode = 1;
631
1074
  }
@@ -633,30 +1076,36 @@ export function createCliProgram() {
633
1076
  membersCmd
634
1077
  .command('list')
635
1078
  .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;
1079
+ .action(async () => {
1080
+ try {
1081
+ const all = await instanceClient(selectedInstance()).json('/api/v1/admin/members');
1082
+ if (!all.length) {
1083
+ console.log('No members. Run "fluxmail members add --name <name>".');
1084
+ return;
1085
+ }
1086
+ for (const m of all) {
1087
+ console.log(`${m.id} ${m.name} role=${m.role} status=${m.status} email=${m.email ?? '-'} mailboxes=${m.accountCount} keys=${m.apiKeyCount}`);
1088
+ }
643
1089
  }
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}`);
1090
+ catch (err) {
1091
+ logActiveCliError(program, err);
1092
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1093
+ process.exitCode = 1;
646
1094
  }
647
1095
  });
648
1096
  membersCmd
649
1097
  .command('remove')
650
1098
  .argument('<memberId>')
651
1099
  .description('Remove a member after reassigning or removing their mailboxes')
652
- .action((memberId) => {
653
- const ctx = createContext();
654
- warnLicense(ctx.db);
1100
+ .action(async (memberId) => {
655
1101
  try {
656
- const { name, revokedApiKeys } = removeMember(ctx.db, memberId);
657
- console.log(`Removed ${name} and revoked ${revokedApiKeys} API key(s).`);
1102
+ await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberId)}`, {
1103
+ method: 'DELETE',
1104
+ });
1105
+ console.log(`Removed ${memberId}.`);
658
1106
  }
659
1107
  catch (err) {
1108
+ logActiveCliError(program, err);
660
1109
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
661
1110
  process.exitCode = 1;
662
1111
  }
@@ -666,17 +1115,97 @@ export function createCliProgram() {
666
1115
  .argument('<member>', 'Member id or email')
667
1116
  .argument('<role>', 'admin or member')
668
1117
  .description('Change a member role')
669
- .action((memberRef, role) => {
670
- const ctx = createContext();
671
- warnLicense(ctx.db);
1118
+ .action(async (memberRef, role) => {
672
1119
  try {
673
1120
  if (role !== 'admin' && role !== 'member') {
674
1121
  throw new EmailError('invalid_request', 'Role must be "admin" or "member".');
675
1122
  }
676
- const member = setMemberRole(ctx.db, memberRef, role);
1123
+ const member = await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberRef)}`, {
1124
+ method: 'PATCH',
1125
+ headers: { 'content-type': 'application/json' },
1126
+ body: JSON.stringify({ role }),
1127
+ });
677
1128
  console.log(`${member.name} now has the ${member.role} role.`);
678
1129
  }
679
1130
  catch (err) {
1131
+ logActiveCliError(program, err);
1132
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1133
+ process.exitCode = 1;
1134
+ }
1135
+ });
1136
+ membersCmd
1137
+ .command('status')
1138
+ .argument('<member>', 'Member id or email')
1139
+ .argument('<status>', 'active or suspended')
1140
+ .description('Activate or suspend a member')
1141
+ .action(async (memberRef, status) => {
1142
+ try {
1143
+ if (status !== 'active' && status !== 'suspended') {
1144
+ throw new EmailError('invalid_request', 'Status must be "active" or "suspended".');
1145
+ }
1146
+ const member = await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberRef)}`, {
1147
+ method: 'PATCH',
1148
+ headers: { 'content-type': 'application/json' },
1149
+ body: JSON.stringify({ status }),
1150
+ });
1151
+ console.log(`${member.name} is now ${member.status}.`);
1152
+ }
1153
+ catch (err) {
1154
+ logActiveCliError(program, err);
1155
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1156
+ process.exitCode = 1;
1157
+ }
1158
+ });
1159
+ for (const [commandName, endpoint, label] of [
1160
+ ['invite', 'invitation', 'Enrollment code'],
1161
+ ['password-reset', 'password-reset', 'Password reset code'],
1162
+ ]) {
1163
+ membersCmd
1164
+ .command(commandName)
1165
+ .argument('<member>', 'Member id or email')
1166
+ .description(commandName === 'invite' ? 'Issue a new enrollment code' : 'Issue a password reset code')
1167
+ .action(async (memberRef) => {
1168
+ try {
1169
+ const token = await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberRef)}/${endpoint}`, { method: 'POST' });
1170
+ console.log(`${label} (shown once): ${token.token}`);
1171
+ console.log(`Expires: ${new Date(token.expiresAt).toISOString()}`);
1172
+ }
1173
+ catch (err) {
1174
+ logActiveCliError(program, err);
1175
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1176
+ process.exitCode = 1;
1177
+ }
1178
+ });
1179
+ }
1180
+ membersCmd
1181
+ .command('sessions')
1182
+ .argument('<member>', 'Member id or email')
1183
+ .description('List sessions for a member')
1184
+ .action(async (memberRef) => {
1185
+ try {
1186
+ const sessions = await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberRef)}/sessions`);
1187
+ for (const session of sessions) {
1188
+ console.log(`${session.id} ${session.deviceName} expires=${new Date(session.expiresAt).toISOString()}`);
1189
+ }
1190
+ }
1191
+ catch (err) {
1192
+ logActiveCliError(program, err);
1193
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1194
+ process.exitCode = 1;
1195
+ }
1196
+ });
1197
+ membersCmd
1198
+ .command('revoke-session')
1199
+ .argument('<member>', 'Member id or email')
1200
+ .argument('<sessionId>')
1201
+ .description('Revoke a member session')
1202
+ .action(async (memberRef, sessionId) => {
1203
+ try {
1204
+ await instanceClient(selectedInstance()).json(`/api/v1/admin/members/${encodeURIComponent(memberRef)}/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
1205
+ console.log(`Revoked ${sessionId}.`);
1206
+ }
1207
+ catch (err) {
1208
+ logActiveCliError(program, err);
680
1209
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
681
1210
  process.exitCode = 1;
682
1211
  }
@@ -692,27 +1221,48 @@ export function createCliProgram() {
692
1221
  apikey
693
1222
  .command('create')
694
1223
  .requiredOption('--name <name>', 'Human-readable key name')
695
- .requiredOption('--member <member>', 'Member (id or email) the key is issued to')
1224
+ .option('--member <member>', 'Admin only: issue the key to another member')
696
1225
  .option('--account <account>', 'Limit the key to one mailbox; repeat as needed', collectOption, [])
697
1226
  .option('--profile <profile>', `Tool profile: ${NAMED_PERMISSION_PROFILES.join(', ')}`)
698
1227
  .option('--allow <capability>', 'Allow one capability in a custom policy; repeat as needed', collectOption, [])
699
1228
  .option('--admin <capability>', 'Add one admin capability to a named profile; repeat as needed', collectOption, [])
700
1229
  .description('Create an API key (shown once)')
701
- .action((opts) => {
702
- const ctx = createContext();
703
- warnLicense(ctx.db);
1230
+ .action(async (opts) => {
704
1231
  try {
705
- const member = findMember(ctx.db, opts.member);
706
- const accountIds = accountIdsFromRefs(ctx, opts.account);
707
1232
  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`);
1233
+ const body = {
1234
+ name: opts.name,
1235
+ ...(opts.member ? { member: opts.member } : {}),
1236
+ accounts: opts.account.length ? opts.account : null,
1237
+ ...(permissions.profile === 'custom'
1238
+ ? { customCapabilities: permissions.capabilities }
1239
+ : {
1240
+ permissionProfile: permissions.profile,
1241
+ supplementalCapabilities: permissions.supplementalCapabilities,
1242
+ }),
1243
+ };
1244
+ const created = await instanceClient(selectedInstance()).json(opts.member ? '/api/v1/admin/api-keys' : '/api/v1/me/api-keys', {
1245
+ method: 'POST',
1246
+ headers: { 'content-type': 'application/json' },
1247
+ body: JSON.stringify(opts.member
1248
+ ? body
1249
+ : {
1250
+ name: body.name,
1251
+ accountIds: body.accounts,
1252
+ ...(permissions.profile === 'custom'
1253
+ ? { capabilities: permissions.capabilities }
1254
+ : {
1255
+ permissionProfile: permissions.profile,
1256
+ supplementalCapabilities: permissions.supplementalCapabilities,
1257
+ }),
1258
+ }),
1259
+ });
1260
+ console.log(`Created API key "${created.name}" (id: ${created.id}, profile: ${created.permissionProfile}).\n`);
1261
+ console.log(` ${created.key}\n`);
713
1262
  console.log('Store it now; it cannot be shown again.');
714
1263
  }
715
1264
  catch (err) {
1265
+ logActiveCliError(program, err);
716
1266
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
717
1267
  process.exitCode = 1;
718
1268
  }
@@ -720,23 +1270,28 @@ export function createCliProgram() {
720
1270
  apikey
721
1271
  .command('list')
722
1272
  .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;
1273
+ .action(async () => {
1274
+ try {
1275
+ const client = instanceClient(selectedInstance());
1276
+ const me = await client.json('/api/v1/me');
1277
+ const keys = await client.json(me.role === 'admin' ? '/api/v1/admin/api-keys' : '/api/v1/me/api-keys');
1278
+ if (!keys.length) {
1279
+ console.log('No API keys. Run "fluxmail apikey create --name <name>".');
1280
+ return;
1281
+ }
1282
+ for (const k of keys) {
1283
+ const lastUsed = k.lastUsedAt ? new Date(k.lastUsedAt).toISOString() : 'never';
1284
+ const accountScope = k.accountIds === null ? 'all granted' : k.accountIds.join(',') || 'none';
1285
+ console.log(`${k.id} ${k.name} member=${k.memberId} accounts=${accountScope} profile=${k.permissionProfile}` +
1286
+ (k.permissionProfile === 'custom' ? ` allows=${k.capabilities.join(',')}` : '') +
1287
+ (k.supplementalCapabilities.length ? ` admin=${k.supplementalCapabilities.join(',')}` : '') +
1288
+ ` created=${new Date(k.createdAt).toISOString()} lastUsed=${lastUsed}`);
1289
+ }
730
1290
  }
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}`);
1291
+ catch (err) {
1292
+ logActiveCliError(program, err);
1293
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1294
+ process.exitCode = 1;
740
1295
  }
741
1296
  });
742
1297
  apikey
@@ -745,22 +1300,23 @@ export function createCliProgram() {
745
1300
  .option('--account <account>', 'Replace the allowlist with this mailbox; repeat as needed', collectOption, [])
746
1301
  .option('--all-accounts', 'Clear the allowlist')
747
1302
  .description('Replace or clear an API key mailbox allowlist')
748
- .action((keyId, opts) => {
749
- const ctx = createContext();
750
- warnLicense(ctx.db);
1303
+ .action(async (keyId, opts) => {
751
1304
  try {
752
1305
  if (Boolean(opts.allAccounts) === Boolean(opts.account.length)) {
753
1306
  throw new EmailError('invalid_request', 'Pass --all-accounts or at least one --account.');
754
1307
  }
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
- }
1308
+ const accountIds = opts.allAccounts ? null : opts.account;
1309
+ await instanceClient(selectedInstance()).json(`/api/v1/admin/api-keys/${encodeURIComponent(keyId)}`, {
1310
+ method: 'PATCH',
1311
+ headers: { 'content-type': 'application/json' },
1312
+ body: JSON.stringify({ accounts: accountIds }),
1313
+ });
759
1314
  console.log(accountIds === null
760
1315
  ? `Cleared the mailbox allowlist for ${keyId}.`
761
1316
  : `Updated ${keyId} to ${accountIds.length} mailbox(es).`);
762
1317
  }
763
1318
  catch (err) {
1319
+ logActiveCliError(program, err);
764
1320
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
765
1321
  process.exitCode = 1;
766
1322
  }
@@ -772,25 +1328,30 @@ export function createCliProgram() {
772
1328
  .option('--allow <capability>', 'Allow one capability in a custom policy; repeat as needed', collectOption, [])
773
1329
  .option('--admin <capability>', 'Add one admin capability to a named profile; repeat as needed', collectOption, [])
774
1330
  .description('Change the permissions for an API key')
775
- .action((keyId, opts) => {
776
- const ctx = createContext();
777
- warnLicense(ctx.db);
1331
+ .action(async (keyId, opts) => {
778
1332
  try {
779
- const existing = listApiKeys(ctx.db).find((key) => key.id === keyId);
1333
+ const client = instanceClient(selectedInstance());
1334
+ const existing = (await client.json('/api/v1/admin/api-keys')).find((key) => key.id === keyId);
780
1335
  if (!existing) {
781
1336
  console.error(`No key with id ${keyId}`);
782
1337
  process.exitCode = 1;
783
1338
  return;
784
1339
  }
785
1340
  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
- }
1341
+ await client.json(`/api/v1/admin/api-keys/${encodeURIComponent(keyId)}`, {
1342
+ method: 'PATCH',
1343
+ headers: { 'content-type': 'application/json' },
1344
+ body: JSON.stringify(permissions.profile === 'custom'
1345
+ ? { customCapabilities: permissions.capabilities }
1346
+ : {
1347
+ permissionProfile: permissions.profile,
1348
+ supplementalCapabilities: permissions.supplementalCapabilities,
1349
+ }),
1350
+ });
791
1351
  console.log(`Updated ${keyId} to profile ${permissions.profile}`);
792
1352
  }
793
1353
  catch (err) {
1354
+ logActiveCliError(program, err);
794
1355
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
795
1356
  process.exitCode = 1;
796
1357
  }
@@ -799,151 +1360,267 @@ export function createCliProgram() {
799
1360
  .command('revoke')
800
1361
  .argument('<keyId>')
801
1362
  .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}`);
1363
+ .action(async (keyId) => {
1364
+ try {
1365
+ const client = instanceClient(selectedInstance());
1366
+ const me = await client.json('/api/v1/me');
1367
+ const path = me.role === 'admin'
1368
+ ? `/api/v1/admin/api-keys/${encodeURIComponent(keyId)}`
1369
+ : `/api/v1/me/api-keys/${encodeURIComponent(keyId)}`;
1370
+ await client.json(path, { method: 'DELETE' });
1371
+ console.log(`Revoked ${keyId}`);
1372
+ }
1373
+ catch (err) {
1374
+ logActiveCliError(program, err);
1375
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1376
+ process.exitCode = 1;
1377
+ }
806
1378
  });
807
1379
  const license = program.command('license').description('Manage the paid-tier license');
808
1380
  license
809
1381
  .command('activate')
810
- .argument('<key>', 'License key (fluxmail_lic_…), shown once at purchase')
1382
+ .argument('[key]', 'Deprecated: license key shown once at purchase')
1383
+ .option('--key-file <path>', 'Read the license key from a file, or use - for stdin')
811
1384
  .description('Store a license key and validate it with the license server')
812
- .action(async (key) => {
813
- if (!LICENSE_KEY_PATTERN.test(key)) {
814
- console.error('That does not look like a Fluxmail license key (expected "fluxmail_lic_" followed by 40 hex characters).');
1385
+ .action(async (deprecatedKey, options) => {
1386
+ if (deprecatedKey && options.keyFile) {
1387
+ console.error('Error: The deprecated positional key cannot be combined with --key-file.');
815
1388
  process.exitCode = 1;
816
1389
  return;
817
1390
  }
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;
1391
+ if (deprecatedKey) {
1392
+ console.error('Warning: Passing a license key as an argument is deprecated. Use --key-file or hidden input.');
824
1393
  }
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.`);
1394
+ const key = deprecatedKey ?? (await secretInput(options.keyFile, 'License key'));
1395
+ if (!LICENSE_KEY_PATTERN.test(key)) {
1396
+ console.error('That does not look like a Fluxmail license key (expected "fluxmail_lic_" followed by 40 hex characters).');
1397
+ process.exitCode = 1;
836
1398
  return;
837
1399
  }
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.');
1400
+ try {
1401
+ const result = await instanceClient(selectedInstance()).json('/api/v1/admin/license/activate', {
1402
+ method: 'POST',
1403
+ headers: { 'content-type': 'application/json' },
1404
+ body: JSON.stringify({ licenseKey: key }),
1405
+ });
1406
+ if (result.lease) {
1407
+ console.log(`License activated: ${result.lease.plan} plan, up to ${result.lease.maxAccounts} mailboxes and ` +
1408
+ `${result.lease.maxMembers} member${result.lease.maxMembers === 1 ? '' : 's'}.`);
1409
+ console.log(`The lease is valid until ${result.lease.expiresAt} and renews automatically while the server runs.`);
1410
+ }
1411
+ else {
1412
+ console.log('The license key was saved. Validation will retry when the instance can reach the license server.');
1413
+ }
841
1414
  }
842
- else {
1415
+ catch (err) {
1416
+ logActiveCliError(program, err);
1417
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
843
1418
  process.exitCode = 1;
844
1419
  }
845
1420
  });
846
1421
  license
847
1422
  .command('status')
848
1423
  .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
- }
1424
+ .action(async () => {
1425
+ try {
1426
+ const status = await instanceClient(selectedInstance()).json('/api/v1/admin/license');
1427
+ console.log(status.configured
1428
+ ? `License configured from ${status.source}.`
1429
+ : 'No license key configured. Run "fluxmail license activate" after purchasing one.');
1430
+ console.log(`Current plan: ${planLine(status.entitlements)}`);
1431
+ console.log(`Usage: ${status.usage.accounts} mailbox(es), ${status.usage.members} member(s)` +
1432
+ (status.usage.overQuota ? ' (over plan limits)' : ''));
1433
+ if (status.lastValidatedAt)
1434
+ console.log(`Last validated: ${status.lastValidatedAt}`);
1435
+ if (status.warning)
1436
+ console.log(`Warning: ${status.warning}`);
869
1437
  }
870
- else {
871
- console.log('No cached lease.');
1438
+ catch (err) {
1439
+ logActiveCliError(program, err);
1440
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1441
+ process.exitCode = 1;
872
1442
  }
873
- console.log(`Current plan: ${planLine(getEntitlements(ctx.db))}`);
874
- warnLicense(ctx.db, console.log);
875
1443
  });
876
1444
  license
877
1445
  .command('deactivate')
878
1446
  .description('Release the license from this instance and remove the stored key and cached lease')
879
1447
  .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.');
896
- }
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.');
904
- }
905
- });
906
- const configCmd = program
907
- .command('config')
908
- .description('Persistent settings stored in the data dir, usable from any directory');
1448
+ try {
1449
+ const result = await instanceClient(selectedInstance()).json('/api/v1/admin/license', { method: 'DELETE' });
1450
+ console.log(result.released
1451
+ ? 'Released the license from this instance.'
1452
+ : 'The local license was removed. The remote binding, if any, will expire automatically.');
1453
+ console.log('This instance is back to Personal plan limits.');
1454
+ }
1455
+ catch (err) {
1456
+ logActiveCliError(program, err);
1457
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1458
+ process.exitCode = 1;
1459
+ }
1460
+ });
1461
+ const configCmd = program.command('config').description('Inspect and initialize deployment configuration');
909
1462
  configCmd
910
- .command('set')
911
- .argument('<key>', 'Setting name, e.g. GOOGLE_CLIENT_ID')
912
- .argument('<value>')
913
- .description('Store a setting (shell env vars and local .env files still take precedence)')
914
- .action((key, value) => {
1463
+ .command('init')
1464
+ .description('Create config.toml in the Fluxmail data directory')
1465
+ .action(() => {
915
1466
  try {
916
1467
  const dataDir = resolveDataDir();
917
- setStoredConfig(dataDir, key, value);
918
- console.log(`Set ${key} in ${configFilePath(dataDir)}`);
1468
+ const file = configTomlPath(dataDir);
1469
+ writeDeploymentConfig(file, deploymentToml(), { exclusive: true });
1470
+ console.log(`Created ${file}`);
919
1471
  }
920
1472
  catch (err) {
1473
+ logActiveCliError(program, err);
921
1474
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
922
1475
  process.exitCode = 1;
923
1476
  }
924
1477
  });
925
1478
  configCmd
926
- .command('unset')
927
- .argument('<key>')
928
- .description('Remove a stored setting')
929
- .action((key) => {
930
- const dataDir = resolveDataDir();
931
- console.log(unsetStoredConfig(dataDir, key) ? `Removed ${key}` : `${key} is not set`);
1479
+ .command('show')
1480
+ .description('Show effective configuration, sources, paths, and restart requirements')
1481
+ .action(() => {
1482
+ const deployment = resolveStoreLocation().deployment;
1483
+ const compatibility = inspectStoreCompatibility(deployment.dbPath, deployment.dataDir);
1484
+ const canReadInstanceSettings = compatibility.storeFormat >= MIN_SUPPORTED_STORE_FORMAT &&
1485
+ compatibility.storeFormat <= compatibility.maximumSupportedFormat;
1486
+ const db = canReadInstanceSettings && deployment.dbPath !== ':memory:'
1487
+ ? openReadonlyDb(deployment.dbPath)
1488
+ : openDb(':memory:', { backupBeforeMigration: false });
1489
+ try {
1490
+ const configuration = createConfigurationService(deployment, db);
1491
+ const config = configuration.config;
1492
+ console.log(`Data directory: ${deployment.dataDir} [${deployment.sources.dataDir}]`);
1493
+ console.log(`Configuration file: ${deployment.configFile}${existsSync(deployment.configFile) ? '' : ' (not created)'}`);
1494
+ console.log(`Database: ${deployment.dbPath} [${deployment.sources.dbPath}] restart required`);
1495
+ console.log(`Server port: ${deployment.port} [${deployment.sources.port}] restart required`);
1496
+ console.log(`Public URL: ${deployment.publicUrl} [${deployment.sources.publicUrl}] restart required`);
1497
+ console.log(`Trust proxy: ${deployment.trustProxy} [${deployment.sources.trustProxy}] restart required`);
1498
+ console.log(`Local OAuth host: ${deployment.oauthHost} [${deployment.sources.oauthHost}] restart required`);
1499
+ console.log(`Local OAuth port: ${deployment.oauthPort} [${deployment.sources.oauthPort}] restart required`);
1500
+ console.log(`Maximum attachment: ${deployment.maxAttachmentBytes / (1024 * 1024)} MB [${deployment.sources.maxAttachmentBytes}] restart required`);
1501
+ console.log(`Encryption key: ${deployment.encryptionKey.length === 32 ? 'configured' : 'not configured'} [${deployment.sources.encryptionKey}]`);
1502
+ const oauth = configuration.oauthStatus();
1503
+ console.log(`Google OAuth: configured [${oauth.google.source}]`);
1504
+ console.log(`Outlook OAuth: ${oauth.outlook.clientId ? 'configured' : 'not configured'} [${oauth.outlook.source ?? 'none'}]`);
1505
+ console.log(`License: ${config.licenseKey ? 'configured' : 'not configured'} [${configuration.licenseSource() ?? 'none'}]`);
1506
+ }
1507
+ finally {
1508
+ db.$client.close();
1509
+ }
932
1510
  });
933
1511
  configCmd
934
- .command('list')
935
- .description('Show stored settings (secret values are masked)')
936
- .action(() => {
937
- const dataDir = resolveDataDir();
938
- const stored = readStoredConfig(dataDir);
939
- const keys = Object.keys(stored);
940
- if (!keys.length) {
941
- console.log(`No stored settings. Run "fluxmail config set <KEY> <value>" (file: ${configFilePath(dataDir)}).`);
1512
+ .command('migrate')
1513
+ .requiredOption('--from <env-file>', 'Read recognized settings from this env file')
1514
+ .option('--dry-run', 'Show recognized setting names without changing Fluxmail')
1515
+ .description('Import settings from an env file without deleting it')
1516
+ .action((options) => {
1517
+ const source = path.resolve(options.from);
1518
+ if (options.dryRun) {
1519
+ const recognized = recognizedMigrationSettings(parseEnvContent(readFileSync(source, 'utf8')));
1520
+ console.log(recognized.length ? recognized.join('\n') : 'No recognized settings found.');
942
1521
  return;
943
1522
  }
944
- for (const key of keys) {
945
- const value = stored[key] ?? '';
946
- console.log(`${key}=${maskStoredConfigValue(key, value)}`);
1523
+ const result = migrateConfigurationFile(source);
1524
+ console.log(`Imported ${result.recognized.length} recognized setting${result.recognized.length === 1 ? '' : 's'} from ${result.source}.`);
1525
+ console.log('Restart Fluxmail to apply deployment configuration changes.');
1526
+ });
1527
+ const oauth = program.command('oauth').description('Manage OAuth applications');
1528
+ oauth
1529
+ .command('status')
1530
+ .description('Show OAuth application status without secrets')
1531
+ .action(async () => {
1532
+ const status = await instanceClient(selectedInstance()).json('/api/v1/admin/oauth-apps');
1533
+ console.log(JSON.stringify(status, null, 2));
1534
+ });
1535
+ const oauthConfigure = oauth.command('configure').description('Configure an OAuth application');
1536
+ oauthConfigure
1537
+ .command('google')
1538
+ .requiredOption('--client-id <id>', 'Google OAuth client ID')
1539
+ .option('--client-secret-file <path>', 'Read the client secret from a file, or use - for stdin')
1540
+ .description('Configure a custom Google OAuth application')
1541
+ .action(async (options) => {
1542
+ const clientSecret = await secretInput(options.clientSecretFile, 'Google client secret');
1543
+ await instanceClient(selectedInstance()).json('/api/v1/admin/oauth-apps/google', {
1544
+ method: 'PUT',
1545
+ headers: { 'content-type': 'application/json' },
1546
+ body: JSON.stringify({ clientId: options.clientId, clientSecret }),
1547
+ });
1548
+ console.log('Google OAuth application configured.');
1549
+ });
1550
+ oauthConfigure
1551
+ .command('outlook')
1552
+ .requiredOption('--client-id <id>', 'Microsoft OAuth client ID')
1553
+ .option('--tenant-id <tenant>', 'Microsoft tenant ID', 'common')
1554
+ .option('--public-client', 'Configure a public client without a secret')
1555
+ .option('--client-secret-file <path>', 'Read the client secret from a file, or use - for stdin')
1556
+ .description('Configure a custom Microsoft OAuth application')
1557
+ .action(async (options) => {
1558
+ if (options.publicClient && options.clientSecretFile) {
1559
+ throw new EmailError('invalid_request', '--public-client cannot be combined with --client-secret-file.');
1560
+ }
1561
+ const clientSecret = options.publicClient
1562
+ ? undefined
1563
+ : await secretInput(options.clientSecretFile, 'Microsoft client secret');
1564
+ await instanceClient(selectedInstance()).json('/api/v1/admin/oauth-apps/outlook', {
1565
+ method: 'PUT',
1566
+ headers: { 'content-type': 'application/json' },
1567
+ body: JSON.stringify({
1568
+ clientId: options.clientId,
1569
+ tenantId: options.tenantId,
1570
+ publicClient: Boolean(options.publicClient),
1571
+ ...(clientSecret ? { clientSecret } : {}),
1572
+ }),
1573
+ });
1574
+ console.log('Microsoft OAuth application configured.');
1575
+ });
1576
+ oauth
1577
+ .command('reset')
1578
+ .argument('<provider>', 'google or outlook')
1579
+ .description('Remove a stored OAuth application')
1580
+ .action(async (provider) => {
1581
+ if (provider !== 'google' && provider !== 'outlook') {
1582
+ throw new EmailError('invalid_request', 'Provider must be google or outlook.');
1583
+ }
1584
+ await instanceClient(selectedInstance()).json(`/api/v1/admin/oauth-apps/${provider}`, { method: 'DELETE' });
1585
+ console.log(`${provider === 'google' ? 'Google' : 'Microsoft'} OAuth application reset.`);
1586
+ });
1587
+ program
1588
+ .command('logs')
1589
+ .description('Show recent local log entries')
1590
+ .option('--tail <count>', 'Number of entries to show, from 1 through 1000', '100')
1591
+ .option('--level <level>', 'Minimum level: info, warn, or error', 'info')
1592
+ .option('--json', 'Print each entry as JSON')
1593
+ .action((opts) => {
1594
+ try {
1595
+ const tail = Number(opts.tail);
1596
+ if (!Number.isInteger(tail) || tail < 1 || tail > 1000) {
1597
+ throw new EmailError('invalid_request', '--tail must be an integer from 1 through 1000.');
1598
+ }
1599
+ if (!['info', 'warn', 'error'].includes(opts.level)) {
1600
+ throw new EmailError('invalid_request', '--level must be info, warn, or error.');
1601
+ }
1602
+ const result = readLocalLogs(resolveDataDir(), {
1603
+ tail,
1604
+ minimumLevel: opts.level,
1605
+ });
1606
+ for (const entry of result.entries) {
1607
+ if (opts.json) {
1608
+ console.log(entry.raw);
1609
+ continue;
1610
+ }
1611
+ const singleLine = escapeLogTextForConsole;
1612
+ const code = entry.record.error?.code ? ` [${singleLine(entry.record.error.code)}]` : '';
1613
+ console.log(`${singleLine(entry.record.timestamp)} ${entry.record.level.toUpperCase()} ${singleLine(entry.record.event)}${code}: ${singleLine(entry.record.message)}`);
1614
+ }
1615
+ if (result.malformedLines > 0) {
1616
+ console.error(`Warning: skipped ${result.malformedLines} malformed local log line${result.malformedLines === 1 ? '' : 's'}.`);
1617
+ }
1618
+ }
1619
+ catch (error) {
1620
+ const code = isEmailError(error) ? error.code : 'internal';
1621
+ finishCliOperation(program, 'error', code, error);
1622
+ console.error(`Error [${code}]: ${error instanceof Error ? error.message : String(error)}`);
1623
+ process.exitCode = 1;
947
1624
  }
948
1625
  });
949
1626
  const telemetryCmd = program.command('telemetry').description('Manage anonymous usage telemetry');
@@ -961,7 +1638,6 @@ export function createCliProgram() {
961
1638
  .action(() => {
962
1639
  const dataDir = resolveDataDir();
963
1640
  setTelemetryEnabled(dataDir, true);
964
- unsetStoredConfig(dataDir, 'FLUXMAIL_TELEMETRY');
965
1641
  console.log('Telemetry enabled. FLUXMAIL_TELEMETRY=0 or DO_NOT_TRACK=1 can still turn it off.');
966
1642
  });
967
1643
  telemetryCmd
@@ -971,24 +1647,41 @@ export function createCliProgram() {
971
1647
  const dataDir = resolveDataDir();
972
1648
  console.log(`Telemetry is ${isTelemetryEnabled(dataDir) ? 'enabled' : 'disabled'}.`);
973
1649
  });
1650
+ registerMailCommands(program, {
1651
+ selectedInstance,
1652
+ selectedAccount,
1653
+ reportError: (error, code) => {
1654
+ finishCliOperation(program, 'error', code, error);
1655
+ console.error(`Error [${code}]: ${error instanceof Error ? error.message : String(error)}`);
1656
+ process.exitCode = 1;
1657
+ },
1658
+ });
974
1659
  program
975
1660
  .command('status')
976
- .description('Show accounts, members, entitlements, and provider availability')
1661
+ .description('Show mailbox and provider status for the selected instance')
977
1662
  .action(async () => {
978
- const ctx = createContext();
979
- warnLicense(ctx.db);
980
- console.log(JSON.stringify(await ctx.service.status(), null, 2));
1663
+ try {
1664
+ const status = await instanceClient(selectedInstance()).json('/api/v1/status');
1665
+ console.log(JSON.stringify(status, null, 2));
1666
+ }
1667
+ catch (err) {
1668
+ logActiveCliError(program, err);
1669
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1670
+ process.exitCode = 1;
1671
+ }
981
1672
  });
982
1673
  return program;
983
1674
  }
984
1675
  export async function runCli(argv = process.argv) {
1676
+ const program = createCliProgram({ updateNotifierFactory: createCliUpdateNotifier });
985
1677
  try {
986
- await createCliProgram().parseAsync([...argv]);
1678
+ await program.parseAsync([...argv]);
987
1679
  }
988
1680
  catch (err) {
1681
+ finishCliOperation(program, 'error', isEmailError(err) ? err.code : 'internal', err);
989
1682
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
990
1683
  process.exitCode = 1;
991
- await shutdownTelemetry();
1684
+ await Promise.all([shutdownTelemetry(), shutdownLogging()]);
992
1685
  }
993
1686
  }
994
1687
  function isEntrypoint() {