fluxmail 0.5.0 → 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 (109) hide show
  1. package/dist/accounts/googleAuth.d.ts +7 -3
  2. package/dist/accounts/googleAuth.d.ts.map +1 -1
  3. package/dist/accounts/googleAuth.js +8 -7
  4. package/dist/accounts/googleAuth.js.map +1 -1
  5. package/dist/accounts/microsoftAuth.d.ts +5 -2
  6. package/dist/accounts/microsoftAuth.d.ts.map +1 -1
  7. package/dist/accounts/microsoftAuth.js +24 -21
  8. package/dist/accounts/microsoftAuth.js.map +1 -1
  9. package/dist/accounts/registry.d.ts +2 -1
  10. package/dist/accounts/registry.d.ts.map +1 -1
  11. package/dist/accounts/registry.js +7 -6
  12. package/dist/accounts/registry.js.map +1 -1
  13. package/dist/auth.d.ts.map +1 -1
  14. package/dist/auth.js +5 -2
  15. package/dist/auth.js.map +1 -1
  16. package/dist/cli.d.ts +8 -0
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +382 -65
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cliInstances.d.ts +14 -0
  21. package/dist/cliInstances.d.ts.map +1 -1
  22. package/dist/cliInstances.js +71 -4
  23. package/dist/cliInstances.js.map +1 -1
  24. package/dist/cliMail.d.ts +9 -0
  25. package/dist/cliMail.d.ts.map +1 -0
  26. package/dist/cliMail.js +450 -0
  27. package/dist/cliMail.js.map +1 -0
  28. package/dist/config.d.ts +89 -118
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +344 -369
  31. package/dist/config.js.map +1 -1
  32. package/dist/configMigration.d.ts +8 -0
  33. package/dist/configMigration.d.ts.map +1 -0
  34. package/dist/configMigration.js +234 -0
  35. package/dist/configMigration.js.map +1 -0
  36. package/dist/context.d.ts +8 -2
  37. package/dist/context.d.ts.map +1 -1
  38. package/dist/context.js +24 -7
  39. package/dist/context.js.map +1 -1
  40. package/dist/deploymentConfig.d.ts +190 -0
  41. package/dist/deploymentConfig.d.ts.map +1 -0
  42. package/dist/deploymentConfig.js +539 -0
  43. package/dist/deploymentConfig.js.map +1 -0
  44. package/dist/http/admin.d.ts +19 -1
  45. package/dist/http/admin.d.ts.map +1 -1
  46. package/dist/http/admin.js +162 -15
  47. package/dist/http/admin.js.map +1 -1
  48. package/dist/http/app.d.ts +4 -1
  49. package/dist/http/app.d.ts.map +1 -1
  50. package/dist/http/app.js +44 -12
  51. package/dist/http/app.js.map +1 -1
  52. package/dist/http/identity.d.ts.map +1 -1
  53. package/dist/http/identity.js +5 -0
  54. package/dist/http/identity.js.map +1 -1
  55. package/dist/http/rest.d.ts +5 -1
  56. package/dist/http/rest.d.ts.map +1 -1
  57. package/dist/http/rest.js +76 -6
  58. package/dist/http/rest.js.map +1 -1
  59. package/dist/index.d.ts +2 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +2 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/instanceConfig.d.ts +36 -0
  64. package/dist/instanceConfig.d.ts.map +1 -0
  65. package/dist/instanceConfig.js +157 -0
  66. package/dist/instanceConfig.js.map +1 -0
  67. package/dist/licensing/activation.d.ts +2 -1
  68. package/dist/licensing/activation.d.ts.map +1 -1
  69. package/dist/licensing/activation.js +2 -3
  70. package/dist/licensing/activation.js.map +1 -1
  71. package/dist/licensing/entitlements.d.ts.map +1 -1
  72. package/dist/licensing/entitlements.js +2 -2
  73. package/dist/licensing/entitlements.js.map +1 -1
  74. package/dist/licensing/refresher.d.ts +6 -1
  75. package/dist/licensing/refresher.d.ts.map +1 -1
  76. package/dist/licensing/refresher.js +18 -7
  77. package/dist/licensing/refresher.js.map +1 -1
  78. package/dist/logging.d.ts +87 -0
  79. package/dist/logging.d.ts.map +1 -0
  80. package/dist/logging.js +612 -0
  81. package/dist/logging.js.map +1 -0
  82. package/dist/mcp/buildServer.d.ts +2 -0
  83. package/dist/mcp/buildServer.d.ts.map +1 -1
  84. package/dist/mcp/buildServer.js +13 -5
  85. package/dist/mcp/buildServer.js.map +1 -1
  86. package/dist/permissions.d.ts.map +1 -1
  87. package/dist/permissions.js +3 -3
  88. package/dist/permissions.js.map +1 -1
  89. package/dist/scheduler/sendScheduler.d.ts +3 -1
  90. package/dist/scheduler/sendScheduler.d.ts.map +1 -1
  91. package/dist/scheduler/sendScheduler.js +14 -1
  92. package/dist/scheduler/sendScheduler.js.map +1 -1
  93. package/dist/service/emailService.d.ts +2 -1
  94. package/dist/service/emailService.d.ts.map +1 -1
  95. package/dist/service/emailService.js +3 -0
  96. package/dist/service/emailService.js.map +1 -1
  97. package/dist/storage/db.d.ts +5 -2
  98. package/dist/storage/db.d.ts.map +1 -1
  99. package/dist/storage/db.js +55 -2
  100. package/dist/storage/db.js.map +1 -1
  101. package/dist/telemetry.d.ts +3 -2
  102. package/dist/telemetry.d.ts.map +1 -1
  103. package/dist/telemetry.js +12 -19
  104. package/dist/telemetry.js.map +1 -1
  105. package/dist/updateNotifier.d.ts +18 -0
  106. package/dist/updateNotifier.d.ts.map +1 -0
  107. package/dist/updateNotifier.js +128 -0
  108. package/dist/updateNotifier.js.map +1 -0
  109. package/package.json +37 -5
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
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
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { hostname } from 'node:os';
@@ -10,7 +10,8 @@ import { serve } from '@hono/node-server';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { EmailError, isEmailError } from '@fluxmail/core';
12
12
  import { createContext } from './context.js';
13
- 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';
14
15
  import { createApp } from './http/app.js';
15
16
  import { buildMcpServer } from './mcp/buildServer.js';
16
17
  import { runLoopbackFlow } from './accounts/googleAuth.js';
@@ -21,12 +22,16 @@ import { findMember } from './storage/members.js';
21
22
  import { LICENSE_KEY_PATTERN } from './licensing/client.js';
22
23
  import { checkLicenseState, getEntitlements } from './licensing/entitlements.js';
23
24
  import { VERSION } from './version.js';
25
+ import { inspectStoreCompatibility, MIN_SUPPORTED_STORE_FORMAT, openDb, openReadonlyDb, } from './storage/db.js';
24
26
  import { customPermissionPolicy, ADMIN_CAPABILITIES, FULL_PERMISSION_POLICY, isNamedPermissionProfile, MCP_CAPABILITIES, NAMED_PERMISSION_PROFILES, permissionPolicyForProfile, } from './permissions.js';
25
- import { captureOperation, getTelemetry, installTelemetryStreamEndHandler, isTelemetryEnabled, setTelemetryEnabled, shutdownTelemetry, } from './telemetry.js';
27
+ import { captureOperation, getTelemetry, installTelemetryStreamEndHandler, isTelemetryEnabled, setTelemetryEnabled, shutdownTelemetry, telemetryDisabledInAnyEnvironment, } from './telemetry.js';
26
28
  import { clearSessionToken, instanceClient, loadInstanceConfig, removeInstance, resolveInstance, saveLocalInstance, saveRemoteInstance, saveSessionToken, useInstance, } from './cliInstances.js';
27
- import { authenticateBearer, recoverAdminPassword, setupInitialAdmin } from './auth.js';
29
+ import { authenticateBearer, normalizeAndValidatePassword, recoverAdminPassword, setupInitialAdmin } from './auth.js';
28
30
  import { recordAdminAuditEvent } from './storage/adminAudit.js';
29
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';
30
35
  function collectOption(value, previous) {
31
36
  return [...previous, value];
32
37
  }
@@ -81,11 +86,11 @@ function connectionSecurity(value, option) {
81
86
  }
82
87
  async function hiddenPrompt(label) {
83
88
  if (!process.stdin.isTTY || !process.stdin.setRawMode) {
84
- 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.`);
85
90
  }
91
+ const wasFlowing = process.stdin.readableFlowing === true;
86
92
  emitKeypressEvents(process.stdin);
87
93
  const wasRaw = process.stdin.isRaw;
88
- const wasPaused = process.stdin.isPaused();
89
94
  process.stdout.write(`${label}: `);
90
95
  process.stdin.setRawMode(true);
91
96
  process.stdin.resume();
@@ -94,7 +99,7 @@ async function hiddenPrompt(label) {
94
99
  const finish = (error) => {
95
100
  process.stdin.off('keypress', onKeypress);
96
101
  process.stdin.setRawMode(wasRaw);
97
- if (wasPaused)
102
+ if (!wasFlowing)
98
103
  process.stdin.pause();
99
104
  process.stdout.write('\n');
100
105
  if (error)
@@ -136,6 +141,25 @@ async function textPrompt(label) {
136
141
  async function loginPassword(label = 'Password') {
137
142
  return process.env.FLUXMAIL_PASSWORD ?? hiddenPrompt(label);
138
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
+ }
139
163
  async function accountSecret(envName, label) {
140
164
  if (!envName)
141
165
  return hiddenPrompt(label);
@@ -144,6 +168,15 @@ async function accountSecret(envName, label) {
144
168
  throw new EmailError('invalid_request', `${envName} is not set or is empty.`);
145
169
  return value;
146
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
+ }
147
180
  function planLine(ent) {
148
181
  const members = `${ent.maxMembers} member${ent.maxMembers === 1 ? '' : 's'}`;
149
182
  return `${ent.plan} (up to ${ent.maxAccounts} mailboxes, ${members})`;
@@ -155,11 +188,19 @@ function warnLicense(db, log = console.error) {
155
188
  log(`Warning: ${warning}`);
156
189
  }
157
190
  const activeCliOperations = new WeakMap();
158
- function finishCliOperation(program, outcome, errorCode) {
191
+ const activeUpdateNotifiers = new WeakMap();
192
+ function finishCliOperation(program, outcome, errorCode, error) {
159
193
  const active = activeCliOperations.get(program);
160
194
  if (!active)
161
195
  return;
162
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
+ }
163
204
  captureOperation(active.telemetry, {
164
205
  productSurface: 'cli',
165
206
  operation: active.operation,
@@ -168,6 +209,17 @@ function finishCliOperation(program, outcome, errorCode) {
168
209
  errorCode,
169
210
  });
170
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
+ }
171
223
  export function waitForServerListening(server) {
172
224
  return new Promise((resolve, reject) => {
173
225
  const onError = (error) => {
@@ -182,15 +234,39 @@ export function waitForServerListening(server) {
182
234
  server.once('listening', onListening);
183
235
  });
184
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
+ }
185
258
  /** Build the command tree without parsing arguments or running command actions. */
186
259
  export function createCliProgram(options = {}) {
187
260
  const program = new Command();
188
261
  program
189
262
  .name('fluxmail')
190
- .description('Fluxmail, a self-hosted MCP server for your email')
263
+ .description('Fluxmail, a self-hosted email API with MCP, REST, and CLI access')
191
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')
192
267
  .version(VERSION, '-v, --version');
193
268
  const selectedInstance = () => program.opts().instance;
269
+ const selectedAccount = () => program.opts().mailAccount;
194
270
  function commandPath(command) {
195
271
  const names = [];
196
272
  for (let current = command; current?.parent; current = current.parent)
@@ -199,7 +275,7 @@ export function createCliProgram(options = {}) {
199
275
  }
200
276
  let telemetrySignalHandlersInstalled = false;
201
277
  const TELEMETRY_SIGNAL_SHUTDOWN_TIMEOUT_MS = 1_000;
202
- /** Flush queued telemetry before restoring Node's default signal behavior. */
278
+ /** Flush queued telemetry and local logs before restoring Node's default signal behavior. */
203
279
  function installTelemetrySignalHandlers() {
204
280
  if (telemetrySignalHandlersInstalled)
205
281
  return;
@@ -216,7 +292,7 @@ export function createCliProgram(options = {}) {
216
292
  process.kill(process.pid, signal);
217
293
  };
218
294
  const timeout = setTimeout(resignal, TELEMETRY_SIGNAL_SHUTDOWN_TIMEOUT_MS);
219
- void shutdownTelemetry().finally(() => {
295
+ void shutdownTelemetryAndLogging().finally(() => {
220
296
  clearTimeout(timeout);
221
297
  resignal();
222
298
  });
@@ -230,14 +306,57 @@ export function createCliProgram(options = {}) {
230
306
  }
231
307
  program.hook('preAction', (_command, actionCommand) => {
232
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
+ }
233
320
  // Respect the opt-out before creating a telemetry client or recording this command.
234
321
  if (command === 'telemetry disable')
235
322
  return;
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';
236
354
  activeCliOperations.set(program, {
237
- telemetry: options.telemetry ?? getTelemetry(resolveDataDir()),
355
+ telemetry: options.telemetry ?? getTelemetry(dataDir, telemetryEnvironment),
238
356
  operation: command,
239
357
  startedAt: performance.now(),
240
358
  initialExitCode: process.exitCode,
359
+ logger: getLogger(dataDir, mode, logging),
241
360
  });
242
361
  });
243
362
  program.hook('postAction', async (_command, actionCommand) => {
@@ -247,8 +366,20 @@ export function createCliProgram(options = {}) {
247
366
  process.exitCode !== undefined &&
248
367
  process.exitCode !== 0;
249
368
  finishCliOperation(program, failed ? 'error' : 'success', failed ? 'command_failed' : undefined);
250
- if (!options.telemetry && actionCommand.name() !== 'serve' && actionCommand.name() !== 'stdio') {
251
- await shutdownTelemetry();
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
+ }
252
383
  }
253
384
  });
254
385
  program
@@ -261,8 +392,11 @@ export function createCliProgram(options = {}) {
261
392
  try {
262
393
  const name = opts.name ?? (opts.existingAdmin ? undefined : await textPrompt('Administrator name'));
263
394
  const email = opts.email ?? (await textPrompt('Administrator email'));
264
- const password = await loginPassword('New password');
265
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);
266
400
  const result = await setupInitialAdmin(context.db, {
267
401
  ...(name ? { name } : {}),
268
402
  email,
@@ -281,6 +415,7 @@ export function createCliProgram(options = {}) {
281
415
  console.log(`Fluxmail is ready. Logged in to local as ${result.member.name}.`);
282
416
  }
283
417
  catch (err) {
418
+ logActiveCliError(program, err);
284
419
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
285
420
  process.exitCode = 1;
286
421
  }
@@ -307,16 +442,15 @@ export function createCliProgram(options = {}) {
307
442
  const client = instanceClient(instanceName, false);
308
443
  const deviceName = `CLI on ${hostname()}`;
309
444
  const result = opts.enroll || opts.reset
310
- ? await client.json(opts.reset ? '/api/v1/auth/password-reset' : '/api/v1/auth/enroll', {
311
- method: 'POST',
312
- headers: { 'content-type': 'application/json' },
313
- body: JSON.stringify({
314
- token: (opts.reset ? process.env.FLUXMAIL_RESET_CODE : process.env.FLUXMAIL_INVITE_CODE) ??
315
- (await hiddenPrompt(opts.reset ? 'Password reset code' : 'Enrollment code')),
316
- password: await loginPassword('New password'),
317
- deviceName,
318
- }),
319
- }, false)
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
+ })()
320
454
  : await client.json('/api/v1/auth/login', {
321
455
  method: 'POST',
322
456
  headers: { 'content-type': 'application/json' },
@@ -331,6 +465,7 @@ export function createCliProgram(options = {}) {
331
465
  console.log(`Logged in to ${instanceName} as ${result.member.name}.`);
332
466
  }
333
467
  catch (err) {
468
+ logActiveCliError(program, err);
334
469
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
335
470
  process.exitCode = 1;
336
471
  }
@@ -349,6 +484,7 @@ export function createCliProgram(options = {}) {
349
484
  console.log(`Logged out of ${selected.name}.`);
350
485
  }
351
486
  catch (err) {
487
+ logActiveCliError(program, err);
352
488
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
353
489
  process.exitCode = 1;
354
490
  }
@@ -373,6 +509,7 @@ export function createCliProgram(options = {}) {
373
509
  console.log(`Using ${name}.`);
374
510
  }
375
511
  catch (err) {
512
+ logActiveCliError(program, err);
376
513
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
377
514
  process.exitCode = 1;
378
515
  }
@@ -387,6 +524,7 @@ export function createCliProgram(options = {}) {
387
524
  console.log(`Removed CLI instance ${name}. Server data was not changed.`);
388
525
  }
389
526
  catch (err) {
527
+ logActiveCliError(program, err);
390
528
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
391
529
  process.exitCode = 1;
392
530
  }
@@ -402,8 +540,9 @@ export function createCliProgram(options = {}) {
402
540
  if (selected.profile.kind !== 'local') {
403
541
  throw new EmailError('invalid_request', 'Administrator recovery is only available on the local instance host.');
404
542
  }
405
- const password = await loginPassword('New password');
406
543
  const context = createContext();
544
+ const target = findMember(context.db, memberRef);
545
+ const password = await newPassword('New password', target, options.passwordPrompt ?? hiddenPrompt);
407
546
  const member = await recoverAdminPassword(context.db, memberRef, password);
408
547
  recordAdminAuditEvent(context.db, {
409
548
  operation: 'auth.recover_admin',
@@ -415,6 +554,7 @@ export function createCliProgram(options = {}) {
415
554
  console.log(`Reset ${member.name}'s password and revoked existing sessions.`);
416
555
  }
417
556
  catch (err) {
557
+ logActiveCliError(program, err);
418
558
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
419
559
  process.exitCode = 1;
420
560
  }
@@ -430,6 +570,7 @@ export function createCliProgram(options = {}) {
430
570
  }
431
571
  }
432
572
  catch (err) {
573
+ logActiveCliError(program, err);
433
574
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
434
575
  process.exitCode = 1;
435
576
  }
@@ -446,6 +587,7 @@ export function createCliProgram(options = {}) {
446
587
  console.log(`Revoked ${sessionId}.`);
447
588
  }
448
589
  catch (err) {
590
+ logActiveCliError(program, err);
449
591
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
450
592
  process.exitCode = 1;
451
593
  }
@@ -459,6 +601,10 @@ export function createCliProgram(options = {}) {
459
601
  const app = createApp(ctx);
460
602
  ctx.scheduler.start();
461
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
+ });
462
608
  ctx.telemetry.capture('mcp server started', { product_surface: 'mcp', transport: 'http' });
463
609
  console.log(`Fluxmail listening on ${ctx.config.publicUrl}`);
464
610
  console.log(` MCP endpoint: ${ctx.config.publicUrl}/mcp`);
@@ -478,7 +624,7 @@ export function createCliProgram(options = {}) {
478
624
  }
479
625
  console.log(` Plan: ${planLine(getEntitlements(ctx.db))}`);
480
626
  warnLicense(ctx.db, console.log);
481
- ctx.licenseController.start(console.log);
627
+ ctx.licenseController.start();
482
628
  });
483
629
  await waitForServerListening(server);
484
630
  });
@@ -490,7 +636,7 @@ export function createCliProgram(options = {}) {
490
636
  .option('--allow <capability>', 'Allow one MCP capability; repeat as needed', collectOption, [])
491
637
  .action(async (opts) => {
492
638
  installTelemetrySignalHandlers();
493
- installTelemetryStreamEndHandler(process.stdin);
639
+ installStdioShutdownHandler(process.stdin);
494
640
  const ctx = createContext();
495
641
  const permissions = permissionPolicyFromOptions(opts);
496
642
  const selected = resolveInstance(selectedInstance());
@@ -509,12 +655,18 @@ export function createCliProgram(options = {}) {
509
655
  maxAttachmentBytes: ctx.config.maxAttachmentBytes,
510
656
  telemetry: ctx.telemetry,
511
657
  transport: 'stdio',
658
+ logger: ctx.logger,
512
659
  });
513
660
  ctx.scheduler.start();
514
661
  await server.connect(new StdioServerTransport());
515
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
+ });
516
668
  // stdout belongs to the MCP protocol; log to stderr only.
517
- ctx.licenseController.start(console.error);
669
+ ctx.licenseController.start();
518
670
  console.error('Fluxmail MCP server running on stdio');
519
671
  warnLicense(ctx.db);
520
672
  const pending = scopedService.listScheduled().filter((send) => send.status === 'pending').length;
@@ -565,6 +717,7 @@ export function createCliProgram(options = {}) {
565
717
  validateAccountConnectionFlags(provider, opts);
566
718
  }
567
719
  catch (err) {
720
+ logActiveCliError(program, err);
568
721
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
569
722
  process.exitCode = 1;
570
723
  return;
@@ -575,6 +728,7 @@ export function createCliProgram(options = {}) {
575
728
  useControlPlane = selectGmailConnectionMode(createContext().config, opts) === 'hosted';
576
729
  }
577
730
  catch (err) {
731
+ logActiveCliError(program, err);
578
732
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
579
733
  process.exitCode = 1;
580
734
  return;
@@ -644,6 +798,7 @@ export function createCliProgram(options = {}) {
644
798
  }
645
799
  }
646
800
  catch (err) {
801
+ logActiveCliError(program, err);
647
802
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
648
803
  process.exitCode = 1;
649
804
  }
@@ -745,7 +900,7 @@ export function createCliProgram(options = {}) {
745
900
  throw new EmailError('invalid_request', `Google authorized ${result.email}, but account ${existing.id} belongs to ${existing.email}. ` +
746
901
  'Try again and choose the matching Google account.');
747
902
  }
748
- 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);
749
904
  });
750
905
  auditConnection('account.connection.complete', account.id);
751
906
  console.log(`\nConnected ${account.email} (account id: ${account.id})` +
@@ -757,7 +912,7 @@ export function createCliProgram(options = {}) {
757
912
  }
758
913
  }
759
914
  catch (err) {
760
- finishCliOperation(program, 'error', isEmailError(err) ? err.code : 'internal');
915
+ finishCliOperation(program, 'error', isEmailError(err) ? err.code : 'internal', err);
761
916
  console.error(`\nError: ${err instanceof Error ? err.message : String(err)}`);
762
917
  process.exitCode = 1;
763
918
  }
@@ -798,6 +953,7 @@ export function createCliProgram(options = {}) {
798
953
  console.log(`Warning: ${warning.message}.`);
799
954
  }
800
955
  catch (err) {
956
+ logActiveCliError(program, err);
801
957
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
802
958
  process.exitCode = 1;
803
959
  }
@@ -822,6 +978,7 @@ export function createCliProgram(options = {}) {
822
978
  }
823
979
  }
824
980
  catch (err) {
981
+ logActiveCliError(program, err);
825
982
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
826
983
  process.exitCode = 1;
827
984
  }
@@ -838,6 +995,7 @@ export function createCliProgram(options = {}) {
838
995
  console.log(`Removed ${accountId}`);
839
996
  }
840
997
  catch (err) {
998
+ logActiveCliError(program, err);
841
999
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
842
1000
  process.exitCode = 1;
843
1001
  }
@@ -857,6 +1015,7 @@ export function createCliProgram(options = {}) {
857
1015
  console.log(`${account.email} is now owned by ${account.ownerMemberId}.`);
858
1016
  }
859
1017
  catch (err) {
1018
+ logActiveCliError(program, err);
860
1019
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
861
1020
  process.exitCode = 1;
862
1021
  }
@@ -882,6 +1041,7 @@ export function createCliProgram(options = {}) {
882
1041
  console.log(`Updated access for ${account.email}: ${account.sharedWithAll ? 'all members' : `${account.grantedMemberIds.length} explicit grant(s)`}.`);
883
1042
  }
884
1043
  catch (err) {
1044
+ logActiveCliError(program, err);
885
1045
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
886
1046
  process.exitCode = 1;
887
1047
  }
@@ -908,6 +1068,7 @@ export function createCliProgram(options = {}) {
908
1068
  console.log(`Expires: ${new Date(member.invitation.expiresAt).toISOString()}`);
909
1069
  }
910
1070
  catch (err) {
1071
+ logActiveCliError(program, err);
911
1072
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
912
1073
  process.exitCode = 1;
913
1074
  }
@@ -927,6 +1088,7 @@ export function createCliProgram(options = {}) {
927
1088
  }
928
1089
  }
929
1090
  catch (err) {
1091
+ logActiveCliError(program, err);
930
1092
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
931
1093
  process.exitCode = 1;
932
1094
  }
@@ -943,6 +1105,7 @@ export function createCliProgram(options = {}) {
943
1105
  console.log(`Removed ${memberId}.`);
944
1106
  }
945
1107
  catch (err) {
1108
+ logActiveCliError(program, err);
946
1109
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
947
1110
  process.exitCode = 1;
948
1111
  }
@@ -965,6 +1128,7 @@ export function createCliProgram(options = {}) {
965
1128
  console.log(`${member.name} now has the ${member.role} role.`);
966
1129
  }
967
1130
  catch (err) {
1131
+ logActiveCliError(program, err);
968
1132
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
969
1133
  process.exitCode = 1;
970
1134
  }
@@ -987,6 +1151,7 @@ export function createCliProgram(options = {}) {
987
1151
  console.log(`${member.name} is now ${member.status}.`);
988
1152
  }
989
1153
  catch (err) {
1154
+ logActiveCliError(program, err);
990
1155
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
991
1156
  process.exitCode = 1;
992
1157
  }
@@ -1006,6 +1171,7 @@ export function createCliProgram(options = {}) {
1006
1171
  console.log(`Expires: ${new Date(token.expiresAt).toISOString()}`);
1007
1172
  }
1008
1173
  catch (err) {
1174
+ logActiveCliError(program, err);
1009
1175
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1010
1176
  process.exitCode = 1;
1011
1177
  }
@@ -1023,6 +1189,7 @@ export function createCliProgram(options = {}) {
1023
1189
  }
1024
1190
  }
1025
1191
  catch (err) {
1192
+ logActiveCliError(program, err);
1026
1193
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1027
1194
  process.exitCode = 1;
1028
1195
  }
@@ -1038,6 +1205,7 @@ export function createCliProgram(options = {}) {
1038
1205
  console.log(`Revoked ${sessionId}.`);
1039
1206
  }
1040
1207
  catch (err) {
1208
+ logActiveCliError(program, err);
1041
1209
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1042
1210
  process.exitCode = 1;
1043
1211
  }
@@ -1094,6 +1262,7 @@ export function createCliProgram(options = {}) {
1094
1262
  console.log('Store it now; it cannot be shown again.');
1095
1263
  }
1096
1264
  catch (err) {
1265
+ logActiveCliError(program, err);
1097
1266
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1098
1267
  process.exitCode = 1;
1099
1268
  }
@@ -1120,6 +1289,7 @@ export function createCliProgram(options = {}) {
1120
1289
  }
1121
1290
  }
1122
1291
  catch (err) {
1292
+ logActiveCliError(program, err);
1123
1293
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1124
1294
  process.exitCode = 1;
1125
1295
  }
@@ -1146,6 +1316,7 @@ export function createCliProgram(options = {}) {
1146
1316
  : `Updated ${keyId} to ${accountIds.length} mailbox(es).`);
1147
1317
  }
1148
1318
  catch (err) {
1319
+ logActiveCliError(program, err);
1149
1320
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1150
1321
  process.exitCode = 1;
1151
1322
  }
@@ -1180,6 +1351,7 @@ export function createCliProgram(options = {}) {
1180
1351
  console.log(`Updated ${keyId} to profile ${permissions.profile}`);
1181
1352
  }
1182
1353
  catch (err) {
1354
+ logActiveCliError(program, err);
1183
1355
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1184
1356
  process.exitCode = 1;
1185
1357
  }
@@ -1199,6 +1371,7 @@ export function createCliProgram(options = {}) {
1199
1371
  console.log(`Revoked ${keyId}`);
1200
1372
  }
1201
1373
  catch (err) {
1374
+ logActiveCliError(program, err);
1202
1375
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1203
1376
  process.exitCode = 1;
1204
1377
  }
@@ -1206,9 +1379,19 @@ export function createCliProgram(options = {}) {
1206
1379
  const license = program.command('license').description('Manage the paid-tier license');
1207
1380
  license
1208
1381
  .command('activate')
1209
- .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')
1210
1384
  .description('Store a license key and validate it with the license server')
1211
- .action(async (key) => {
1385
+ .action(async (deprecatedKey, options) => {
1386
+ if (deprecatedKey && options.keyFile) {
1387
+ console.error('Error: The deprecated positional key cannot be combined with --key-file.');
1388
+ process.exitCode = 1;
1389
+ return;
1390
+ }
1391
+ if (deprecatedKey) {
1392
+ console.error('Warning: Passing a license key as an argument is deprecated. Use --key-file or hidden input.');
1393
+ }
1394
+ const key = deprecatedKey ?? (await secretInput(options.keyFile, 'License key'));
1212
1395
  if (!LICENSE_KEY_PATTERN.test(key)) {
1213
1396
  console.error('That does not look like a Fluxmail license key (expected "fluxmail_lic_" followed by 40 hex characters).');
1214
1397
  process.exitCode = 1;
@@ -1230,6 +1413,7 @@ export function createCliProgram(options = {}) {
1230
1413
  }
1231
1414
  }
1232
1415
  catch (err) {
1416
+ logActiveCliError(program, err);
1233
1417
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1234
1418
  process.exitCode = 1;
1235
1419
  }
@@ -1242,7 +1426,7 @@ export function createCliProgram(options = {}) {
1242
1426
  const status = await instanceClient(selectedInstance()).json('/api/v1/admin/license');
1243
1427
  console.log(status.configured
1244
1428
  ? `License configured from ${status.source}.`
1245
- : 'No license key configured. Run "fluxmail license activate <key>" after purchasing one.');
1429
+ : 'No license key configured. Run "fluxmail license activate" after purchasing one.');
1246
1430
  console.log(`Current plan: ${planLine(status.entitlements)}`);
1247
1431
  console.log(`Usage: ${status.usage.accounts} mailbox(es), ${status.usage.members} member(s)` +
1248
1432
  (status.usage.overQuota ? ' (over plan limits)' : ''));
@@ -1252,6 +1436,7 @@ export function createCliProgram(options = {}) {
1252
1436
  console.log(`Warning: ${status.warning}`);
1253
1437
  }
1254
1438
  catch (err) {
1439
+ logActiveCliError(program, err);
1255
1440
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1256
1441
  process.exitCode = 1;
1257
1442
  }
@@ -1268,51 +1453,174 @@ export function createCliProgram(options = {}) {
1268
1453
  console.log('This instance is back to Personal plan limits.');
1269
1454
  }
1270
1455
  catch (err) {
1456
+ logActiveCliError(program, err);
1271
1457
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1272
1458
  process.exitCode = 1;
1273
1459
  }
1274
1460
  });
1275
- const configCmd = program
1276
- .command('config')
1277
- .description('Persistent settings stored in the data dir, usable from any directory');
1461
+ const configCmd = program.command('config').description('Inspect and initialize deployment configuration');
1278
1462
  configCmd
1279
- .command('set')
1280
- .argument('<key>', 'Setting name, e.g. GOOGLE_CLIENT_ID')
1281
- .argument('<value>')
1282
- .description('Store a setting (shell env vars and local .env files still take precedence)')
1283
- .action((key, value) => {
1463
+ .command('init')
1464
+ .description('Create config.toml in the Fluxmail data directory')
1465
+ .action(() => {
1284
1466
  try {
1285
1467
  const dataDir = resolveDataDir();
1286
- setStoredConfig(dataDir, key, value);
1287
- console.log(`Set ${key} in ${configFilePath(dataDir)}`);
1468
+ const file = configTomlPath(dataDir);
1469
+ writeDeploymentConfig(file, deploymentToml(), { exclusive: true });
1470
+ console.log(`Created ${file}`);
1288
1471
  }
1289
1472
  catch (err) {
1473
+ logActiveCliError(program, err);
1290
1474
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1291
1475
  process.exitCode = 1;
1292
1476
  }
1293
1477
  });
1294
1478
  configCmd
1295
- .command('unset')
1296
- .argument('<key>')
1297
- .description('Remove a stored setting')
1298
- .action((key) => {
1299
- const dataDir = resolveDataDir();
1300
- 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
+ }
1301
1510
  });
1302
1511
  configCmd
1303
- .command('list')
1304
- .description('Show stored settings (secret values are masked)')
1305
- .action(() => {
1306
- const dataDir = resolveDataDir();
1307
- const stored = readStoredConfig(dataDir);
1308
- const keys = Object.keys(stored);
1309
- if (!keys.length) {
1310
- 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.');
1311
1521
  return;
1312
1522
  }
1313
- for (const key of keys) {
1314
- const value = stored[key] ?? '';
1315
- 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;
1316
1624
  }
1317
1625
  });
1318
1626
  const telemetryCmd = program.command('telemetry').description('Manage anonymous usage telemetry');
@@ -1330,7 +1638,6 @@ export function createCliProgram(options = {}) {
1330
1638
  .action(() => {
1331
1639
  const dataDir = resolveDataDir();
1332
1640
  setTelemetryEnabled(dataDir, true);
1333
- unsetStoredConfig(dataDir, 'FLUXMAIL_TELEMETRY');
1334
1641
  console.log('Telemetry enabled. FLUXMAIL_TELEMETRY=0 or DO_NOT_TRACK=1 can still turn it off.');
1335
1642
  });
1336
1643
  telemetryCmd
@@ -1340,6 +1647,15 @@ export function createCliProgram(options = {}) {
1340
1647
  const dataDir = resolveDataDir();
1341
1648
  console.log(`Telemetry is ${isTelemetryEnabled(dataDir) ? 'enabled' : 'disabled'}.`);
1342
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
+ });
1343
1659
  program
1344
1660
  .command('status')
1345
1661
  .description('Show mailbox and provider status for the selected instance')
@@ -1349,6 +1665,7 @@ export function createCliProgram(options = {}) {
1349
1665
  console.log(JSON.stringify(status, null, 2));
1350
1666
  }
1351
1667
  catch (err) {
1668
+ logActiveCliError(program, err);
1352
1669
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1353
1670
  process.exitCode = 1;
1354
1671
  }
@@ -1356,15 +1673,15 @@ export function createCliProgram(options = {}) {
1356
1673
  return program;
1357
1674
  }
1358
1675
  export async function runCli(argv = process.argv) {
1359
- const program = createCliProgram();
1676
+ const program = createCliProgram({ updateNotifierFactory: createCliUpdateNotifier });
1360
1677
  try {
1361
1678
  await program.parseAsync([...argv]);
1362
1679
  }
1363
1680
  catch (err) {
1364
- finishCliOperation(program, 'error', isEmailError(err) ? err.code : 'internal');
1681
+ finishCliOperation(program, 'error', isEmailError(err) ? err.code : 'internal', err);
1365
1682
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1366
1683
  process.exitCode = 1;
1367
- await shutdownTelemetry();
1684
+ await Promise.all([shutdownTelemetry(), shutdownLogging()]);
1368
1685
  }
1369
1686
  }
1370
1687
  function isEntrypoint() {