fluxmail 0.3.0 → 0.4.1
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 +15 -55
- package/dist/accounts/gmailConnection.d.ts +5 -1
- package/dist/accounts/gmailConnection.d.ts.map +1 -1
- package/dist/accounts/gmailConnection.js +14 -2
- package/dist/accounts/gmailConnection.js.map +1 -1
- package/dist/accounts/googleAuth.js +1 -1
- package/dist/accounts/googleAuth.js.map +1 -1
- package/dist/accounts/microsoftAuth.d.ts +21 -0
- package/dist/accounts/microsoftAuth.d.ts.map +1 -0
- package/dist/accounts/microsoftAuth.js +220 -0
- package/dist/accounts/microsoftAuth.js.map +1 -0
- package/dist/accounts/registry.d.ts +21 -8
- package/dist/accounts/registry.d.ts.map +1 -1
- package/dist/accounts/registry.js +190 -24
- package/dist/accounts/registry.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +857 -647
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +102 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +114 -14
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +2 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +7 -1
- package/dist/context.js.map +1 -1
- package/dist/http/admin.d.ts +21 -0
- package/dist/http/admin.d.ts.map +1 -0
- package/dist/http/admin.js +618 -0
- package/dist/http/admin.js.map +1 -0
- package/dist/http/app.d.ts +2 -0
- package/dist/http/app.d.ts.map +1 -1
- package/dist/http/app.js +322 -42
- package/dist/http/app.js.map +1 -1
- package/dist/http/rest.d.ts +28 -0
- package/dist/http/rest.d.ts.map +1 -0
- package/dist/http/rest.js +1130 -0
- package/dist/http/rest.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/licensing/refresher.d.ts +23 -7
- package/dist/licensing/refresher.d.ts.map +1 -1
- package/dist/licensing/refresher.js +93 -37
- package/dist/licensing/refresher.js.map +1 -1
- package/dist/mcp/buildServer.d.ts.map +1 -1
- package/dist/mcp/buildServer.js +1 -1
- package/dist/mcp/buildServer.js.map +1 -1
- package/dist/permissions.d.ts +15 -4
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +61 -12
- package/dist/permissions.js.map +1 -1
- package/dist/service/emailService.d.ts +15 -8
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +42 -17
- package/dist/service/emailService.js.map +1 -1
- package/dist/storage/adminAudit.d.ts +14 -0
- package/dist/storage/adminAudit.d.ts.map +1 -0
- package/dist/storage/adminAudit.js +32 -0
- package/dist/storage/adminAudit.js.map +1 -0
- package/dist/storage/apiKeys.d.ts +25 -7
- package/dist/storage/apiKeys.d.ts.map +1 -1
- package/dist/storage/apiKeys.js +104 -12
- package/dist/storage/apiKeys.js.map +1 -1
- package/dist/storage/db.d.ts +486 -0
- package/dist/storage/db.d.ts.map +1 -1
- package/dist/storage/db.js +171 -8
- package/dist/storage/db.js.map +1 -1
- package/dist/storage/gmailConnectionGrants.d.ts +9 -0
- package/dist/storage/gmailConnectionGrants.d.ts.map +1 -1
- package/dist/storage/gmailConnectionGrants.js +30 -7
- package/dist/storage/gmailConnectionGrants.js.map +1 -1
- package/dist/storage/members.d.ts +7 -4
- package/dist/storage/members.d.ts.map +1 -1
- package/dist/storage/members.js +34 -8
- package/dist/storage/members.js.map +1 -1
- package/dist/storage/restIdempotency.d.ts +28 -0
- package/dist/storage/restIdempotency.d.ts.map +1 -0
- package/dist/storage/restIdempotency.js +47 -0
- package/dist/storage/restIdempotency.js.map +1 -0
- package/package.json +10 -8
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
2
3
|
import { emitKeypressEvents } from 'node:readline';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
3
6
|
import { Command } from 'commander';
|
|
4
7
|
import { serve } from '@hono/node-server';
|
|
5
8
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
@@ -9,37 +12,64 @@ import { configFilePath, maskStoredConfigValue, readStoredConfig, resolveDataDir
|
|
|
9
12
|
import { createApp } from './http/app.js';
|
|
10
13
|
import { buildMcpServer } from './mcp/buildServer.js';
|
|
11
14
|
import { runLoopbackFlow } from './accounts/googleAuth.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
+
import { runMicrosoftLoopbackFlow } from './accounts/microsoftAuth.js';
|
|
16
|
+
import { prepareHostedGmailConnection, prepareHostedOutlookConnection, selectGmailConnectionMode, validateAccountConnectionFlags, } from './accounts/gmailConnection.js';
|
|
17
|
+
import { createApiKey, listApiKeys, revokeApiKey, updateApiKeyAccounts, updateApiKeyPermissions, } from './storage/apiKeys.js';
|
|
18
|
+
import { addMember, findMember, listMembers, removeMember, setMemberRole } from './storage/members.js';
|
|
15
19
|
import { activateLicense } from './licensing/activation.js';
|
|
16
20
|
import { LICENSE_KEY_PATTERN, releaseLicense } from './licensing/client.js';
|
|
17
21
|
import { licensePublicKeys, verifyLease } from './licensing/lease.js';
|
|
18
22
|
import { checkLicenseState, clearLease, getEntitlements, readLeaseRow, } from './licensing/entitlements.js';
|
|
19
|
-
import { loadInstanceId
|
|
23
|
+
import { loadInstanceId } from './licensing/refresher.js';
|
|
20
24
|
import { VERSION } from './version.js';
|
|
21
|
-
import {
|
|
22
|
-
import { customPermissionPolicy, FULL_PERMISSION_POLICY, isNamedPermissionProfile, MCP_CAPABILITIES, NAMED_PERMISSION_PROFILES, permissionPolicyForProfile, } from './permissions.js';
|
|
25
|
+
import { customPermissionPolicy, ADMIN_CAPABILITIES, FULL_PERMISSION_POLICY, isNamedPermissionProfile, MCP_CAPABILITIES, NAMED_PERMISSION_PROFILES, permissionPolicyForProfile, } from './permissions.js';
|
|
23
26
|
import { getTelemetry, installTelemetryStreamEndHandler, isTelemetryEnabled, setTelemetryEnabled, shutdownTelemetry, } from './telemetry.js';
|
|
24
27
|
function collectOption(value, previous) {
|
|
25
28
|
return [...previous, value];
|
|
26
29
|
}
|
|
30
|
+
function accountIdsFromRefs(ctx, refs) {
|
|
31
|
+
return refs.length ? [...new Set(refs.map((ref) => ctx.registry.findAccount(ref).id))] : null;
|
|
32
|
+
}
|
|
33
|
+
function sharingMode(shared, sharedMemberIds) {
|
|
34
|
+
if (shared)
|
|
35
|
+
return 'all';
|
|
36
|
+
if (sharedMemberIds.length)
|
|
37
|
+
return 'selected';
|
|
38
|
+
return 'private';
|
|
39
|
+
}
|
|
27
40
|
export function permissionPolicyFromOptions(opts, requireSelection = false) {
|
|
41
|
+
const supplemental = opts.admin ?? [];
|
|
28
42
|
if (opts.profile && opts.allow.length) {
|
|
29
43
|
throw new Error('--profile cannot be combined with --allow.');
|
|
30
44
|
}
|
|
45
|
+
if (opts.allow.length && supplemental.length) {
|
|
46
|
+
throw new Error('--admin can only be combined with a named --profile. Put admin capabilities in --allow for a custom policy.');
|
|
47
|
+
}
|
|
31
48
|
if (opts.profile) {
|
|
32
49
|
if (!isNamedPermissionProfile(opts.profile)) {
|
|
33
50
|
throw new Error(`Unknown profile "${opts.profile}". Expected one of: ${NAMED_PERMISSION_PROFILES.join(', ')}.`);
|
|
34
51
|
}
|
|
35
|
-
return permissionPolicyForProfile(opts.profile);
|
|
52
|
+
return permissionPolicyForProfile(opts.profile, supplemental);
|
|
36
53
|
}
|
|
37
54
|
if (opts.allow.length)
|
|
38
55
|
return customPermissionPolicy(opts.allow);
|
|
56
|
+
if (supplemental.length)
|
|
57
|
+
return permissionPolicyForProfile('full', supplemental);
|
|
39
58
|
if (requireSelection)
|
|
40
59
|
throw new Error('Choose --profile or at least one --allow capability.');
|
|
41
60
|
return FULL_PERMISSION_POLICY;
|
|
42
61
|
}
|
|
62
|
+
export function permissionPolicyForUpdate(opts, existing) {
|
|
63
|
+
const supplemental = opts.admin ?? [];
|
|
64
|
+
if (opts.profile || opts.allow.length)
|
|
65
|
+
return permissionPolicyFromOptions(opts, true);
|
|
66
|
+
if (!supplemental.length)
|
|
67
|
+
throw new Error('Choose --profile, --admin, or at least one --allow capability.');
|
|
68
|
+
if (existing.permissionProfile === 'custom') {
|
|
69
|
+
throw new Error('This key uses a custom policy. Pass every capability with --allow.');
|
|
70
|
+
}
|
|
71
|
+
return permissionPolicyForProfile(existing.permissionProfile, supplemental);
|
|
72
|
+
}
|
|
43
73
|
function connectionPort(value, option) {
|
|
44
74
|
const port = Number(value);
|
|
45
75
|
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
@@ -114,683 +144,863 @@ function warnLicense(db, log = console.error) {
|
|
|
114
144
|
if (warning)
|
|
115
145
|
log(`Warning: ${warning}`);
|
|
116
146
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
names
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
/** Build the command tree without parsing arguments or running command actions. */
|
|
148
|
+
export function createCliProgram() {
|
|
149
|
+
const program = new Command();
|
|
150
|
+
program
|
|
151
|
+
.name('fluxmail')
|
|
152
|
+
.description('Fluxmail, a self-hosted MCP server for your email')
|
|
153
|
+
.version(VERSION, '-v, --version');
|
|
154
|
+
function commandPath(command) {
|
|
155
|
+
const names = [];
|
|
156
|
+
for (let current = command; current?.parent; current = current.parent)
|
|
157
|
+
names.unshift(current.name());
|
|
158
|
+
return names.join(' ');
|
|
159
|
+
}
|
|
160
|
+
let telemetrySignalHandlersInstalled = false;
|
|
161
|
+
const TELEMETRY_SIGNAL_SHUTDOWN_TIMEOUT_MS = 1_000;
|
|
162
|
+
/** Flush queued telemetry before restoring Node's default signal behavior. */
|
|
163
|
+
function installTelemetrySignalHandlers() {
|
|
164
|
+
if (telemetrySignalHandlersInstalled)
|
|
165
|
+
return;
|
|
166
|
+
telemetrySignalHandlersInstalled = true;
|
|
167
|
+
const shutdownAndResignal = (signal) => {
|
|
168
|
+
process.off('SIGINT', onSigint);
|
|
169
|
+
process.off('SIGTERM', onSigterm);
|
|
170
|
+
process.off('SIGHUP', onSighup);
|
|
171
|
+
let resignaled = false;
|
|
172
|
+
const resignal = () => {
|
|
173
|
+
if (resignaled)
|
|
174
|
+
return;
|
|
175
|
+
resignaled = true;
|
|
176
|
+
process.kill(process.pid, signal);
|
|
177
|
+
};
|
|
178
|
+
const timeout = setTimeout(resignal, TELEMETRY_SIGNAL_SHUTDOWN_TIMEOUT_MS);
|
|
179
|
+
void shutdownTelemetry().finally(() => {
|
|
180
|
+
clearTimeout(timeout);
|
|
181
|
+
resignal();
|
|
182
|
+
});
|
|
145
183
|
};
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
184
|
+
const onSigint = () => shutdownAndResignal('SIGINT');
|
|
185
|
+
const onSigterm = () => shutdownAndResignal('SIGTERM');
|
|
186
|
+
const onSighup = () => shutdownAndResignal('SIGHUP');
|
|
187
|
+
process.once('SIGINT', onSigint);
|
|
188
|
+
process.once('SIGTERM', onSigterm);
|
|
189
|
+
process.once('SIGHUP', onSighup);
|
|
190
|
+
}
|
|
191
|
+
program.hook('preAction', (_command, actionCommand) => {
|
|
192
|
+
const command = commandPath(actionCommand);
|
|
193
|
+
// Respect the opt-out before creating a telemetry client or recording this command.
|
|
194
|
+
if (command === 'telemetry disable')
|
|
195
|
+
return;
|
|
196
|
+
const dataDir = resolveDataDir();
|
|
197
|
+
getTelemetry(dataDir).capture('cli command used', {
|
|
198
|
+
product_surface: 'cli',
|
|
199
|
+
command,
|
|
150
200
|
});
|
|
151
|
-
};
|
|
152
|
-
const onSigint = () => shutdownAndResignal('SIGINT');
|
|
153
|
-
const onSigterm = () => shutdownAndResignal('SIGTERM');
|
|
154
|
-
const onSighup = () => shutdownAndResignal('SIGHUP');
|
|
155
|
-
process.once('SIGINT', onSigint);
|
|
156
|
-
process.once('SIGTERM', onSigterm);
|
|
157
|
-
process.once('SIGHUP', onSighup);
|
|
158
|
-
}
|
|
159
|
-
program.hook('preAction', (_command, actionCommand) => {
|
|
160
|
-
const command = commandPath(actionCommand);
|
|
161
|
-
// Respect the opt-out before creating a telemetry client or recording this command.
|
|
162
|
-
if (command === 'telemetry disable')
|
|
163
|
-
return;
|
|
164
|
-
const dataDir = resolveDataDir();
|
|
165
|
-
getTelemetry(dataDir).capture('cli command used', {
|
|
166
|
-
product_surface: 'cli',
|
|
167
|
-
command,
|
|
168
201
|
});
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
await shutdownTelemetry();
|
|
173
|
-
});
|
|
174
|
-
program
|
|
175
|
-
.command('serve')
|
|
176
|
-
.description('Run the HTTP server (Streamable HTTP MCP at /mcp)')
|
|
177
|
-
.action(() => {
|
|
178
|
-
installTelemetrySignalHandlers();
|
|
179
|
-
const ctx = createContext();
|
|
180
|
-
const app = createApp(ctx);
|
|
181
|
-
ctx.scheduler.start();
|
|
182
|
-
serve({ fetch: app.fetch, port: ctx.config.port }, () => {
|
|
183
|
-
ctx.telemetry.capture('mcp server started', { product_surface: 'mcp', transport: 'http' });
|
|
184
|
-
console.log(`Fluxmail listening on ${ctx.config.publicUrl}`);
|
|
185
|
-
console.log(` MCP endpoint: ${ctx.config.publicUrl}/mcp`);
|
|
186
|
-
console.log(` Auth mode: ${ctx.config.authMode}`);
|
|
187
|
-
if (ctx.config.authMode === 'apikey' && listApiKeys(ctx.db).length === 0) {
|
|
188
|
-
console.log(' Note: no API keys exist yet. Run "fluxmail apikey create --name <name>" to create one.');
|
|
189
|
-
}
|
|
190
|
-
const accounts = ctx.registry.listAccounts();
|
|
191
|
-
console.log(accounts.length
|
|
192
|
-
? ` Accounts: ${accounts.map((a) => `${a.email} (${a.status})`).join(', ')}`
|
|
193
|
-
: ' Accounts: none (run "fluxmail accounts add gmail" or "fluxmail accounts add imap")');
|
|
194
|
-
console.log(` Plan: ${planLine(getEntitlements(ctx.db))}`);
|
|
195
|
-
warnLicense(ctx.db, console.log);
|
|
196
|
-
startLicenseRefresher({
|
|
197
|
-
db: ctx.db,
|
|
198
|
-
config: ctx.config,
|
|
199
|
-
log: console.log,
|
|
200
|
-
onRefreshed: () => ctx.scheduler.wake(),
|
|
201
|
-
});
|
|
202
|
+
program.hook('postAction', async (_command, actionCommand) => {
|
|
203
|
+
if (actionCommand.name() !== 'serve' && actionCommand.name() !== 'stdio')
|
|
204
|
+
await shutdownTelemetry();
|
|
202
205
|
});
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
206
|
+
program
|
|
207
|
+
.command('serve')
|
|
208
|
+
.description('Run the HTTP server (MCP at /mcp and REST at /api/v1)')
|
|
209
|
+
.action(() => {
|
|
210
|
+
installTelemetrySignalHandlers();
|
|
211
|
+
const ctx = createContext();
|
|
212
|
+
const app = createApp(ctx);
|
|
213
|
+
ctx.scheduler.start();
|
|
214
|
+
serve({ fetch: app.fetch, port: ctx.config.port }, () => {
|
|
215
|
+
ctx.telemetry.capture('mcp server started', { product_surface: 'mcp', transport: 'http' });
|
|
216
|
+
console.log(`Fluxmail listening on ${ctx.config.publicUrl}`);
|
|
217
|
+
console.log(` MCP endpoint: ${ctx.config.publicUrl}/mcp`);
|
|
218
|
+
console.log(` REST API: ${ctx.config.publicUrl}/api/v1`);
|
|
219
|
+
console.log(` OpenAPI: ${ctx.config.publicUrl}/api/v1/openapi.json`);
|
|
220
|
+
console.log(` Auth mode: ${ctx.config.authMode}`);
|
|
221
|
+
if (ctx.config.authMode === 'apikey' && listApiKeys(ctx.db).length === 0) {
|
|
222
|
+
console.log(' Note: no API keys exist yet. Run "fluxmail apikey create --name <name> --member <member>" to create one.');
|
|
223
|
+
}
|
|
224
|
+
const accounts = ctx.registry.listAccounts();
|
|
225
|
+
console.log(accounts.length
|
|
226
|
+
? ` Accounts: ${accounts.map((a) => `${a.email} (${a.status})`).join(', ')}`
|
|
227
|
+
: ' Accounts: none (run "fluxmail accounts add gmail --owner <member>", "fluxmail accounts add outlook --owner <member>", or the IMAP equivalent)');
|
|
228
|
+
console.log(` Plan: ${planLine(getEntitlements(ctx.db))}`);
|
|
229
|
+
warnLicense(ctx.db, console.log);
|
|
230
|
+
ctx.licenseController.start(console.log);
|
|
231
|
+
});
|
|
219
232
|
});
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
233
|
+
program
|
|
234
|
+
.command('stdio')
|
|
235
|
+
.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
|
+
.option('--account <account>', 'Limit access to one mailbox; repeat as needed', collectOption, [])
|
|
238
|
+
.option('--profile <profile>', `Tool profile: ${NAMED_PERMISSION_PROFILES.join(', ')}`)
|
|
239
|
+
.option('--allow <capability>', 'Allow one MCP capability; repeat as needed', collectOption, [])
|
|
240
|
+
.action(async (opts) => {
|
|
241
|
+
installTelemetrySignalHandlers();
|
|
242
|
+
installTelemetryStreamEndHandler(process.stdin);
|
|
243
|
+
const ctx = createContext();
|
|
244
|
+
const permissions = permissionPolicyFromOptions(opts);
|
|
245
|
+
const member = findMember(ctx.db, opts.member);
|
|
246
|
+
const accountIds = accountIdsFromRefs(ctx, opts.account);
|
|
247
|
+
const scopedService = ctx.service.withScope({ memberId: member.id, role: member.role, accountIds });
|
|
248
|
+
const server = buildMcpServer(scopedService, {
|
|
249
|
+
permissions,
|
|
250
|
+
maxAttachmentBytes: ctx.config.maxAttachmentBytes,
|
|
251
|
+
telemetry: ctx.telemetry,
|
|
252
|
+
transport: 'stdio',
|
|
253
|
+
});
|
|
254
|
+
ctx.scheduler.start();
|
|
255
|
+
await server.connect(new StdioServerTransport());
|
|
256
|
+
ctx.telemetry.capture('mcp server started', { product_surface: 'mcp', transport: 'stdio' });
|
|
257
|
+
// stdout belongs to the MCP protocol; log to stderr only.
|
|
258
|
+
ctx.licenseController.start(console.error);
|
|
259
|
+
console.error('Fluxmail MCP server running on stdio');
|
|
260
|
+
warnLicense(ctx.db);
|
|
261
|
+
const pending = scopedService.listScheduled().filter((send) => send.status === 'pending').length;
|
|
262
|
+
if (pending > 0)
|
|
263
|
+
console.error(`Scheduled sends pending: ${pending}`);
|
|
229
264
|
});
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
console.error(`Provider "${provider}" is not supported. Available: gmail, imap`);
|
|
266
|
-
process.exitCode = 1;
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
const ctx = createContext();
|
|
270
|
-
warnLicense(ctx.db);
|
|
271
|
-
try {
|
|
272
|
-
validateAccountConnectionFlags(provider, opts);
|
|
273
|
-
if (opts.reauthorize && opts.member) {
|
|
274
|
-
throw new EmailError('invalid_request', '--member cannot be combined with --reauthorize; use "fluxmail accounts assign" to change ownership.');
|
|
275
|
-
}
|
|
276
|
-
// Resolve before the OAuth flow so a typo fails fast.
|
|
277
|
-
const member = opts.member ? findMember(ctx.db, opts.member) : undefined;
|
|
278
|
-
const existing = opts.reauthorize ? ctx.registry.getAccount(opts.reauthorize) : undefined;
|
|
279
|
-
if (existing && existing.provider !== provider) {
|
|
280
|
-
throw new EmailError('invalid_request', `Account ${existing.id} uses ${existing.provider}, not ${provider}.`);
|
|
281
|
-
}
|
|
282
|
-
if (!existing)
|
|
283
|
-
ctx.registry.assertCanAddAccount();
|
|
284
|
-
if (provider === 'imap') {
|
|
285
|
-
const previousCredentials = existing ? ctx.registry.loadImapCredentials(existing.id) : undefined;
|
|
286
|
-
const email = opts.email?.trim();
|
|
287
|
-
if (!email || !email.includes('@'))
|
|
288
|
-
throw new EmailError('invalid_request', '--email must be a mailbox address.');
|
|
289
|
-
if (!opts.imapHost)
|
|
290
|
-
throw new EmailError('invalid_request', '--imap-host is required for IMAP accounts.');
|
|
291
|
-
if (!opts.smtpHost)
|
|
292
|
-
throw new EmailError('invalid_request', '--smtp-host is required for IMAP accounts.');
|
|
293
|
-
if (existing && existing.email !== email) {
|
|
294
|
-
throw new EmailError('invalid_request', `Account ${existing.id} belongs to ${existing.email}, not ${email}.`);
|
|
295
|
-
}
|
|
296
|
-
const imapPassword = await accountSecret(opts.imapPasswordEnv, 'IMAP password');
|
|
297
|
-
const smtpPassword = opts.smtpPasswordEnv
|
|
298
|
-
? await accountSecret(opts.smtpPasswordEnv, 'SMTP password')
|
|
299
|
-
: imapPassword;
|
|
300
|
-
const imapUser = opts.imapUser ?? email;
|
|
301
|
-
const credentials = {
|
|
302
|
-
imap: {
|
|
303
|
-
host: opts.imapHost,
|
|
304
|
-
port: connectionPort(opts.imapPort, '--imap-port'),
|
|
305
|
-
security: connectionSecurity(opts.imapSecurity, '--imap-security'),
|
|
306
|
-
user: imapUser,
|
|
307
|
-
password: imapPassword,
|
|
308
|
-
},
|
|
309
|
-
smtp: {
|
|
310
|
-
host: opts.smtpHost,
|
|
311
|
-
port: connectionPort(opts.smtpPort, '--smtp-port'),
|
|
312
|
-
security: connectionSecurity(opts.smtpSecurity, '--smtp-security'),
|
|
313
|
-
user: opts.smtpUser ?? imapUser,
|
|
314
|
-
password: smtpPassword,
|
|
315
|
-
},
|
|
316
|
-
saveSent: previousCredentials && command.getOptionValueSource('saveSent') === 'default'
|
|
317
|
-
? previousCredentials.saveSent
|
|
318
|
-
: opts.saveSent,
|
|
319
|
-
folderOverrides: {
|
|
320
|
-
...previousCredentials?.folderOverrides,
|
|
321
|
-
...(opts.sentFolder ? { sent: opts.sentFolder } : {}),
|
|
322
|
-
...(opts.draftsFolder ? { drafts: opts.draftsFolder } : {}),
|
|
323
|
-
...(opts.trashFolder ? { trash: opts.trashFolder } : {}),
|
|
324
|
-
...(opts.archiveFolder ? { archive: opts.archiveFolder } : {}),
|
|
325
|
-
...(opts.spamFolder ? { spam: opts.spamFolder } : {}),
|
|
326
|
-
},
|
|
327
|
-
};
|
|
328
|
-
console.log('Checking IMAP and SMTP settings...');
|
|
329
|
-
const warnings = await ctx.registry.testImapCredentials(email, credentials, opts.displayName);
|
|
330
|
-
const account = ctx.registry.addImapAccount(email, credentials, opts.displayName, member?.id, opts.reauthorize);
|
|
331
|
-
console.log(`Connected ${account.email} (account id: ${account.id})`);
|
|
332
|
-
for (const warning of warnings)
|
|
333
|
-
console.log(`Warning: ${warning.message}.`);
|
|
265
|
+
const accounts = program.command('accounts').description('Manage connected email accounts');
|
|
266
|
+
accounts
|
|
267
|
+
.command('add')
|
|
268
|
+
.argument('<provider>', 'Email provider: gmail, outlook, or imap')
|
|
269
|
+
.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
|
+
.option('--local', 'Use the local browser callback for OAuth')
|
|
275
|
+
.option('--hosted', 'Use FLUXMAIL_PUBLIC_URL for the OAuth callback')
|
|
276
|
+
.option('--email <address>', 'Mailbox address (required for IMAP)')
|
|
277
|
+
.option('--display-name <name>', 'Sender name for IMAP messages')
|
|
278
|
+
.option('--imap-host <host>', 'IMAP server hostname')
|
|
279
|
+
.option('--imap-port <port>', 'IMAP server port', '993')
|
|
280
|
+
.option('--imap-security <mode>', 'IMAP security: tls or starttls', 'tls')
|
|
281
|
+
.option('--imap-user <user>', 'IMAP username; defaults to the mailbox address')
|
|
282
|
+
.option('--imap-password-env <name>', 'Read the IMAP password from this environment variable')
|
|
283
|
+
.option('--smtp-host <host>', 'SMTP server hostname')
|
|
284
|
+
.option('--smtp-port <port>', 'SMTP server port', '587')
|
|
285
|
+
.option('--smtp-security <mode>', 'SMTP security: tls or starttls', 'starttls')
|
|
286
|
+
.option('--smtp-user <user>', 'SMTP username; defaults to the IMAP username')
|
|
287
|
+
.option('--smtp-password-env <name>', 'Read a separate SMTP password from this environment variable')
|
|
288
|
+
.option('--sent-folder <path>', 'Sent mailbox path')
|
|
289
|
+
.option('--drafts-folder <path>', 'Drafts mailbox path')
|
|
290
|
+
.option('--trash-folder <path>', 'Trash mailbox path')
|
|
291
|
+
.option('--archive-folder <path>', 'Archive mailbox path')
|
|
292
|
+
.option('--spam-folder <path>', 'Spam mailbox path')
|
|
293
|
+
.option('--no-save-sent', 'Do not append SMTP submissions to the Sent folder')
|
|
294
|
+
.description('Connect a Gmail, Outlook, or IMAP account')
|
|
295
|
+
.action(async (providerInput, opts, command) => {
|
|
296
|
+
const provider = providerInput === 'microsoft' ? 'outlook' : providerInput;
|
|
297
|
+
if (provider !== 'gmail' && provider !== 'outlook' && provider !== 'imap') {
|
|
298
|
+
console.error(`Provider "${providerInput}" is not supported. Available: gmail, outlook, imap`);
|
|
299
|
+
process.exitCode = 1;
|
|
334
300
|
return;
|
|
335
301
|
}
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
302
|
+
const ctx = createContext();
|
|
303
|
+
warnLicense(ctx.db);
|
|
304
|
+
try {
|
|
305
|
+
validateAccountConnectionFlags(provider, opts);
|
|
306
|
+
if (opts.owner && opts.member) {
|
|
307
|
+
throw new EmailError('invalid_request', '--owner and --member cannot be combined. Use --owner.');
|
|
308
|
+
}
|
|
309
|
+
if (opts.shared && opts.shareWith.length) {
|
|
310
|
+
throw new EmailError('invalid_request', '--shared and --share-with cannot be combined.');
|
|
311
|
+
}
|
|
312
|
+
const ownerRef = opts.owner ?? opts.member;
|
|
313
|
+
if (opts.member)
|
|
314
|
+
console.error('Warning: --member is deprecated; use --owner.');
|
|
315
|
+
if (opts.reauthorize && (ownerRef || opts.shared || opts.shareWith.length)) {
|
|
316
|
+
throw new EmailError('invalid_request', 'Ownership and sharing options cannot be combined with --reauthorize. Use "fluxmail accounts assign" or "fluxmail accounts access".');
|
|
317
|
+
}
|
|
318
|
+
// Resolve before the OAuth flow so a typo fails fast.
|
|
319
|
+
const owner = ownerRef ? findMember(ctx.db, ownerRef) : undefined;
|
|
320
|
+
const existing = opts.reauthorize ? ctx.registry.getAccount(opts.reauthorize) : undefined;
|
|
321
|
+
if (existing && existing.provider !== provider) {
|
|
322
|
+
throw new EmailError('invalid_request', `Account ${existing.id} uses ${existing.provider}, not ${provider}.`);
|
|
323
|
+
}
|
|
324
|
+
if (!existing)
|
|
325
|
+
ctx.registry.assertCanAddAccount();
|
|
326
|
+
if (!existing && !owner) {
|
|
327
|
+
throw new EmailError('invalid_request', '--owner <id-or-email> is required when connecting a mailbox.');
|
|
328
|
+
}
|
|
329
|
+
const sharedMemberIds = opts.shareWith.map((ref) => findMember(ctx.db, ref).id);
|
|
330
|
+
const access = {
|
|
331
|
+
sharingMode: sharingMode(opts.shared, sharedMemberIds),
|
|
332
|
+
sharedMemberIds,
|
|
333
|
+
};
|
|
334
|
+
if (provider === 'imap') {
|
|
335
|
+
const previousCredentials = existing ? ctx.registry.loadImapCredentials(existing.id) : undefined;
|
|
336
|
+
const email = opts.email?.trim();
|
|
337
|
+
if (!email || !email.includes('@'))
|
|
338
|
+
throw new EmailError('invalid_request', '--email must be a mailbox address.');
|
|
339
|
+
if (!opts.imapHost)
|
|
340
|
+
throw new EmailError('invalid_request', '--imap-host is required for IMAP accounts.');
|
|
341
|
+
if (!opts.smtpHost)
|
|
342
|
+
throw new EmailError('invalid_request', '--smtp-host is required for IMAP accounts.');
|
|
343
|
+
if (existing && existing.email !== email) {
|
|
344
|
+
throw new EmailError('invalid_request', `Account ${existing.id} belongs to ${existing.email}, not ${email}.`);
|
|
345
|
+
}
|
|
346
|
+
const imapPassword = await accountSecret(opts.imapPasswordEnv, 'IMAP password');
|
|
347
|
+
const smtpPassword = opts.smtpPasswordEnv
|
|
348
|
+
? await accountSecret(opts.smtpPasswordEnv, 'SMTP password')
|
|
349
|
+
: imapPassword;
|
|
350
|
+
const imapUser = opts.imapUser ?? email;
|
|
351
|
+
const credentials = {
|
|
352
|
+
imap: {
|
|
353
|
+
host: opts.imapHost,
|
|
354
|
+
port: connectionPort(opts.imapPort, '--imap-port'),
|
|
355
|
+
security: connectionSecurity(opts.imapSecurity, '--imap-security'),
|
|
356
|
+
user: imapUser,
|
|
357
|
+
password: imapPassword,
|
|
358
|
+
},
|
|
359
|
+
smtp: {
|
|
360
|
+
host: opts.smtpHost,
|
|
361
|
+
port: connectionPort(opts.smtpPort, '--smtp-port'),
|
|
362
|
+
security: connectionSecurity(opts.smtpSecurity, '--smtp-security'),
|
|
363
|
+
user: opts.smtpUser ?? imapUser,
|
|
364
|
+
password: smtpPassword,
|
|
365
|
+
},
|
|
366
|
+
saveSent: previousCredentials && command.getOptionValueSource('saveSent') === 'default'
|
|
367
|
+
? previousCredentials.saveSent
|
|
368
|
+
: opts.saveSent,
|
|
369
|
+
folderOverrides: {
|
|
370
|
+
...previousCredentials?.folderOverrides,
|
|
371
|
+
...(opts.sentFolder ? { sent: opts.sentFolder } : {}),
|
|
372
|
+
...(opts.draftsFolder ? { drafts: opts.draftsFolder } : {}),
|
|
373
|
+
...(opts.trashFolder ? { trash: opts.trashFolder } : {}),
|
|
374
|
+
...(opts.archiveFolder ? { archive: opts.archiveFolder } : {}),
|
|
375
|
+
...(opts.spamFolder ? { spam: opts.spamFolder } : {}),
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
console.log('Checking IMAP and SMTP settings...');
|
|
379
|
+
const warnings = await ctx.registry.testImapCredentials(email, credentials, opts.displayName);
|
|
380
|
+
const account = ctx.registry.addImapAccount(email, credentials, opts.displayName, owner?.id, opts.reauthorize, access);
|
|
381
|
+
console.log(`Connected ${account.email} (account id: ${account.id})`);
|
|
382
|
+
for (const warning of warnings)
|
|
383
|
+
console.log(`Warning: ${warning.message}.`);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (provider === 'outlook') {
|
|
387
|
+
const connectionMode = selectGmailConnectionMode(ctx.config, opts);
|
|
388
|
+
if (connectionMode === 'hosted') {
|
|
389
|
+
const { connectionUrl } = prepareHostedOutlookConnection(ctx.db, ctx.config, {
|
|
390
|
+
...(owner ? { memberId: owner.id } : {}),
|
|
391
|
+
...(existing ? { reauthorizeAccountId: existing.id } : {}),
|
|
392
|
+
...(!existing ? { sharingMode: access.sharingMode, sharedMemberIds: access.sharedMemberIds } : {}),
|
|
393
|
+
});
|
|
394
|
+
console.log('\nOpen this URL in your browser to connect Outlook:\n');
|
|
395
|
+
console.log(` ${connectionUrl}\n`);
|
|
396
|
+
console.log('This link expires in 10 minutes and can only be used once.');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const account = await runMicrosoftLoopbackFlow(ctx.config, (url) => {
|
|
400
|
+
console.log('\nOpen this URL in your browser to authorize Microsoft mail access:\n');
|
|
401
|
+
console.log(` ${url}\n`);
|
|
402
|
+
console.log('Waiting for Microsoft to redirect back...');
|
|
403
|
+
}, (result) => {
|
|
404
|
+
if (existing && result.email.toLowerCase() !== existing.email.toLowerCase()) {
|
|
405
|
+
throw new EmailError('invalid_request', `Microsoft authorized ${result.email}, but account ${existing.id} belongs to ${existing.email}. ` +
|
|
406
|
+
'Try again and choose the matching Microsoft account.');
|
|
407
|
+
}
|
|
408
|
+
return ctx.registry.addOutlookAccount(result.email, result.credentials, result.displayName, owner?.id, existing?.id, access);
|
|
409
|
+
});
|
|
410
|
+
console.log(`\nConnected ${account.email} (account id: ${account.id})` +
|
|
411
|
+
(owner && account.ownerId === owner.id ? ` for member ${owner.name}` : ''));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const gmailConnectionMode = selectGmailConnectionMode(ctx.config, opts);
|
|
415
|
+
if (gmailConnectionMode === 'hosted') {
|
|
416
|
+
const { connectionUrl } = prepareHostedGmailConnection(ctx.db, ctx.config, {
|
|
417
|
+
...(owner ? { memberId: owner.id } : {}),
|
|
418
|
+
...(existing ? { reauthorizeAccountId: existing.id } : {}),
|
|
419
|
+
...(!existing ? { sharingMode: access.sharingMode, sharedMemberIds: access.sharedMemberIds } : {}),
|
|
420
|
+
});
|
|
421
|
+
console.log('\nOpen this URL in your browser to connect Gmail:\n');
|
|
422
|
+
console.log(` ${connectionUrl}\n`);
|
|
423
|
+
console.log('This link expires in 10 minutes and can only be used once.');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const account = await runLoopbackFlow(ctx.config, (url) => {
|
|
427
|
+
console.log('\nOpen this URL in your browser to authorize Gmail access:\n');
|
|
428
|
+
console.log(` ${url}\n`);
|
|
429
|
+
console.log('Waiting for Google to redirect back…');
|
|
430
|
+
}, (result) => {
|
|
431
|
+
if (existing && result.email !== existing.email) {
|
|
432
|
+
throw new EmailError('invalid_request', `Google authorized ${result.email}, but account ${existing.id} belongs to ${existing.email}. ` +
|
|
433
|
+
'Try again and choose the matching Google account.');
|
|
434
|
+
}
|
|
435
|
+
return ctx.registry.addGmailAccount(result.email, result.tokens, result.displayName, owner?.id, access);
|
|
341
436
|
});
|
|
342
|
-
console.log(
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
console.log('\nOpen this URL in your browser to authorize Gmail access:\n');
|
|
349
|
-
console.log(` ${url}\n`);
|
|
350
|
-
console.log('Waiting for Google to redirect back…');
|
|
351
|
-
}, (result) => {
|
|
352
|
-
if (existing && result.email !== existing.email) {
|
|
353
|
-
throw new EmailError('invalid_request', `Google authorized ${result.email}, but account ${existing.id} belongs to ${existing.email}. ` +
|
|
354
|
-
'Try again and choose the matching Google account.');
|
|
437
|
+
console.log(`\nConnected ${account.email} (account id: ${account.id})` +
|
|
438
|
+
(owner && account.ownerId === owner.id ? ` for member ${owner.name}` : ''));
|
|
439
|
+
if (owner && account.ownerId !== owner.id) {
|
|
440
|
+
// The mailbox was already connected; re-auth keeps its existing owner.
|
|
441
|
+
console.log(`${account.email} was already connected, so its ownership is unchanged. ` +
|
|
442
|
+
`To assign it to ${owner.name}, run "fluxmail accounts assign ${account.id} --owner ${owner.id}".`);
|
|
355
443
|
}
|
|
356
|
-
return ctx.registry.addGmailAccount(result.email, result.tokens, result.displayName, member?.id);
|
|
357
|
-
});
|
|
358
|
-
console.log(`\nConnected ${account.email} (account id: ${account.id})` +
|
|
359
|
-
(member && account.memberId === member.id ? ` for member ${member.name}` : ''));
|
|
360
|
-
if (member && account.memberId !== member.id) {
|
|
361
|
-
// The mailbox was already connected; re-auth keeps its existing owner.
|
|
362
|
-
console.log(`${account.email} was already connected, so its ownership is unchanged. ` +
|
|
363
|
-
`To assign it to ${member.name}, run "fluxmail accounts assign ${account.id} --member ${member.id}".`);
|
|
364
444
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
warnLicense(ctx.db);
|
|
383
|
-
try {
|
|
384
|
-
const changes = [
|
|
385
|
-
['sent', opts.sentFolder],
|
|
386
|
-
['drafts', opts.draftsFolder],
|
|
387
|
-
['trash', opts.trashFolder],
|
|
388
|
-
['archive', opts.archiveFolder],
|
|
389
|
-
['spam', opts.spamFolder],
|
|
390
|
-
];
|
|
391
|
-
if (!changes.some(([, value]) => value !== undefined)) {
|
|
392
|
-
throw new EmailError('invalid_request', 'Pass at least one folder option.');
|
|
393
|
-
}
|
|
394
|
-
const credentials = ctx.registry.loadImapCredentials(accountId);
|
|
395
|
-
const provider = ctx.registry.getProvider(accountId);
|
|
445
|
+
catch (err) {
|
|
446
|
+
console.error(`\nError: ${err instanceof Error ? err.message : String(err)}`);
|
|
447
|
+
process.exitCode = 1;
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
accounts
|
|
451
|
+
.command('configure')
|
|
452
|
+
.argument('<accountId>')
|
|
453
|
+
.option('--sent-folder <path>', 'Sent path, or auto to clear the override')
|
|
454
|
+
.option('--drafts-folder <path>', 'Drafts path, or auto to clear the override')
|
|
455
|
+
.option('--trash-folder <path>', 'Trash path, or auto to clear the override')
|
|
456
|
+
.option('--archive-folder <path>', 'Archive path, or auto to clear the override')
|
|
457
|
+
.option('--spam-folder <path>', 'Spam path, or auto to clear the override')
|
|
458
|
+
.description('Set special folder paths for an IMAP account')
|
|
459
|
+
.action(async (accountId, opts) => {
|
|
460
|
+
const ctx = createContext();
|
|
461
|
+
warnLicense(ctx.db);
|
|
396
462
|
try {
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
throw new EmailError('not_found', `No selectable mailbox named "${value}".`);
|
|
407
|
-
}
|
|
408
|
-
overrides[role] = value;
|
|
409
|
-
}
|
|
463
|
+
const changes = [
|
|
464
|
+
['sent', opts.sentFolder],
|
|
465
|
+
['drafts', opts.draftsFolder],
|
|
466
|
+
['trash', opts.trashFolder],
|
|
467
|
+
['archive', opts.archiveFolder],
|
|
468
|
+
['spam', opts.spamFolder],
|
|
469
|
+
];
|
|
470
|
+
if (!changes.some(([, value]) => value !== undefined)) {
|
|
471
|
+
throw new EmailError('invalid_request', 'Pass at least one folder option.');
|
|
410
472
|
}
|
|
411
|
-
credentials
|
|
412
|
-
ctx.registry.
|
|
413
|
-
const checked = ctx.registry.getProvider(accountId);
|
|
473
|
+
const credentials = ctx.registry.loadImapCredentials(accountId);
|
|
474
|
+
const provider = ctx.registry.getProvider(accountId);
|
|
414
475
|
try {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
476
|
+
const existingPaths = new Set((await provider.listFolders()).map((folder) => folder.id));
|
|
477
|
+
const overrides = { ...credentials.folderOverrides };
|
|
478
|
+
for (const [role, value] of changes) {
|
|
479
|
+
if (value === undefined)
|
|
480
|
+
continue;
|
|
481
|
+
if (value === 'auto')
|
|
482
|
+
delete overrides[role];
|
|
483
|
+
else {
|
|
484
|
+
if (!existingPaths.has(value)) {
|
|
485
|
+
throw new EmailError('not_found', `No selectable mailbox named "${value}".`);
|
|
486
|
+
}
|
|
487
|
+
overrides[role] = value;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
credentials.folderOverrides = overrides;
|
|
491
|
+
ctx.registry.saveImapCredentials(accountId, credentials);
|
|
492
|
+
const checked = ctx.registry.getProvider(accountId);
|
|
493
|
+
try {
|
|
494
|
+
console.log(`Updated folder settings for ${accountId}.`);
|
|
495
|
+
for (const warning of (await checked.getFolderWarnings?.()) ?? []) {
|
|
496
|
+
console.log(`Warning: ${warning.message}.`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
await checked.close?.();
|
|
418
501
|
}
|
|
419
502
|
}
|
|
420
503
|
finally {
|
|
421
|
-
await
|
|
504
|
+
await provider.close?.();
|
|
422
505
|
}
|
|
423
506
|
}
|
|
424
|
-
|
|
425
|
-
|
|
507
|
+
catch (err) {
|
|
508
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
509
|
+
process.exitCode = 1;
|
|
426
510
|
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
warnLicense(ctx.db);
|
|
439
|
-
const all = ctx.registry.listAccounts();
|
|
440
|
-
if (!all.length) {
|
|
441
|
-
console.log('No accounts connected. Run "fluxmail accounts add gmail" or "fluxmail accounts add imap".');
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
const memberNames = new Map(listMembers(ctx.db).map((m) => [m.id, m.name]));
|
|
445
|
-
for (const a of all) {
|
|
446
|
-
const owner = a.memberId ? (memberNames.get(a.memberId) ?? a.memberId) : 'shared';
|
|
447
|
-
console.log(`${a.id} ${a.provider} ${a.email} [${a.status}] owner=${owner}`);
|
|
448
|
-
}
|
|
449
|
-
});
|
|
450
|
-
accounts
|
|
451
|
-
.command('remove')
|
|
452
|
-
.argument('<accountId>')
|
|
453
|
-
.description('Disconnect an account and delete its stored tokens')
|
|
454
|
-
.action((accountId) => {
|
|
455
|
-
const ctx = createContext();
|
|
456
|
-
warnLicense(ctx.db);
|
|
457
|
-
ctx.registry.removeAccount(accountId);
|
|
458
|
-
console.log(`Removed ${accountId}`);
|
|
459
|
-
});
|
|
460
|
-
accounts
|
|
461
|
-
.command('assign')
|
|
462
|
-
.argument('<accountId>')
|
|
463
|
-
.option('--member <member>', 'Member (id or email) to own the mailbox')
|
|
464
|
-
.option('--shared', 'Make the mailbox shared across the instance')
|
|
465
|
-
.description('Assign a mailbox to a member, or make it shared')
|
|
466
|
-
.action((accountId, opts) => {
|
|
467
|
-
const ctx = createContext();
|
|
468
|
-
warnLicense(ctx.db);
|
|
469
|
-
try {
|
|
470
|
-
if (!opts.member === !opts.shared) {
|
|
471
|
-
throw new EmailError('invalid_request', 'Pass exactly one of --member <member> or --shared.');
|
|
511
|
+
});
|
|
512
|
+
accounts
|
|
513
|
+
.command('list')
|
|
514
|
+
.description('List connected accounts')
|
|
515
|
+
.action(() => {
|
|
516
|
+
const ctx = createContext();
|
|
517
|
+
warnLicense(ctx.db);
|
|
518
|
+
const all = ctx.registry.listAccounts();
|
|
519
|
+
if (!all.length) {
|
|
520
|
+
console.log('No accounts connected. Run "fluxmail accounts add gmail --owner <member>", "fluxmail accounts add outlook --owner <member>", or the IMAP equivalent.');
|
|
521
|
+
return;
|
|
472
522
|
}
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
(member ? ` for member ${member.name}` : '') +
|
|
555
|
-
'\n');
|
|
556
|
-
console.log(` ${key}\n`);
|
|
557
|
-
console.log('Store it now; it cannot be shown again.');
|
|
558
|
-
}
|
|
559
|
-
catch (err) {
|
|
560
|
-
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
561
|
-
process.exitCode = 1;
|
|
562
|
-
}
|
|
563
|
-
});
|
|
564
|
-
apikey
|
|
565
|
-
.command('list')
|
|
566
|
-
.description('List API keys')
|
|
567
|
-
.action(() => {
|
|
568
|
-
const ctx = createContext();
|
|
569
|
-
warnLicense(ctx.db);
|
|
570
|
-
const keys = listApiKeys(ctx.db);
|
|
571
|
-
if (!keys.length) {
|
|
572
|
-
console.log('No API keys. Run "fluxmail apikey create --name <name>".');
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
const memberNames = new Map(listMembers(ctx.db).map((m) => [m.id, m.name]));
|
|
576
|
-
for (const k of keys) {
|
|
577
|
-
const lastUsed = k.lastUsedAt ? new Date(k.lastUsedAt).toISOString() : 'never';
|
|
578
|
-
const member = k.memberId ? (memberNames.get(k.memberId) ?? k.memberId) : '-';
|
|
579
|
-
console.log(`${k.id} ${k.name} member=${member} profile=${k.permissionProfile}` +
|
|
580
|
-
(k.permissionProfile === 'custom' ? ` allows=${k.capabilities.join(',')}` : '') +
|
|
581
|
-
` created=${new Date(k.createdAt).toISOString()} lastUsed=${lastUsed}`);
|
|
582
|
-
}
|
|
583
|
-
});
|
|
584
|
-
apikey
|
|
585
|
-
.command('permissions')
|
|
586
|
-
.argument('<keyId>')
|
|
587
|
-
.option('--profile <profile>', `Tool profile: ${NAMED_PERMISSION_PROFILES.join(', ')}`)
|
|
588
|
-
.option('--allow <capability>', 'Allow one MCP capability; repeat as needed', collectOption, [])
|
|
589
|
-
.description('Change the MCP permissions for an API key')
|
|
590
|
-
.action((keyId, opts) => {
|
|
591
|
-
const ctx = createContext();
|
|
592
|
-
warnLicense(ctx.db);
|
|
593
|
-
try {
|
|
594
|
-
const permissions = permissionPolicyFromOptions(opts, true);
|
|
595
|
-
if (!updateApiKeyPermissions(ctx.db, keyId, permissions)) {
|
|
596
|
-
console.error(`No key with id ${keyId}`);
|
|
523
|
+
const memberNames = new Map(listMembers(ctx.db).map((m) => [m.id, m.name]));
|
|
524
|
+
for (const a of all) {
|
|
525
|
+
const owner = a.ownerId ? (memberNames.get(a.ownerId) ?? a.ownerId) : '-';
|
|
526
|
+
const access = a.sharingMode === 'selected'
|
|
527
|
+
? `selected:${a.sharedMemberIds.map((id) => memberNames.get(id) ?? id).join(',')}`
|
|
528
|
+
: a.sharingMode;
|
|
529
|
+
console.log(`${a.id} ${a.provider} ${a.email} [${a.status}] owner=${owner} access=${access}`);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
accounts
|
|
533
|
+
.command('remove')
|
|
534
|
+
.argument('<accountId>')
|
|
535
|
+
.description('Disconnect an account and delete its stored tokens')
|
|
536
|
+
.action((accountId) => {
|
|
537
|
+
const ctx = createContext();
|
|
538
|
+
warnLicense(ctx.db);
|
|
539
|
+
ctx.registry.removeAccount(accountId);
|
|
540
|
+
console.log(`Removed ${accountId}`);
|
|
541
|
+
});
|
|
542
|
+
accounts
|
|
543
|
+
.command('assign')
|
|
544
|
+
.argument('<accountId>')
|
|
545
|
+
.option('--owner <member>', 'Member (id or email) to own the mailbox')
|
|
546
|
+
.option('--member <member>', 'Deprecated alias for --owner')
|
|
547
|
+
.option('--shared', 'Deprecated alias for sharing with all members')
|
|
548
|
+
.description('Change mailbox ownership')
|
|
549
|
+
.action((accountId, opts) => {
|
|
550
|
+
const ctx = createContext();
|
|
551
|
+
warnLicense(ctx.db);
|
|
552
|
+
try {
|
|
553
|
+
if (opts.owner && opts.member) {
|
|
554
|
+
throw new EmailError('invalid_request', '--owner and --member cannot be combined. Use --owner.');
|
|
555
|
+
}
|
|
556
|
+
const ownerRef = opts.owner ?? opts.member;
|
|
557
|
+
if (!ownerRef && !opts.shared) {
|
|
558
|
+
throw new EmailError('invalid_request', 'Pass --owner <member>.');
|
|
559
|
+
}
|
|
560
|
+
if (ownerRef && opts.shared) {
|
|
561
|
+
throw new EmailError('invalid_request', '--owner and --shared cannot be combined.');
|
|
562
|
+
}
|
|
563
|
+
if (opts.member)
|
|
564
|
+
console.error('Warning: --member is deprecated; use --owner.');
|
|
565
|
+
if (opts.shared) {
|
|
566
|
+
console.error('Warning: "accounts assign --shared" is deprecated; use "accounts access --shared".');
|
|
567
|
+
const account = ctx.registry.setAccountAccess(accountId, { sharingMode: 'all' });
|
|
568
|
+
console.log(`${account.email} is now shared with every member. Its owner is unchanged.`);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const owner = findMember(ctx.db, ownerRef);
|
|
572
|
+
const account = ctx.registry.assignAccountOwner(accountId, owner.id);
|
|
573
|
+
console.log(`${account.email} is now owned by ${owner.name}.`);
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
577
|
+
process.exitCode = 1;
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
accounts
|
|
581
|
+
.command('access')
|
|
582
|
+
.argument('<accountId>')
|
|
583
|
+
.option('--owner-only', 'Only the owner can access the mailbox')
|
|
584
|
+
.option('--shared', 'Share the mailbox with every member')
|
|
585
|
+
.option('--share-with <member>', 'Replace selected access with this member; repeat as needed', collectOption, [])
|
|
586
|
+
.description('Set who can access a mailbox')
|
|
587
|
+
.action((accountId, opts) => {
|
|
588
|
+
const ctx = createContext();
|
|
589
|
+
warnLicense(ctx.db);
|
|
590
|
+
try {
|
|
591
|
+
const choices = Number(Boolean(opts.ownerOnly)) + Number(Boolean(opts.shared)) + Number(opts.shareWith.length > 0);
|
|
592
|
+
if (choices !== 1) {
|
|
593
|
+
throw new EmailError('invalid_request', 'Pass exactly one of --owner-only, --shared, or one or more --share-with options.');
|
|
594
|
+
}
|
|
595
|
+
const sharedMemberIds = opts.shareWith.map((ref) => findMember(ctx.db, ref).id);
|
|
596
|
+
const account = ctx.registry.setAccountAccess(accountId, {
|
|
597
|
+
sharingMode: sharingMode(opts.shared, sharedMemberIds),
|
|
598
|
+
sharedMemberIds,
|
|
599
|
+
});
|
|
600
|
+
console.log(`Updated access for ${account.email}: ${account.sharingMode}.`);
|
|
601
|
+
}
|
|
602
|
+
catch (err) {
|
|
603
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
597
604
|
process.exitCode = 1;
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
const membersCmd = program.command('members').description('Manage members (people using this instance)');
|
|
608
|
+
membersCmd
|
|
609
|
+
.command('add')
|
|
610
|
+
.requiredOption('--name <name>', 'Member name')
|
|
611
|
+
.option('--email <email>', 'Member email, usable as a shorthand in --member flags')
|
|
612
|
+
.option('--role <role>', 'Member role: admin or member')
|
|
613
|
+
.description('Add a member (subject to the plan seat limit)')
|
|
614
|
+
.action((opts) => {
|
|
615
|
+
const ctx = createContext();
|
|
616
|
+
warnLicense(ctx.db);
|
|
617
|
+
try {
|
|
618
|
+
if (opts.role && opts.role !== 'admin' && opts.role !== 'member') {
|
|
619
|
+
throw new EmailError('invalid_request', '--role must be "admin" or "member".');
|
|
620
|
+
}
|
|
621
|
+
const member = addMember(ctx.db, {
|
|
622
|
+
name: opts.name,
|
|
623
|
+
...(opts.email ? { email: opts.email } : {}),
|
|
624
|
+
...(opts.role ? { role: opts.role } : {}),
|
|
625
|
+
});
|
|
626
|
+
console.log(`Added member ${member.name} (id: ${member.id}, role: ${member.role})`);
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
630
|
+
process.exitCode = 1;
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
membersCmd
|
|
634
|
+
.command('list')
|
|
635
|
+
.description('List members with their mailbox and API key counts')
|
|
636
|
+
.action(() => {
|
|
637
|
+
const ctx = createContext();
|
|
638
|
+
warnLicense(ctx.db);
|
|
639
|
+
const all = listMembers(ctx.db);
|
|
640
|
+
if (!all.length) {
|
|
641
|
+
console.log('No members. Run "fluxmail members add --name <name>".');
|
|
598
642
|
return;
|
|
599
643
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
604
|
-
process.exitCode = 1;
|
|
605
|
-
}
|
|
606
|
-
});
|
|
607
|
-
apikey
|
|
608
|
-
.command('revoke')
|
|
609
|
-
.argument('<keyId>')
|
|
610
|
-
.description('Revoke an API key')
|
|
611
|
-
.action((keyId) => {
|
|
612
|
-
const ctx = createContext();
|
|
613
|
-
warnLicense(ctx.db);
|
|
614
|
-
console.log(revokeApiKey(ctx.db, keyId) ? `Revoked ${keyId}` : `No key with id ${keyId}`);
|
|
615
|
-
});
|
|
616
|
-
const license = program.command('license').description('Manage the paid-tier license');
|
|
617
|
-
license
|
|
618
|
-
.command('activate')
|
|
619
|
-
.argument('<key>', 'License key (fluxmail_lic_…), shown once at purchase')
|
|
620
|
-
.description('Store a license key and validate it with the license server')
|
|
621
|
-
.action(async (key) => {
|
|
622
|
-
if (!LICENSE_KEY_PATTERN.test(key)) {
|
|
623
|
-
console.error('That does not look like a Fluxmail license key (expected "fluxmail_lic_" followed by 40 hex characters).');
|
|
624
|
-
process.exitCode = 1;
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
const dataDir = resolveDataDir();
|
|
628
|
-
const overridingKey = process.env.FLUXMAIL_LICENSE_KEY?.trim();
|
|
629
|
-
if (overridingKey && overridingKey !== key) {
|
|
630
|
-
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.');
|
|
631
|
-
process.exitCode = 1;
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
const ctx = createContext();
|
|
635
|
-
const result = await activateLicense(ctx.db, {
|
|
636
|
-
licenseKey: key,
|
|
637
|
-
serverUrl: ctx.config.licenseServerUrl,
|
|
638
|
-
dataDir,
|
|
644
|
+
for (const m of all) {
|
|
645
|
+
console.log(`${m.id} ${m.name} role=${m.role} email=${m.email ?? '-'} mailboxes=${m.accountCount} keys=${m.apiKeyCount}`);
|
|
646
|
+
}
|
|
639
647
|
});
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
console.error(result.message);
|
|
648
|
-
if (result.outcome === 'outage') {
|
|
649
|
-
console.error('The key is saved; validation will be retried automatically once the server can reach it.');
|
|
650
|
-
}
|
|
651
|
-
else {
|
|
652
|
-
process.exitCode = 1;
|
|
653
|
-
}
|
|
654
|
-
});
|
|
655
|
-
license
|
|
656
|
-
.command('status')
|
|
657
|
-
.description('Show the configured license and cached lease')
|
|
658
|
-
.action(() => {
|
|
659
|
-
const ctx = createContext();
|
|
660
|
-
if (ctx.config.licenseKey) {
|
|
661
|
-
console.log(`License key: ${maskStoredConfigValue('FLUXMAIL_LICENSE_KEY', ctx.config.licenseKey)}`);
|
|
662
|
-
}
|
|
663
|
-
else {
|
|
664
|
-
console.log('No license key configured. Run "fluxmail license activate <key>" after purchasing one.');
|
|
665
|
-
}
|
|
666
|
-
const row = readLeaseRow(ctx.db);
|
|
667
|
-
if (row) {
|
|
668
|
-
console.log(`Last validated: ${new Date(row.updatedAt).toISOString()}`);
|
|
648
|
+
membersCmd
|
|
649
|
+
.command('remove')
|
|
650
|
+
.argument('<memberId>')
|
|
651
|
+
.description('Remove a member after reassigning or removing their mailboxes')
|
|
652
|
+
.action((memberId) => {
|
|
653
|
+
const ctx = createContext();
|
|
654
|
+
warnLicense(ctx.db);
|
|
669
655
|
try {
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
console.log(`Lease ${expired ? 'expired' : 'valid until'} ${lease.expiresAt}: ${lease.plan} plan, ` +
|
|
673
|
-
`up to ${lease.maxAccounts} mailboxes and ${lease.maxMembers} member${lease.maxMembers === 1 ? '' : 's'}.`);
|
|
656
|
+
const { name, revokedApiKeys } = removeMember(ctx.db, memberId);
|
|
657
|
+
console.log(`Removed ${name} and revoked ${revokedApiKeys} API key(s).`);
|
|
674
658
|
}
|
|
675
659
|
catch (err) {
|
|
676
|
-
console.
|
|
660
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
661
|
+
process.exitCode = 1;
|
|
677
662
|
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
663
|
+
});
|
|
664
|
+
membersCmd
|
|
665
|
+
.command('role')
|
|
666
|
+
.argument('<member>', 'Member id or email')
|
|
667
|
+
.argument('<role>', 'admin or member')
|
|
668
|
+
.description('Change a member role')
|
|
669
|
+
.action((memberRef, role) => {
|
|
670
|
+
const ctx = createContext();
|
|
671
|
+
warnLicense(ctx.db);
|
|
672
|
+
try {
|
|
673
|
+
if (role !== 'admin' && role !== 'member') {
|
|
674
|
+
throw new EmailError('invalid_request', 'Role must be "admin" or "member".');
|
|
675
|
+
}
|
|
676
|
+
const member = setMemberRole(ctx.db, memberRef, role);
|
|
677
|
+
console.log(`${member.name} now has the ${member.role} role.`);
|
|
678
|
+
}
|
|
679
|
+
catch (err) {
|
|
680
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
681
|
+
process.exitCode = 1;
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
const apikey = program.command('apikey').description('Manage API keys for the HTTP MCP and REST APIs');
|
|
685
|
+
apikey
|
|
686
|
+
.command('capabilities')
|
|
687
|
+
.description('List capabilities for API key permission policies')
|
|
688
|
+
.action(() => {
|
|
689
|
+
for (const capability of [...MCP_CAPABILITIES, ...ADMIN_CAPABILITIES])
|
|
690
|
+
console.log(capability);
|
|
691
|
+
});
|
|
692
|
+
apikey
|
|
693
|
+
.command('create')
|
|
694
|
+
.requiredOption('--name <name>', 'Human-readable key name')
|
|
695
|
+
.requiredOption('--member <member>', 'Member (id or email) the key is issued to')
|
|
696
|
+
.option('--account <account>', 'Limit the key to one mailbox; repeat as needed', collectOption, [])
|
|
697
|
+
.option('--profile <profile>', `Tool profile: ${NAMED_PERMISSION_PROFILES.join(', ')}`)
|
|
698
|
+
.option('--allow <capability>', 'Allow one capability in a custom policy; repeat as needed', collectOption, [])
|
|
699
|
+
.option('--admin <capability>', 'Add one admin capability to a named profile; repeat as needed', collectOption, [])
|
|
700
|
+
.description('Create an API key (shown once)')
|
|
701
|
+
.action((opts) => {
|
|
702
|
+
const ctx = createContext();
|
|
703
|
+
warnLicense(ctx.db);
|
|
704
|
+
try {
|
|
705
|
+
const member = findMember(ctx.db, opts.member);
|
|
706
|
+
const accountIds = accountIdsFromRefs(ctx, opts.account);
|
|
707
|
+
const permissions = permissionPolicyFromOptions(opts);
|
|
708
|
+
const { key, info } = createApiKey(ctx.db, opts.name, member.id, permissions, accountIds);
|
|
709
|
+
console.log(`Created API key "${info.name}" (id: ${info.id}, profile: ${info.permissionProfile})` +
|
|
710
|
+
` for member ${member.name}` +
|
|
711
|
+
'\n');
|
|
712
|
+
console.log(` ${key}\n`);
|
|
713
|
+
console.log('Store it now; it cannot be shown again.');
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
717
|
+
process.exitCode = 1;
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
apikey
|
|
721
|
+
.command('list')
|
|
722
|
+
.description('List API keys')
|
|
723
|
+
.action(() => {
|
|
724
|
+
const ctx = createContext();
|
|
725
|
+
warnLicense(ctx.db);
|
|
726
|
+
const keys = listApiKeys(ctx.db);
|
|
727
|
+
if (!keys.length) {
|
|
728
|
+
console.log('No API keys. Run "fluxmail apikey create --name <name> --member <member>".');
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const memberNames = new Map(listMembers(ctx.db).map((m) => [m.id, m.name]));
|
|
732
|
+
for (const k of keys) {
|
|
733
|
+
const lastUsed = k.lastUsedAt ? new Date(k.lastUsedAt).toISOString() : 'never';
|
|
734
|
+
const member = k.memberId ? (memberNames.get(k.memberId) ?? k.memberId) : 'system';
|
|
735
|
+
const accountScope = k.accountIds === null ? 'all granted' : k.accountIds.join(',') || 'none';
|
|
736
|
+
console.log(`${k.id} ${k.name} member=${member} accounts=${accountScope} profile=${k.permissionProfile}` +
|
|
737
|
+
(k.permissionProfile === 'custom' ? ` allows=${k.capabilities.join(',')}` : '') +
|
|
738
|
+
(k.supplementalCapabilities.length ? ` admin=${k.supplementalCapabilities.join(',')}` : '') +
|
|
739
|
+
` created=${new Date(k.createdAt).toISOString()} lastUsed=${lastUsed}`);
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
apikey
|
|
743
|
+
.command('accounts')
|
|
744
|
+
.argument('<keyId>')
|
|
745
|
+
.option('--account <account>', 'Replace the allowlist with this mailbox; repeat as needed', collectOption, [])
|
|
746
|
+
.option('--all-accounts', 'Clear the allowlist')
|
|
747
|
+
.description('Replace or clear an API key mailbox allowlist')
|
|
748
|
+
.action((keyId, opts) => {
|
|
749
|
+
const ctx = createContext();
|
|
750
|
+
warnLicense(ctx.db);
|
|
751
|
+
try {
|
|
752
|
+
if (Boolean(opts.allAccounts) === Boolean(opts.account.length)) {
|
|
753
|
+
throw new EmailError('invalid_request', 'Pass --all-accounts or at least one --account.');
|
|
754
|
+
}
|
|
755
|
+
const accountIds = opts.allAccounts ? null : accountIdsFromRefs(ctx, opts.account);
|
|
756
|
+
if (!updateApiKeyAccounts(ctx.db, keyId, accountIds)) {
|
|
757
|
+
throw new EmailError('not_found', `No key with id ${keyId}.`);
|
|
758
|
+
}
|
|
759
|
+
console.log(accountIds === null
|
|
760
|
+
? `Cleared the mailbox allowlist for ${keyId}.`
|
|
761
|
+
: `Updated ${keyId} to ${accountIds.length} mailbox(es).`);
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
765
|
+
process.exitCode = 1;
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
apikey
|
|
769
|
+
.command('permissions')
|
|
770
|
+
.argument('<keyId>')
|
|
771
|
+
.option('--profile <profile>', `Tool profile: ${NAMED_PERMISSION_PROFILES.join(', ')}`)
|
|
772
|
+
.option('--allow <capability>', 'Allow one capability in a custom policy; repeat as needed', collectOption, [])
|
|
773
|
+
.option('--admin <capability>', 'Add one admin capability to a named profile; repeat as needed', collectOption, [])
|
|
774
|
+
.description('Change the permissions for an API key')
|
|
775
|
+
.action((keyId, opts) => {
|
|
776
|
+
const ctx = createContext();
|
|
777
|
+
warnLicense(ctx.db);
|
|
778
|
+
try {
|
|
779
|
+
const existing = listApiKeys(ctx.db).find((key) => key.id === keyId);
|
|
780
|
+
if (!existing) {
|
|
781
|
+
console.error(`No key with id ${keyId}`);
|
|
782
|
+
process.exitCode = 1;
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const permissions = permissionPolicyForUpdate(opts, existing);
|
|
786
|
+
if (!updateApiKeyPermissions(ctx.db, keyId, permissions)) {
|
|
787
|
+
console.error(`No key with id ${keyId}`);
|
|
788
|
+
process.exitCode = 1;
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
console.log(`Updated ${keyId} to profile ${permissions.profile}`);
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
795
|
+
process.exitCode = 1;
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
apikey
|
|
799
|
+
.command('revoke')
|
|
800
|
+
.argument('<keyId>')
|
|
801
|
+
.description('Revoke an API key')
|
|
802
|
+
.action((keyId) => {
|
|
803
|
+
const ctx = createContext();
|
|
804
|
+
warnLicense(ctx.db);
|
|
805
|
+
console.log(revokeApiKey(ctx.db, keyId) ? `Revoked ${keyId}` : `No key with id ${keyId}`);
|
|
806
|
+
});
|
|
807
|
+
const license = program.command('license').description('Manage the paid-tier license');
|
|
808
|
+
license
|
|
809
|
+
.command('activate')
|
|
810
|
+
.argument('<key>', 'License key (fluxmail_lic_…), shown once at purchase')
|
|
811
|
+
.description('Store a license key and validate it with the license server')
|
|
812
|
+
.action(async (key) => {
|
|
813
|
+
if (!LICENSE_KEY_PATTERN.test(key)) {
|
|
814
|
+
console.error('That does not look like a Fluxmail license key (expected "fluxmail_lic_" followed by 40 hex characters).');
|
|
815
|
+
process.exitCode = 1;
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const dataDir = resolveDataDir();
|
|
819
|
+
const overridingKey = process.env.FLUXMAIL_LICENSE_KEY?.trim();
|
|
820
|
+
if (overridingKey && overridingKey !== key) {
|
|
821
|
+
console.error('FLUXMAIL_LICENSE_KEY is already set by the shell or a .env file, which takes precedence over stored settings. Remove it before activating a different key.');
|
|
822
|
+
process.exitCode = 1;
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const ctx = createContext();
|
|
826
|
+
const result = await activateLicense(ctx.db, {
|
|
827
|
+
licenseKey: key,
|
|
698
828
|
serverUrl: ctx.config.licenseServerUrl,
|
|
699
|
-
|
|
700
|
-
instanceId: loadInstanceId(dataDir),
|
|
829
|
+
dataDir,
|
|
701
830
|
});
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
:
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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.`);
|
|
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.');
|
|
841
|
+
}
|
|
842
|
+
else {
|
|
843
|
+
process.exitCode = 1;
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
license
|
|
847
|
+
.command('status')
|
|
848
|
+
.description('Show the configured license and cached lease')
|
|
849
|
+
.action(() => {
|
|
850
|
+
const ctx = createContext();
|
|
851
|
+
if (ctx.config.licenseKey) {
|
|
852
|
+
console.log(`License key: ${maskStoredConfigValue('FLUXMAIL_LICENSE_KEY', ctx.config.licenseKey)}`);
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
console.log('No license key configured. Run "fluxmail license activate <key>" after purchasing one.');
|
|
856
|
+
}
|
|
857
|
+
const row = readLeaseRow(ctx.db);
|
|
858
|
+
if (row) {
|
|
859
|
+
console.log(`Last validated: ${new Date(row.updatedAt).toISOString()}`);
|
|
860
|
+
try {
|
|
861
|
+
const lease = verifyLease(row.token, licensePublicKeys(), new Date(), { allowExpired: true });
|
|
862
|
+
const expired = Date.parse(lease.expiresAt) <= Date.now();
|
|
863
|
+
console.log(`Lease ${expired ? 'expired' : 'valid until'} ${lease.expiresAt}: ${lease.plan} plan, ` +
|
|
864
|
+
`up to ${lease.maxAccounts} mailboxes and ${lease.maxMembers} member${lease.maxMembers === 1 ? '' : 's'}.`);
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
console.log(`Cached lease is not usable (${err instanceof Error ? err.message : String(err)}).`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
console.log('No cached lease.');
|
|
872
|
+
}
|
|
873
|
+
console.log(`Current plan: ${planLine(getEntitlements(ctx.db))}`);
|
|
874
|
+
warnLicense(ctx.db, console.log);
|
|
875
|
+
});
|
|
876
|
+
license
|
|
877
|
+
.command('deactivate')
|
|
878
|
+
.description('Release the license from this instance and remove the stored key and cached lease')
|
|
879
|
+
.action(async () => {
|
|
725
880
|
const dataDir = resolveDataDir();
|
|
726
|
-
|
|
727
|
-
|
|
881
|
+
// Captured before createContext() merges the stored config into the
|
|
882
|
+
// environment: truthy only when the key comes from the shell or a .env file.
|
|
883
|
+
const envLicenseKey = process.env.FLUXMAIL_LICENSE_KEY?.trim();
|
|
884
|
+
const ctx = createContext();
|
|
885
|
+
// Best effort: free the server-side instance binding so the key can be
|
|
886
|
+
// activated elsewhere. Local deactivation proceeds either way.
|
|
887
|
+
if (ctx.config.licenseKey) {
|
|
888
|
+
const released = await releaseLicense({
|
|
889
|
+
serverUrl: ctx.config.licenseServerUrl,
|
|
890
|
+
licenseKey: ctx.config.licenseKey,
|
|
891
|
+
instanceId: loadInstanceId(dataDir),
|
|
892
|
+
});
|
|
893
|
+
console.log(released
|
|
894
|
+
? 'Released the license from this instance; it can now be activated elsewhere.'
|
|
895
|
+
: 'Could not reach the license server to release this instance; unused bindings are released automatically after a while.');
|
|
896
|
+
}
|
|
897
|
+
const removed = unsetStoredConfig(dataDir, 'FLUXMAIL_LICENSE_KEY');
|
|
898
|
+
clearLease(ctx.db);
|
|
899
|
+
console.log(removed
|
|
900
|
+
? 'License removed; this instance is back to Personal-plan limits.'
|
|
901
|
+
: 'No stored license key; cleared any cached lease.');
|
|
902
|
+
if (envLicenseKey) {
|
|
903
|
+
console.log('Note: FLUXMAIL_LICENSE_KEY is still set in the environment or a .env file.');
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
const configCmd = program
|
|
907
|
+
.command('config')
|
|
908
|
+
.description('Persistent settings stored in the data dir, usable from any directory');
|
|
909
|
+
configCmd
|
|
910
|
+
.command('set')
|
|
911
|
+
.argument('<key>', 'Setting name, e.g. GOOGLE_CLIENT_ID')
|
|
912
|
+
.argument('<value>')
|
|
913
|
+
.description('Store a setting (shell env vars and local .env files still take precedence)')
|
|
914
|
+
.action((key, value) => {
|
|
915
|
+
try {
|
|
916
|
+
const dataDir = resolveDataDir();
|
|
917
|
+
setStoredConfig(dataDir, key, value);
|
|
918
|
+
console.log(`Set ${key} in ${configFilePath(dataDir)}`);
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
922
|
+
process.exitCode = 1;
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
configCmd
|
|
926
|
+
.command('unset')
|
|
927
|
+
.argument('<key>')
|
|
928
|
+
.description('Remove a stored setting')
|
|
929
|
+
.action((key) => {
|
|
930
|
+
const dataDir = resolveDataDir();
|
|
931
|
+
console.log(unsetStoredConfig(dataDir, key) ? `Removed ${key}` : `${key} is not set`);
|
|
932
|
+
});
|
|
933
|
+
configCmd
|
|
934
|
+
.command('list')
|
|
935
|
+
.description('Show stored settings (secret values are masked)')
|
|
936
|
+
.action(() => {
|
|
937
|
+
const dataDir = resolveDataDir();
|
|
938
|
+
const stored = readStoredConfig(dataDir);
|
|
939
|
+
const keys = Object.keys(stored);
|
|
940
|
+
if (!keys.length) {
|
|
941
|
+
console.log(`No stored settings. Run "fluxmail config set <KEY> <value>" (file: ${configFilePath(dataDir)}).`);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
for (const key of keys) {
|
|
945
|
+
const value = stored[key] ?? '';
|
|
946
|
+
console.log(`${key}=${maskStoredConfigValue(key, value)}`);
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
const telemetryCmd = program.command('telemetry').description('Manage anonymous usage telemetry');
|
|
950
|
+
telemetryCmd
|
|
951
|
+
.command('disable')
|
|
952
|
+
.description('Stop sending anonymous usage telemetry')
|
|
953
|
+
.action(() => {
|
|
954
|
+
const dataDir = resolveDataDir();
|
|
955
|
+
setTelemetryEnabled(dataDir, false);
|
|
956
|
+
console.log('Telemetry disabled.');
|
|
957
|
+
});
|
|
958
|
+
telemetryCmd
|
|
959
|
+
.command('enable')
|
|
960
|
+
.description('Allow anonymous usage telemetry')
|
|
961
|
+
.action(() => {
|
|
962
|
+
const dataDir = resolveDataDir();
|
|
963
|
+
setTelemetryEnabled(dataDir, true);
|
|
964
|
+
unsetStoredConfig(dataDir, 'FLUXMAIL_TELEMETRY');
|
|
965
|
+
console.log('Telemetry enabled. FLUXMAIL_TELEMETRY=0 or DO_NOT_TRACK=1 can still turn it off.');
|
|
966
|
+
});
|
|
967
|
+
telemetryCmd
|
|
968
|
+
.command('status')
|
|
969
|
+
.description('Show whether anonymous usage telemetry is enabled')
|
|
970
|
+
.action(() => {
|
|
971
|
+
const dataDir = resolveDataDir();
|
|
972
|
+
console.log(`Telemetry is ${isTelemetryEnabled(dataDir) ? 'enabled' : 'disabled'}.`);
|
|
973
|
+
});
|
|
974
|
+
program
|
|
975
|
+
.command('status')
|
|
976
|
+
.description('Show accounts, members, entitlements, and provider availability')
|
|
977
|
+
.action(async () => {
|
|
978
|
+
const ctx = createContext();
|
|
979
|
+
warnLicense(ctx.db);
|
|
980
|
+
console.log(JSON.stringify(await ctx.service.status(), null, 2));
|
|
981
|
+
});
|
|
982
|
+
return program;
|
|
983
|
+
}
|
|
984
|
+
export async function runCli(argv = process.argv) {
|
|
985
|
+
try {
|
|
986
|
+
await createCliProgram().parseAsync([...argv]);
|
|
728
987
|
}
|
|
729
988
|
catch (err) {
|
|
730
989
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
731
990
|
process.exitCode = 1;
|
|
991
|
+
await shutdownTelemetry();
|
|
732
992
|
}
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const dataDir = resolveDataDir();
|
|
740
|
-
console.log(unsetStoredConfig(dataDir, key) ? `Removed ${key}` : `${key} is not set`);
|
|
741
|
-
});
|
|
742
|
-
configCmd
|
|
743
|
-
.command('list')
|
|
744
|
-
.description('Show stored settings (secret values are masked)')
|
|
745
|
-
.action(() => {
|
|
746
|
-
const dataDir = resolveDataDir();
|
|
747
|
-
const stored = readStoredConfig(dataDir);
|
|
748
|
-
const keys = Object.keys(stored);
|
|
749
|
-
if (!keys.length) {
|
|
750
|
-
console.log(`No stored settings. Run "fluxmail config set <KEY> <value>" (file: ${configFilePath(dataDir)}).`);
|
|
751
|
-
return;
|
|
993
|
+
}
|
|
994
|
+
function isEntrypoint() {
|
|
995
|
+
if (!process.argv[1])
|
|
996
|
+
return false;
|
|
997
|
+
try {
|
|
998
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
752
999
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
console.log(`${key}=${maskStoredConfigValue(key, value)}`);
|
|
1000
|
+
catch {
|
|
1001
|
+
return pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
|
|
756
1002
|
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
.command('disable')
|
|
761
|
-
.description('Stop sending anonymous usage telemetry')
|
|
762
|
-
.action(() => {
|
|
763
|
-
const dataDir = resolveDataDir();
|
|
764
|
-
setTelemetryEnabled(dataDir, false);
|
|
765
|
-
console.log('Telemetry disabled.');
|
|
766
|
-
});
|
|
767
|
-
telemetryCmd
|
|
768
|
-
.command('enable')
|
|
769
|
-
.description('Allow anonymous usage telemetry')
|
|
770
|
-
.action(() => {
|
|
771
|
-
const dataDir = resolveDataDir();
|
|
772
|
-
setTelemetryEnabled(dataDir, true);
|
|
773
|
-
unsetStoredConfig(dataDir, 'FLUXMAIL_TELEMETRY');
|
|
774
|
-
console.log('Telemetry enabled. FLUXMAIL_TELEMETRY=0 or DO_NOT_TRACK=1 can still turn it off.');
|
|
775
|
-
});
|
|
776
|
-
telemetryCmd
|
|
777
|
-
.command('status')
|
|
778
|
-
.description('Show whether anonymous usage telemetry is enabled')
|
|
779
|
-
.action(() => {
|
|
780
|
-
const dataDir = resolveDataDir();
|
|
781
|
-
console.log(`Telemetry is ${isTelemetryEnabled(dataDir) ? 'enabled' : 'disabled'}.`);
|
|
782
|
-
});
|
|
783
|
-
program
|
|
784
|
-
.command('status')
|
|
785
|
-
.description('Show accounts, members, entitlements, and provider availability')
|
|
786
|
-
.action(async () => {
|
|
787
|
-
const ctx = createContext();
|
|
788
|
-
warnLicense(ctx.db);
|
|
789
|
-
console.log(JSON.stringify(await ctx.service.status(), null, 2));
|
|
790
|
-
});
|
|
791
|
-
program.parseAsync(process.argv).catch(async (err) => {
|
|
792
|
-
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
793
|
-
process.exitCode = 1;
|
|
794
|
-
await shutdownTelemetry();
|
|
795
|
-
});
|
|
1003
|
+
}
|
|
1004
|
+
if (isEntrypoint())
|
|
1005
|
+
void runCli();
|
|
796
1006
|
//# sourceMappingURL=cli.js.map
|