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