fluxmail 0.9.0 → 0.11.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 (101) hide show
  1. package/README.md +1 -1
  2. package/dist/accounts/googleAuth.d.ts +4 -2
  3. package/dist/accounts/googleAuth.d.ts.map +1 -1
  4. package/dist/accounts/googleAuth.js +20 -85
  5. package/dist/accounts/googleAuth.js.map +1 -1
  6. package/dist/accounts/microsoftAuth.d.ts +2 -1
  7. package/dist/accounts/microsoftAuth.d.ts.map +1 -1
  8. package/dist/accounts/microsoftAuth.js +16 -80
  9. package/dist/accounts/microsoftAuth.js.map +1 -1
  10. package/dist/accounts/oauthCallback.d.ts +52 -0
  11. package/dist/accounts/oauthCallback.d.ts.map +1 -0
  12. package/dist/accounts/oauthCallback.js +237 -0
  13. package/dist/accounts/oauthCallback.js.map +1 -0
  14. package/dist/accounts/registry.d.ts.map +1 -1
  15. package/dist/accounts/registry.js +12 -5
  16. package/dist/accounts/registry.js.map +1 -1
  17. package/dist/accounts/telemetry.d.ts +6 -0
  18. package/dist/accounts/telemetry.d.ts.map +1 -1
  19. package/dist/accounts/telemetry.js +19 -0
  20. package/dist/accounts/telemetry.js.map +1 -1
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +114 -36
  23. package/dist/cli.js.map +1 -1
  24. package/dist/cliInstances.d.ts +5 -2
  25. package/dist/cliInstances.d.ts.map +1 -1
  26. package/dist/cliInstances.js +74 -22
  27. package/dist/cliInstances.js.map +1 -1
  28. package/dist/cliMail.d.ts +2 -0
  29. package/dist/cliMail.d.ts.map +1 -1
  30. package/dist/cliMail.js +113 -19
  31. package/dist/cliMail.js.map +1 -1
  32. package/dist/cliOutput.d.ts +9 -0
  33. package/dist/cliOutput.d.ts.map +1 -0
  34. package/dist/cliOutput.js +76 -0
  35. package/dist/cliOutput.js.map +1 -0
  36. package/dist/context.js +1 -1
  37. package/dist/context.js.map +1 -1
  38. package/dist/http/admin.d.ts.map +1 -1
  39. package/dist/http/admin.js +2 -0
  40. package/dist/http/admin.js.map +1 -1
  41. package/dist/http/app.d.ts.map +1 -1
  42. package/dist/http/app.js +4 -0
  43. package/dist/http/app.js.map +1 -1
  44. package/dist/http/identity.d.ts.map +1 -1
  45. package/dist/http/identity.js +2 -0
  46. package/dist/http/identity.js.map +1 -1
  47. package/dist/http/rest.d.ts.map +1 -1
  48. package/dist/http/rest.js +270 -48
  49. package/dist/http/rest.js.map +1 -1
  50. package/dist/licensing/entitlements.d.ts +23 -5
  51. package/dist/licensing/entitlements.d.ts.map +1 -1
  52. package/dist/licensing/entitlements.js +101 -22
  53. package/dist/licensing/entitlements.js.map +1 -1
  54. package/dist/licensing/lease.d.ts +1 -1
  55. package/dist/licensing/lease.d.ts.map +1 -1
  56. package/dist/licensing/refresher.d.ts.map +1 -1
  57. package/dist/licensing/refresher.js +5 -2
  58. package/dist/licensing/refresher.js.map +1 -1
  59. package/dist/mcp/buildServer.d.ts.map +1 -1
  60. package/dist/mcp/buildServer.js +236 -78
  61. package/dist/mcp/buildServer.js.map +1 -1
  62. package/dist/mcp/outputSchemas.d.ts +2923 -0
  63. package/dist/mcp/outputSchemas.d.ts.map +1 -0
  64. package/dist/mcp/outputSchemas.js +108 -0
  65. package/dist/mcp/outputSchemas.js.map +1 -0
  66. package/dist/scheduler/sendScheduler.d.ts +2 -0
  67. package/dist/scheduler/sendScheduler.d.ts.map +1 -1
  68. package/dist/scheduler/sendScheduler.js +13 -3
  69. package/dist/scheduler/sendScheduler.js.map +1 -1
  70. package/dist/service/deliveryCoordinator.d.ts +45 -0
  71. package/dist/service/deliveryCoordinator.d.ts.map +1 -0
  72. package/dist/service/deliveryCoordinator.js +296 -0
  73. package/dist/service/deliveryCoordinator.js.map +1 -0
  74. package/dist/service/emailService.d.ts +48 -5
  75. package/dist/service/emailService.d.ts.map +1 -1
  76. package/dist/service/emailService.js +258 -37
  77. package/dist/service/emailService.js.map +1 -1
  78. package/dist/service/publicErrors.d.ts +12 -0
  79. package/dist/service/publicErrors.d.ts.map +1 -0
  80. package/dist/service/publicErrors.js +41 -0
  81. package/dist/service/publicErrors.js.map +1 -0
  82. package/dist/service/searchCursor.d.ts +2 -0
  83. package/dist/service/searchCursor.d.ts.map +1 -1
  84. package/dist/service/searchCursor.js +6 -2
  85. package/dist/service/searchCursor.js.map +1 -1
  86. package/dist/storage/db.d.ts +252 -2
  87. package/dist/storage/db.d.ts.map +1 -1
  88. package/dist/storage/db.js +57 -1
  89. package/dist/storage/db.js.map +1 -1
  90. package/dist/storage/members.d.ts.map +1 -1
  91. package/dist/storage/members.js +3 -1
  92. package/dist/storage/members.js.map +1 -1
  93. package/dist/storage/restIdempotency.d.ts +6 -0
  94. package/dist/storage/restIdempotency.d.ts.map +1 -1
  95. package/dist/storage/restIdempotency.js +8 -0
  96. package/dist/storage/restIdempotency.js.map +1 -1
  97. package/dist/storage/scheduledSends.d.ts +2 -1
  98. package/dist/storage/scheduledSends.d.ts.map +1 -1
  99. package/dist/storage/scheduledSends.js +6 -0
  100. package/dist/storage/scheduledSends.js.map +1 -1
  101. package/package.json +5 -5
package/dist/cli.js CHANGED
@@ -25,13 +25,14 @@ import { VERSION } from './version.js';
25
25
  import { inspectStoreCompatibility, MIN_SUPPORTED_STORE_FORMAT, openDb, openReadonlyDb, } from './storage/db.js';
26
26
  import { customPermissionPolicy, ADMIN_CAPABILITIES, FULL_PERMISSION_POLICY, isNamedPermissionProfile, MCP_CAPABILITIES, NAMED_PERMISSION_PROFILES, permissionPolicyForProfile, } from './permissions.js';
27
27
  import { captureOperation, getTelemetry, installTelemetryStreamEndHandler, isTelemetryEnabled, setTelemetryEnabled, shutdownTelemetry, telemetryDisabledInAnyEnvironment, } from './telemetry.js';
28
- import { apiErrorCode, clearSessionToken, instanceClient, loadInstanceConfig, removeInstance, resolveInstance, saveLocalInstance, saveRemoteInstance, saveSessionToken, useInstance, } from './cliInstances.js';
28
+ import { apiErrorCode, apiRequestId, clearSessionToken, instanceClient, loadInstanceConfig, removeInstance, resolveInstance, saveLocalInstance, saveRemoteInstance, saveSessionToken, setRequestDeadlineMs, useInstance, } from './cliInstances.js';
29
+ import { cliExitCode, parseOutputFormat, printOutput } from './cliOutput.js';
29
30
  import { authenticateBearer, isBootstrapComplete, normalizeAndValidatePassword, recoverAdminPassword, setupInitialAdmin, } from './auth.js';
30
31
  import { recordAdminAuditEvent } from './storage/adminAudit.js';
31
32
  import { canManageOwnedAccount } from './authorization.js';
32
33
  import { createCliUpdateNotifier } from './updateNotifier.js';
33
34
  import { registerMailCommands } from './cliMail.js';
34
- import { accountInventoryProperties, configuredOAuthAppKind, connectionProperties } from './accounts/telemetry.js';
35
+ import { accountInventoryProperties, configuredOAuthAppKind, connectionProperties, instanceUsageProperties, } from './accounts/telemetry.js';
35
36
  import { flushLogging, escapeLogTextForConsole, getLogger, logCodedFailure, logFailure, readLocalLogs, shutdownLogging, } from './logging.js';
36
37
  function collectOption(value, previous) {
37
38
  return [...previous, value];
@@ -178,6 +179,33 @@ function readSecretFile(file) {
178
179
  async function secretInput(file, label) {
179
180
  return file === undefined ? hiddenPrompt(label) : readSecretFile(file);
180
181
  }
182
+ /** Print the consent URL and explain how to finish when the redirect cannot reach this computer. */
183
+ function printLoopbackInstructions(provider, url, context, port) {
184
+ const providerName = provider === 'gmail' ? 'Google' : 'Microsoft';
185
+ const access = provider === 'gmail' ? 'Gmail access' : 'Microsoft mail access';
186
+ console.log(`\nOpen this URL in your browser to authorize ${access}:\n`);
187
+ console.log(` ${url}\n`);
188
+ if (!context.listening) {
189
+ console.log(`Port ${port} is already in use, so Fluxmail cannot receive the redirect from ${providerName}.\n\n` +
190
+ 'If Fluxmail is running with Docker Compose, press Ctrl-C and connect the account inside the container ' +
191
+ 'so it is saved there:\n\n' +
192
+ ` docker compose exec fluxmail fluxmail accounts add ${provider}\n\n` +
193
+ "Otherwise, approve access, then copy the full URL from your browser's address bar and paste it here.");
194
+ return;
195
+ }
196
+ console.log(`Waiting for ${providerName} to redirect back...`);
197
+ if (context.pasteEnabled) {
198
+ console.log('If your browser runs on another computer, the page will not load after you approve access. ' +
199
+ 'Copy the full URL from its address bar and paste it here.');
200
+ }
201
+ }
202
+ /** Accept a pasted callback URL when a person is at the terminal, and report how the callback arrived. */
203
+ function loopbackFlowOptions(program, provider) {
204
+ return {
205
+ ...(process.stdin.isTTY ? { paste: { input: process.stdin, write: (message) => console.error(message) } } : {}),
206
+ onCallback: (callback) => recordCliOperationProperties(program, connectionProperties({ provider, callback })),
207
+ };
208
+ }
181
209
  /** Offered when a configured public URL, not an explicit --hosted, chose the hosted flow. */
182
210
  const LOOPBACK_FLOW_REMEDY = 'Pass --local to use the loopback flow on this computer instead.';
183
211
  function planLine(ent) {
@@ -248,9 +276,22 @@ function finishCliOperation(program, outcome, errorCode, error) {
248
276
  }
249
277
  /** End a failed command with the error's own telemetry code instead of a generic one. */
250
278
  function failCliOperation(program, error, prefix = 'Error: ') {
251
- finishCliOperation(program, 'error', apiErrorCode(error), error);
252
- console.error(`${prefix}${error instanceof Error ? error.message : String(error)}`);
253
- process.exitCode = 1;
279
+ const code = apiErrorCode(error);
280
+ finishCliOperation(program, 'error', code, error);
281
+ let format = 'json';
282
+ try {
283
+ format = parseOutputFormat(program.opts().format ?? 'json');
284
+ }
285
+ catch {
286
+ // Report an invalid --format value in the default structured format.
287
+ }
288
+ const message = error instanceof Error ? error.message : String(error);
289
+ console.error(format === 'table'
290
+ ? `${prefix}${message}`
291
+ : JSON.stringify({
292
+ error: { code, message, ...(apiRequestId(error) ? { requestId: apiRequestId(error) } : {}) },
293
+ }));
294
+ process.exitCode = cliExitCode(code);
254
295
  }
255
296
  export function waitForServerListening(server) {
256
297
  return new Promise((resolve, reject) => {
@@ -295,10 +336,14 @@ export function createCliProgram(options = {}) {
295
336
  .description('Fluxmail, a self-hosted email API with MCP, REST, and CLI access')
296
337
  .option('--instance <name>', 'Use a named local or remote instance')
297
338
  .option('-a, --mail-account <id-or-email>', 'Use an email account by ID or address')
339
+ .option('--format <format>', 'Output format: json, table, or ndjson', 'json')
340
+ .option('--timeout <seconds>', 'Request deadline in seconds (1 to 300)', '30')
298
341
  .option('--no-update-notifier', 'Skip the automatic update check for this command')
299
342
  .version(VERSION, '-v, --version');
300
343
  const selectedInstance = () => program.opts().instance;
301
344
  const selectedAccount = () => program.opts().mailAccount;
345
+ const selectedFormat = () => parseOutputFormat(program.opts().format);
346
+ const capturedOutput = new Map();
302
347
  function commandPath(command) {
303
348
  const names = [];
304
349
  for (let current = command; current?.parent; current = current.parent)
@@ -337,6 +382,8 @@ export function createCliProgram(options = {}) {
337
382
  process.once('SIGHUP', onSighup);
338
383
  }
339
384
  program.hook('preAction', (_command, actionCommand) => {
385
+ selectedFormat();
386
+ setRequestDeadlineMs(Number(program.opts().timeout) * 1000);
340
387
  const command = commandPath(actionCommand);
341
388
  const updateNotifierEnabled = program.opts().updateNotifier;
342
389
  if (updateNotifierEnabled && actionCommand.name() !== 'stdio') {
@@ -390,8 +437,44 @@ export function createCliProgram(options = {}) {
390
437
  initialExitCode: process.exitCode,
391
438
  logger: getLogger(dataDir, mode, logging),
392
439
  });
440
+ const [group, action] = command.split(' ');
441
+ const alreadyStructured = ['folders', 'labels', 'emails', 'threads', 'drafts', 'scheduled', 'attachments'].includes(group ?? '') ||
442
+ command === 'accounts list' ||
443
+ command === 'status';
444
+ const interactive = ['setup', 'login', 'serve', 'stdio'].includes(command) ||
445
+ group === 'auth' ||
446
+ command === 'accounts add' ||
447
+ command === 'accounts configure' ||
448
+ command === 'license activate' ||
449
+ (group === 'oauth' && action !== 'status') ||
450
+ group === 'logs';
451
+ if (selectedFormat() !== 'table' && !alreadyStructured && !interactive) {
452
+ const original = console.log;
453
+ const lines = [];
454
+ capturedOutput.set(program, { original, lines });
455
+ console.log = (...values) => {
456
+ lines.push(values.map((value) => (typeof value === 'string' ? value : JSON.stringify(value))).join(' '));
457
+ };
458
+ }
393
459
  });
394
460
  program.hook('postAction', async (_command, actionCommand) => {
461
+ const captured = capturedOutput.get(program);
462
+ if (captured) {
463
+ capturedOutput.delete(program);
464
+ console.log = captured.original;
465
+ if (captured.lines.length) {
466
+ let data = captured.lines.length === 1 ? captured.lines[0] : captured.lines;
467
+ if (captured.lines.length === 1) {
468
+ try {
469
+ data = JSON.parse(captured.lines[0]);
470
+ }
471
+ catch {
472
+ /* Plain output stays a string. */
473
+ }
474
+ }
475
+ printOutput({ data }, selectedFormat());
476
+ }
477
+ }
395
478
  const active = activeCliOperations.get(program);
396
479
  const failed = active !== undefined &&
397
480
  process.exitCode !== active.initialExitCode &&
@@ -638,7 +721,11 @@ export function createCliProgram(options = {}) {
638
721
  details: { port: ctx.config.port },
639
722
  skipConsole: true,
640
723
  });
641
- ctx.telemetry.capture('mcp server started', { product_surface: 'mcp', transport: 'http' });
724
+ ctx.telemetry.capture('mcp server started', {
725
+ ...instanceUsageProperties(ctx.db),
726
+ product_surface: 'mcp',
727
+ transport: 'http',
728
+ });
642
729
  console.log(`Fluxmail listening on ${ctx.config.publicUrl}`);
643
730
  console.log(` MCP endpoint: ${ctx.config.publicUrl}/mcp`);
644
731
  console.log(` REST API: ${ctx.config.publicUrl}/api/v1`);
@@ -692,7 +779,11 @@ export function createCliProgram(options = {}) {
692
779
  });
693
780
  ctx.scheduler.start();
694
781
  await server.connect(new StdioServerTransport());
695
- ctx.telemetry.capture('mcp server started', { product_surface: 'mcp', transport: 'stdio' });
782
+ ctx.telemetry.capture('mcp server started', {
783
+ ...instanceUsageProperties(ctx.db),
784
+ product_surface: 'mcp',
785
+ transport: 'stdio',
786
+ });
696
787
  ctx.logger.info('server.started', 'Fluxmail stdio MCP server started', {
697
788
  productSurface: 'mcp',
698
789
  details: { transport: 'stdio' },
@@ -979,11 +1070,7 @@ export function createCliProgram(options = {}) {
979
1070
  console.log('This link expires in 10 minutes and can only be used once.');
980
1071
  return;
981
1072
  }
982
- const account = await runMicrosoftLoopbackFlow(ctx.config, (url) => {
983
- console.log('\nOpen this URL in your browser to authorize Microsoft mail access:\n');
984
- console.log(` ${url}\n`);
985
- console.log('Waiting for Microsoft to redirect back...');
986
- }, (result) => {
1073
+ const account = await runMicrosoftLoopbackFlow(ctx.config, (url, context) => printLoopbackInstructions('outlook', url, context, ctx.config.oauthPort), (result) => {
987
1074
  const duplicate = ctx.registry
988
1075
  .listAccounts()
989
1076
  .find((candidate) => candidate.email.toLowerCase() === result.email.toLowerCase());
@@ -995,7 +1082,7 @@ export function createCliProgram(options = {}) {
995
1082
  'Try again and choose the matching Microsoft account.');
996
1083
  }
997
1084
  return ctx.registry.addOutlookAccount(result.email, result.credentials, result.displayName, owner.id, existing?.id, access);
998
- });
1085
+ }, loopbackFlowOptions(program, 'outlook'));
999
1086
  auditConnection('account.connection.complete', account.id);
1000
1087
  recordCliOperationProperties(program, {
1001
1088
  reauthorize: accountIdsBeforeConnection.has(account.id),
@@ -1018,11 +1105,7 @@ export function createCliProgram(options = {}) {
1018
1105
  console.log('This link expires in 10 minutes and can only be used once.');
1019
1106
  return;
1020
1107
  }
1021
- const account = await runLoopbackFlow(ctx.config, (url) => {
1022
- console.log('\nOpen this URL in your browser to authorize Gmail access:\n');
1023
- console.log(` ${url}\n`);
1024
- console.log('Waiting for Google to redirect back…');
1025
- }, (result) => {
1108
+ const account = await runLoopbackFlow(ctx.config, (url, context) => printLoopbackInstructions('gmail', url, context, ctx.config.oauthPort), (result) => {
1026
1109
  const duplicate = ctx.registry
1027
1110
  .listAccounts()
1028
1111
  .find((candidate) => candidate.email.toLowerCase() === result.email.toLowerCase());
@@ -1034,7 +1117,7 @@ export function createCliProgram(options = {}) {
1034
1117
  'Try again and choose the matching Google account.');
1035
1118
  }
1036
1119
  return ctx.registry.addGmailAccount(result.email, result.tokens, result.displayName, owner.id, access, result.oauthClient);
1037
- });
1120
+ }, loopbackFlowOptions(program, 'gmail'));
1038
1121
  auditConnection('account.connection.complete', account.id);
1039
1122
  recordCliOperationProperties(program, {
1040
1123
  reauthorize: accountIdsBeforeConnection.has(account.id),
@@ -1097,18 +1180,7 @@ export function createCliProgram(options = {}) {
1097
1180
  .action(async () => {
1098
1181
  try {
1099
1182
  const all = await instanceClient(selectedInstance()).json('/api/v1/accounts');
1100
- if (!all.length) {
1101
- console.log('No accounts connected. Run "fluxmail accounts add <provider>".');
1102
- return;
1103
- }
1104
- for (const a of all) {
1105
- const access = a.sharedWithAll
1106
- ? 'all'
1107
- : a.grantedMemberIds.length
1108
- ? `selected:${a.grantedMemberIds.join(',')}`
1109
- : 'owner-only';
1110
- console.log(`${a.id} ${a.provider} ${a.email} [${a.status}] owner=${a.ownerMemberId} access=${access}`);
1111
- }
1183
+ printOutput({ data: all }, selectedFormat());
1112
1184
  }
1113
1185
  catch (err) {
1114
1186
  failCliOperation(program, err);
@@ -1188,7 +1260,7 @@ export function createCliProgram(options = {}) {
1188
1260
  .requiredOption('--name <name>', 'Member name')
1189
1261
  .requiredOption('--email <email>', 'Member login email')
1190
1262
  .option('--role <role>', 'Member role: admin or member')
1191
- .description('Add a member (subject to the plan seat limit)')
1263
+ .description("Add a member (subject to your plan's member limit)")
1192
1264
  .action(async (opts) => {
1193
1265
  try {
1194
1266
  if (opts.role && opts.role !== 'admin' && opts.role !== 'member') {
@@ -1752,14 +1824,20 @@ export function createCliProgram(options = {}) {
1752
1824
  registerMailCommands(program, {
1753
1825
  selectedInstance,
1754
1826
  selectedAccount,
1827
+ selectedFormat,
1755
1828
  reportError: (error, code) => {
1756
1829
  finishCliOperation(program, 'error', code, error);
1757
- console.error(`Error [${code}]: ${error instanceof Error ? error.message : String(error)}`);
1758
- process.exitCode = 1;
1830
+ const message = error instanceof Error ? error.message : String(error);
1831
+ console.error(selectedFormat() === 'table'
1832
+ ? `Error [${code}]: ${message}`
1833
+ : JSON.stringify({
1834
+ error: { code, message, ...(apiRequestId(error) ? { requestId: apiRequestId(error) } : {}) },
1835
+ }));
1836
+ process.exitCode = cliExitCode(code);
1759
1837
  },
1760
1838
  reportPartialFailure: (code) => {
1761
1839
  finishCliOperation(program, 'error', code);
1762
- process.exitCode = 1;
1840
+ process.exitCode = cliExitCode(code);
1763
1841
  },
1764
1842
  });
1765
1843
  program
@@ -1768,7 +1846,7 @@ export function createCliProgram(options = {}) {
1768
1846
  .action(async () => {
1769
1847
  try {
1770
1848
  const status = await instanceClient(selectedInstance()).json('/api/v1/status');
1771
- console.log(JSON.stringify(status, null, 2));
1849
+ printOutput({ data: status }, selectedFormat());
1772
1850
  }
1773
1851
  catch (err) {
1774
1852
  failCliOperation(program, err);