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.
- package/README.md +6 -4
- package/dist/accounts/defaultGoogleOAuth.d.ts +4 -0
- package/dist/accounts/defaultGoogleOAuth.d.ts.map +1 -0
- package/dist/accounts/defaultGoogleOAuth.js +7 -0
- package/dist/accounts/defaultGoogleOAuth.js.map +1 -0
- package/dist/accounts/gmailConnection.js +2 -2
- package/dist/accounts/gmailConnection.js.map +1 -1
- package/dist/accounts/googleAuth.d.ts +16 -9
- package/dist/accounts/googleAuth.d.ts.map +1 -1
- package/dist/accounts/googleAuth.js +52 -20
- package/dist/accounts/googleAuth.js.map +1 -1
- package/dist/accounts/microsoftAuth.d.ts +5 -2
- package/dist/accounts/microsoftAuth.d.ts.map +1 -1
- package/dist/accounts/microsoftAuth.js +25 -22
- package/dist/accounts/microsoftAuth.js.map +1 -1
- package/dist/accounts/registry.d.ts +11 -9
- package/dist/accounts/registry.d.ts.map +1 -1
- package/dist/accounts/registry.js +169 -98
- package/dist/accounts/registry.js.map +1 -1
- package/dist/auth.d.ts +106 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +455 -0
- package/dist/auth.js.map +1 -0
- package/dist/authorization.d.ts +9 -0
- package/dist/authorization.d.ts.map +1 -0
- package/dist/authorization.js +23 -0
- package/dist/authorization.js.map +1 -0
- package/dist/cli.d.ts +15 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1096 -403
- package/dist/cli.js.map +1 -1
- package/dist/cliInstances.d.ts +51 -0
- package/dist/cliInstances.d.ts.map +1 -0
- package/dist/cliInstances.js +237 -0
- package/dist/cliInstances.js.map +1 -0
- package/dist/cliMail.d.ts +9 -0
- package/dist/cliMail.d.ts.map +1 -0
- package/dist/cliMail.js +450 -0
- package/dist/cliMail.js.map +1 -0
- package/dist/config.d.ts +95 -118
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +351 -286
- package/dist/config.js.map +1 -1
- package/dist/configMigration.d.ts +8 -0
- package/dist/configMigration.d.ts.map +1 -0
- package/dist/configMigration.js +234 -0
- package/dist/configMigration.js.map +1 -0
- package/dist/context.d.ts +8 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +29 -8
- package/dist/context.js.map +1 -1
- package/dist/deploymentConfig.d.ts +190 -0
- package/dist/deploymentConfig.d.ts.map +1 -0
- package/dist/deploymentConfig.js +539 -0
- package/dist/deploymentConfig.js.map +1 -0
- package/dist/http/admin.d.ts +75 -3
- package/dist/http/admin.d.ts.map +1 -1
- package/dist/http/admin.js +347 -79
- package/dist/http/admin.js.map +1 -1
- package/dist/http/app.d.ts +4 -1
- package/dist/http/app.d.ts.map +1 -1
- package/dist/http/app.js +119 -63
- package/dist/http/app.js.map +1 -1
- package/dist/http/identity.d.ts +114 -0
- package/dist/http/identity.d.ts.map +1 -0
- package/dist/http/identity.js +936 -0
- package/dist/http/identity.js.map +1 -0
- package/dist/http/operationRoutes.d.ts +8 -0
- package/dist/http/operationRoutes.d.ts.map +1 -0
- package/dist/http/operationRoutes.js +13 -0
- package/dist/http/operationRoutes.js.map +1 -0
- package/dist/http/rest.d.ts +16 -4
- package/dist/http/rest.d.ts.map +1 -1
- package/dist/http/rest.js +213 -71
- package/dist/http/rest.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/instanceConfig.d.ts +36 -0
- package/dist/instanceConfig.d.ts.map +1 -0
- package/dist/instanceConfig.js +157 -0
- package/dist/instanceConfig.js.map +1 -0
- package/dist/licensing/activation.d.ts +2 -1
- package/dist/licensing/activation.d.ts.map +1 -1
- package/dist/licensing/activation.js +2 -3
- package/dist/licensing/activation.js.map +1 -1
- package/dist/licensing/entitlements.d.ts.map +1 -1
- package/dist/licensing/entitlements.js +2 -2
- package/dist/licensing/entitlements.js.map +1 -1
- package/dist/licensing/refresher.d.ts +6 -1
- package/dist/licensing/refresher.d.ts.map +1 -1
- package/dist/licensing/refresher.js +18 -7
- package/dist/licensing/refresher.js.map +1 -1
- package/dist/logging.d.ts +87 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +612 -0
- package/dist/logging.js.map +1 -0
- package/dist/mcp/buildServer.d.ts +3 -1
- package/dist/mcp/buildServer.d.ts.map +1 -1
- package/dist/mcp/buildServer.js +27 -24
- package/dist/mcp/buildServer.js.map +1 -1
- package/dist/permissions.d.ts +1 -1
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +10 -4
- package/dist/permissions.js.map +1 -1
- package/dist/scheduler/sendScheduler.d.ts +4 -3
- package/dist/scheduler/sendScheduler.d.ts.map +1 -1
- package/dist/scheduler/sendScheduler.js +15 -3
- package/dist/scheduler/sendScheduler.js.map +1 -1
- package/dist/service/emailService.d.ts +10 -17
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +28 -38
- package/dist/service/emailService.js.map +1 -1
- package/dist/storage/adminAudit.d.ts +15 -2
- package/dist/storage/adminAudit.d.ts.map +1 -1
- package/dist/storage/adminAudit.js +22 -27
- package/dist/storage/adminAudit.js.map +1 -1
- package/dist/storage/apiKeys.d.ts +8 -8
- package/dist/storage/apiKeys.d.ts.map +1 -1
- package/dist/storage/apiKeys.js +16 -11
- package/dist/storage/apiKeys.js.map +1 -1
- package/dist/storage/db.d.ts +596 -31
- package/dist/storage/db.d.ts.map +1 -1
- package/dist/storage/db.js +383 -45
- package/dist/storage/db.js.map +1 -1
- package/dist/storage/fileLock.d.ts +7 -0
- package/dist/storage/fileLock.d.ts.map +1 -0
- package/dist/storage/fileLock.js +183 -0
- package/dist/storage/fileLock.js.map +1 -0
- package/dist/storage/gmailConnectionGrants.d.ts +3 -4
- package/dist/storage/gmailConnectionGrants.d.ts.map +1 -1
- package/dist/storage/gmailConnectionGrants.js +6 -6
- package/dist/storage/gmailConnectionGrants.js.map +1 -1
- package/dist/storage/members.d.ts +11 -0
- package/dist/storage/members.d.ts.map +1 -1
- package/dist/storage/members.js +70 -6
- package/dist/storage/members.js.map +1 -1
- package/dist/telemetry.d.ts +17 -2
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +34 -19
- package/dist/telemetry.js.map +1 -1
- package/dist/updateNotifier.d.ts +18 -0
- package/dist/updateNotifier.d.ts.map +1 -0
- package/dist/updateNotifier.js +128 -0
- package/dist/updateNotifier.js.map +1 -0
- 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 {
|
|
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 {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
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}
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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(
|
|
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
|
-
|
|
639
|
+
installStdioShutdownHandler(process.stdin);
|
|
243
640
|
const ctx = createContext();
|
|
244
641
|
const permissions = permissionPolicyFromOptions(opts);
|
|
245
|
-
const
|
|
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.
|
|
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(
|
|
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
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
843
|
+
ownerMemberId: owner.id,
|
|
391
844
|
...(existing ? { reauthorizeAccountId: existing.id } : {}),
|
|
392
|
-
...(!existing ?
|
|
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
|
|
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
|
-
(
|
|
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
|
-
|
|
878
|
+
ownerMemberId: owner.id,
|
|
418
879
|
...(existing ? { reauthorizeAccountId: existing.id } : {}),
|
|
419
|
-
...(!existing ?
|
|
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
|
|
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
|
-
(
|
|
439
|
-
if (
|
|
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
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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.
|
|
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
|
-
.
|
|
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 =
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
645
|
-
|
|
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
|
-
|
|
657
|
-
|
|
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 =
|
|
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
|
-
.
|
|
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
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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 :
|
|
756
|
-
|
|
757
|
-
|
|
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
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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('
|
|
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 (
|
|
813
|
-
if (
|
|
814
|
-
console.error('
|
|
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
|
-
|
|
819
|
-
|
|
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
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
console.log(
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
console.log(
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
871
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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('
|
|
911
|
-
.
|
|
912
|
-
.
|
|
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
|
-
|
|
918
|
-
|
|
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('
|
|
927
|
-
.
|
|
928
|
-
.
|
|
929
|
-
|
|
930
|
-
const
|
|
931
|
-
|
|
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('
|
|
935
|
-
.
|
|
936
|
-
.
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const
|
|
940
|
-
if (
|
|
941
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
|
1661
|
+
.description('Show mailbox and provider status for the selected instance')
|
|
977
1662
|
.action(async () => {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
|
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() {
|