fluxmail 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/accounts/googleAuth.d.ts +7 -3
  2. package/dist/accounts/googleAuth.d.ts.map +1 -1
  3. package/dist/accounts/googleAuth.js +8 -7
  4. package/dist/accounts/googleAuth.js.map +1 -1
  5. package/dist/accounts/microsoftAuth.d.ts +5 -2
  6. package/dist/accounts/microsoftAuth.d.ts.map +1 -1
  7. package/dist/accounts/microsoftAuth.js +24 -21
  8. package/dist/accounts/microsoftAuth.js.map +1 -1
  9. package/dist/accounts/registry.d.ts +2 -1
  10. package/dist/accounts/registry.d.ts.map +1 -1
  11. package/dist/accounts/registry.js +7 -6
  12. package/dist/accounts/registry.js.map +1 -1
  13. package/dist/auth.d.ts.map +1 -1
  14. package/dist/auth.js +5 -2
  15. package/dist/auth.js.map +1 -1
  16. package/dist/cli.d.ts +8 -0
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +382 -65
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cliInstances.d.ts +14 -0
  21. package/dist/cliInstances.d.ts.map +1 -1
  22. package/dist/cliInstances.js +71 -4
  23. package/dist/cliInstances.js.map +1 -1
  24. package/dist/cliMail.d.ts +9 -0
  25. package/dist/cliMail.d.ts.map +1 -0
  26. package/dist/cliMail.js +450 -0
  27. package/dist/cliMail.js.map +1 -0
  28. package/dist/config.d.ts +89 -118
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +344 -369
  31. package/dist/config.js.map +1 -1
  32. package/dist/configMigration.d.ts +8 -0
  33. package/dist/configMigration.d.ts.map +1 -0
  34. package/dist/configMigration.js +234 -0
  35. package/dist/configMigration.js.map +1 -0
  36. package/dist/context.d.ts +8 -2
  37. package/dist/context.d.ts.map +1 -1
  38. package/dist/context.js +24 -7
  39. package/dist/context.js.map +1 -1
  40. package/dist/deploymentConfig.d.ts +190 -0
  41. package/dist/deploymentConfig.d.ts.map +1 -0
  42. package/dist/deploymentConfig.js +539 -0
  43. package/dist/deploymentConfig.js.map +1 -0
  44. package/dist/http/admin.d.ts +19 -1
  45. package/dist/http/admin.d.ts.map +1 -1
  46. package/dist/http/admin.js +162 -15
  47. package/dist/http/admin.js.map +1 -1
  48. package/dist/http/app.d.ts +4 -1
  49. package/dist/http/app.d.ts.map +1 -1
  50. package/dist/http/app.js +44 -12
  51. package/dist/http/app.js.map +1 -1
  52. package/dist/http/identity.d.ts.map +1 -1
  53. package/dist/http/identity.js +5 -0
  54. package/dist/http/identity.js.map +1 -1
  55. package/dist/http/rest.d.ts +5 -1
  56. package/dist/http/rest.d.ts.map +1 -1
  57. package/dist/http/rest.js +76 -6
  58. package/dist/http/rest.js.map +1 -1
  59. package/dist/index.d.ts +2 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +2 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/instanceConfig.d.ts +36 -0
  64. package/dist/instanceConfig.d.ts.map +1 -0
  65. package/dist/instanceConfig.js +157 -0
  66. package/dist/instanceConfig.js.map +1 -0
  67. package/dist/licensing/activation.d.ts +2 -1
  68. package/dist/licensing/activation.d.ts.map +1 -1
  69. package/dist/licensing/activation.js +2 -3
  70. package/dist/licensing/activation.js.map +1 -1
  71. package/dist/licensing/entitlements.d.ts.map +1 -1
  72. package/dist/licensing/entitlements.js +2 -2
  73. package/dist/licensing/entitlements.js.map +1 -1
  74. package/dist/licensing/refresher.d.ts +6 -1
  75. package/dist/licensing/refresher.d.ts.map +1 -1
  76. package/dist/licensing/refresher.js +18 -7
  77. package/dist/licensing/refresher.js.map +1 -1
  78. package/dist/logging.d.ts +87 -0
  79. package/dist/logging.d.ts.map +1 -0
  80. package/dist/logging.js +612 -0
  81. package/dist/logging.js.map +1 -0
  82. package/dist/mcp/buildServer.d.ts +2 -0
  83. package/dist/mcp/buildServer.d.ts.map +1 -1
  84. package/dist/mcp/buildServer.js +13 -5
  85. package/dist/mcp/buildServer.js.map +1 -1
  86. package/dist/permissions.d.ts.map +1 -1
  87. package/dist/permissions.js +3 -3
  88. package/dist/permissions.js.map +1 -1
  89. package/dist/scheduler/sendScheduler.d.ts +3 -1
  90. package/dist/scheduler/sendScheduler.d.ts.map +1 -1
  91. package/dist/scheduler/sendScheduler.js +14 -1
  92. package/dist/scheduler/sendScheduler.js.map +1 -1
  93. package/dist/service/emailService.d.ts +2 -1
  94. package/dist/service/emailService.d.ts.map +1 -1
  95. package/dist/service/emailService.js +3 -0
  96. package/dist/service/emailService.js.map +1 -1
  97. package/dist/storage/db.d.ts +5 -2
  98. package/dist/storage/db.d.ts.map +1 -1
  99. package/dist/storage/db.js +55 -2
  100. package/dist/storage/db.js.map +1 -1
  101. package/dist/telemetry.d.ts +3 -2
  102. package/dist/telemetry.d.ts.map +1 -1
  103. package/dist/telemetry.js +12 -19
  104. package/dist/telemetry.js.map +1 -1
  105. package/dist/updateNotifier.d.ts +18 -0
  106. package/dist/updateNotifier.d.ts.map +1 -0
  107. package/dist/updateNotifier.js +128 -0
  108. package/dist/updateNotifier.js.map +1 -0
  109. package/package.json +37 -5
package/dist/config.js CHANGED
@@ -1,427 +1,402 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import path from 'node:path';
1
+ import { mkdirSync } from 'node:fs';
5
2
  import { DEFAULT_GOOGLE_CLIENT_ID, DEFAULT_GOOGLE_CLIENT_SECRET } from './accounts/defaultGoogleOAuth.js';
6
3
  import { DEFAULT_LICENSE_SERVER_URL } from './licensing/client.js';
7
- import { withFileLock } from './storage/fileLock.js';
8
- export const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
9
- export const HARD_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
10
- const BYTES_PER_MEGABYTE = 1024 * 1024;
11
- const CONFIG_LOCK_TIMEOUT_MS = 5_000;
12
- const CONFIG_LOCK_STALE_MS = 30_000;
13
- /**
14
- * User-settable environment variables understood by the server. The public
15
- * documentation generator reads this registry, and config loading uses its
16
- * keys so adding a new setting cannot bypass the reference list by accident.
17
- */
18
- export const CONFIG_REFERENCE = {
19
- GOOGLE_CLIENT_ID: {
4
+ import { DEFAULT_MAX_ATTACHMENT_BYTES, DEPLOYMENT_REFERENCE, HARD_MAX_ATTACHMENT_BYTES, configTomlPath, deploymentToml, ensureDeploymentEncryptionKey, expandHome, readSecretEnvironment, resolveDataDirectory, resolveDeploymentConfig, writeDeploymentConfig, } from './deploymentConfig.js';
5
+ import { InstanceConfigStore } from './instanceConfig.js';
6
+ export { DEFAULT_MAX_ATTACHMENT_BYTES, HARD_MAX_ATTACHMENT_BYTES, configTomlPath, deploymentToml, ensureDeploymentEncryptionKey, expandHome, resolveDeploymentConfig, writeDeploymentConfig, };
7
+ const INSTANCE_SETTING_REGISTRY = {
8
+ 'oauth.google.client_id': {
9
+ canonicalName: 'oauth.google.client_id',
10
+ env: 'GOOGLE_CLIENT_ID',
11
+ type: 'string',
12
+ validation: 'Non-empty and paired with a Google client secret',
20
13
  defaultValue: 'Fluxmail Desktop OAuth app',
14
+ sensitive: false,
15
+ mutability: 'immediate',
16
+ environmentRequiresRestart: true,
21
17
  description: 'Override the built-in Google OAuth client ID.',
18
+ category: 'instance',
22
19
  },
23
- GOOGLE_CLIENT_SECRET: {
20
+ 'oauth.google.client_secret': {
21
+ canonicalName: 'oauth.google.client_secret',
22
+ env: 'GOOGLE_CLIENT_SECRET',
23
+ type: 'secret',
24
+ validation: 'Non-empty and paired with a Google client ID',
24
25
  defaultValue: 'Fluxmail Desktop OAuth app',
26
+ sensitive: true,
27
+ mutability: 'immediate',
28
+ environmentRequiresRestart: true,
25
29
  description: 'Override the built-in Google OAuth client secret. Required with GOOGLE_CLIENT_ID.',
26
- secret: true,
30
+ category: 'instance',
31
+ envFile: true,
27
32
  },
28
- MICROSOFT_CLIENT_ID: {
33
+ 'oauth.microsoft.client_id': {
34
+ canonicalName: 'oauth.microsoft.client_id',
35
+ env: 'MICROSOFT_CLIENT_ID',
36
+ type: 'string',
37
+ validation: 'Non-empty when any Microsoft override is present',
29
38
  defaultValue: 'required for Outlook',
39
+ sensitive: false,
40
+ mutability: 'immediate',
41
+ environmentRequiresRestart: true,
30
42
  description: 'Microsoft Entra application client ID.',
43
+ category: 'instance',
31
44
  },
32
- MICROSOFT_CLIENT_SECRET: {
45
+ 'oauth.microsoft.client_secret': {
46
+ canonicalName: 'oauth.microsoft.client_secret',
47
+ env: 'MICROSOFT_CLIENT_SECRET',
48
+ type: 'secret',
49
+ validation: 'Non-empty when configured',
33
50
  defaultValue: 'required for hosted Outlook connections',
51
+ sensitive: true,
52
+ mutability: 'immediate',
53
+ environmentRequiresRestart: true,
34
54
  description: 'Microsoft Entra application client secret.',
35
- secret: true,
55
+ category: 'instance',
56
+ envFile: true,
36
57
  },
37
- MICROSOFT_TENANT_ID: {
58
+ 'oauth.microsoft.tenant_id': {
59
+ canonicalName: 'oauth.microsoft.tenant_id',
60
+ env: 'MICROSOFT_TENANT_ID',
61
+ type: 'string',
62
+ validation: 'Non-empty tenant ID or verified domain',
38
63
  defaultValue: 'common',
64
+ sensitive: false,
65
+ mutability: 'immediate',
66
+ environmentRequiresRestart: true,
39
67
  description: 'Microsoft Entra tenant ID or verified domain.',
68
+ category: 'instance',
40
69
  },
41
- FLUXMAIL_DATA_DIR: {
42
- defaultValue: '~/.fluxmail (/data in Docker)',
43
- description: 'Directory for the SQLite database, stored config, and generated encryption key.',
44
- },
45
- FLUXMAIL_DB_PATH: {
46
- defaultValue: '<data dir>/fluxmail.db',
47
- description: 'Override the SQLite database path.',
48
- },
49
- FLUXMAIL_ENCRYPTION_KEY: {
50
- defaultValue: 'generated automatically',
51
- description: 'A 64-character hexadecimal key used to encrypt provider credentials.',
52
- secret: true,
53
- },
54
- FLUXMAIL_PORT: {
55
- defaultValue: '8977',
56
- description: 'HTTP server port.',
57
- },
58
- FLUXMAIL_PUBLIC_URL: {
59
- defaultValue: 'http://localhost:<FLUXMAIL_PORT>',
60
- description: 'Public base URL used for HTTP APIs and hosted OAuth callbacks.',
61
- },
62
- FLUXMAIL_TRUST_PROXY: {
63
- defaultValue: '0',
64
- description: 'Trust Forwarded, X-Forwarded-Proto, and X-Forwarded-For headers from a reverse proxy.',
65
- },
66
- FLUXMAIL_OAUTH_PORT: {
67
- defaultValue: '8976',
68
- description: 'Port for the local OAuth callback listener.',
69
- },
70
- FLUXMAIL_OAUTH_HOST: {
71
- defaultValue: '127.0.0.1',
72
- description: 'Bind address for the local OAuth callback listener.',
73
- },
74
- FLUXMAIL_MAX_ATTACHMENT_MB: {
75
- defaultValue: '10',
76
- description: 'Largest decoded attachment returned through MCP or REST, from 1 through 25 MB.',
77
- },
78
- FLUXMAIL_LICENSE_KEY: {
70
+ 'license.key': {
71
+ canonicalName: 'license.key',
72
+ env: 'FLUXMAIL_LICENSE_KEY',
73
+ type: 'secret',
74
+ validation: 'Fluxmail license key',
79
75
  defaultValue: 'none',
76
+ sensitive: true,
77
+ mutability: 'immediate',
78
+ environmentRequiresRestart: true,
80
79
  description: 'Paid-plan license key, normally stored with fluxmail license activate.',
81
- secret: true,
80
+ category: 'instance',
81
+ envFile: true,
82
82
  },
83
- FLUXMAIL_TELEMETRY: {
83
+ 'telemetry.enabled': {
84
+ canonicalName: 'telemetry.enabled',
85
+ env: 'FLUXMAIL_TELEMETRY',
86
+ type: 'boolean',
87
+ validation: '0, 1, false, true, no, off, or on',
84
88
  defaultValue: '1',
89
+ sensitive: false,
90
+ mutability: 'pre-database',
85
91
  description: 'Set to 0 to turn off anonymous CLI, MCP, and REST usage telemetry.',
92
+ category: 'instance',
93
+ primaryStorage: 'Data directory marker',
86
94
  },
87
- DO_NOT_TRACK: {
95
+ 'telemetry.do_not_track': {
96
+ canonicalName: 'telemetry.do_not_track',
97
+ env: 'DO_NOT_TRACK',
98
+ type: 'boolean',
99
+ validation: 'Truthy value disables telemetry',
88
100
  defaultValue: 'unset',
101
+ sensitive: false,
102
+ mutability: 'pre-database',
89
103
  description: 'Set to 1 to turn off anonymous usage telemetry.',
90
- },
91
- FLUXMAIL_LICENSE_SERVER_URL: {
92
- defaultValue: DEFAULT_LICENSE_SERVER_URL,
93
- description: 'License validation service override used for development and testing.',
94
- documented: false,
104
+ category: 'instance',
105
+ primaryStorage: 'Data directory marker',
95
106
  },
96
107
  };
97
- function readEnvironment(name) {
98
- return process.env[name];
108
+ export const SETTING_REGISTRY = {
109
+ ...Object.fromEntries(Object.values(DEPLOYMENT_REFERENCE).map((entry) => [
110
+ entry.canonicalName,
111
+ {
112
+ canonicalName: entry.canonicalName,
113
+ env: entry.env,
114
+ ...('toml' in entry && entry.toml ? { toml: entry.toml } : {}),
115
+ type: entry.type,
116
+ validation: entry.validation,
117
+ defaultValue: entry.defaultValue,
118
+ sensitive: 'secret' in entry && entry.secret === true,
119
+ mutability: 'restart',
120
+ description: entry.description,
121
+ category: 'deployment',
122
+ ...('secret' in entry && entry.secret ? { envFile: true } : {}),
123
+ ...('documented' in entry && entry.documented === false ? { documented: false } : {}),
124
+ },
125
+ ])),
126
+ ...INSTANCE_SETTING_REGISTRY,
127
+ };
128
+ export const CONFIG_REFERENCE = Object.fromEntries(Object.values(SETTING_REGISTRY).map((entry) => [
129
+ entry.env,
130
+ {
131
+ defaultValue: entry.defaultValue,
132
+ description: entry.description,
133
+ category: entry.category,
134
+ ...(entry.sensitive ? { secret: true } : {}),
135
+ ...(entry.envFile ? { envFile: true } : {}),
136
+ ...(entry.toml ? { toml: entry.toml } : {}),
137
+ ...(entry.documented === false ? { documented: false } : {}),
138
+ },
139
+ ]));
140
+ export class EnvironmentControlledSettingError extends Error {
141
+ code = 'environment_controlled';
99
142
  }
100
- function unquoteEnvValue(value) {
101
- if (value.startsWith('"') && value.endsWith('"')) {
102
- try {
103
- const parsed = JSON.parse(value);
104
- return typeof parsed === 'string' ? parsed : value.slice(1, -1);
105
- }
106
- catch {
107
- return value.slice(1, -1);
108
- }
109
- }
110
- if (value.startsWith("'") && value.endsWith("'"))
111
- return value.slice(1, -1);
112
- return value;
143
+ function readOptionalSecretEnvironment(env, name) {
144
+ return readSecretEnvironment(env, name, { blank: 'unset' });
113
145
  }
114
- function parseEnvValue(raw) {
115
- // A fully quoted value may contain "#" verbatim.
116
- if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
117
- return unquoteEnvValue(raw);
146
+ function environmentGoogle(env) {
147
+ const clientId = env.GOOGLE_CLIENT_ID?.trim();
148
+ const secret = readOptionalSecretEnvironment(env, 'GOOGLE_CLIENT_SECRET');
149
+ const controlled = Boolean(clientId) || secret.value !== undefined;
150
+ if (!controlled)
151
+ return { controlled: false };
152
+ if (!clientId)
153
+ throw new Error('GOOGLE_CLIENT_ID is required when Google OAuth environment overrides are used.');
154
+ if (!secret.value) {
155
+ throw new Error('GOOGLE_CLIENT_SECRET or GOOGLE_CLIENT_SECRET_FILE is required with GOOGLE_CLIENT_ID.');
118
156
  }
119
- // Otherwise strip an inline comment (whitespace + #), like dotenv and docker compose do.
120
- const quoted = raw.match(/^("(?:[^"\\]|\\.)*"|'[^']*')\s+#/);
121
- if (quoted?.[1])
122
- return unquoteEnvValue(quoted[1]);
123
- const comment = raw.search(/\s#/);
124
- return comment === -1 ? raw : raw.slice(0, comment).trimEnd();
157
+ return {
158
+ value: { clientId, clientSecret: secret.value },
159
+ source: secret.source === 'environment-file' ? 'environment-file' : 'environment',
160
+ controlled: true,
161
+ };
125
162
  }
126
- function parseEnvContent(content) {
127
- const out = {};
128
- for (const line of content.split('\n')) {
129
- const trimmed = line.trim();
130
- if (!trimmed || trimmed.startsWith('#'))
131
- continue;
132
- const eq = trimmed.indexOf('=');
133
- if (eq === -1)
134
- continue;
135
- const key = trimmed
136
- .slice(0, eq)
137
- .trim()
138
- .replace(/^export\s+/, '');
139
- if (key)
140
- out[key] = parseEnvValue(trimmed.slice(eq + 1).trim());
163
+ function environmentMicrosoft(env) {
164
+ const clientId = env.MICROSOFT_CLIENT_ID?.trim();
165
+ const clientSecret = readOptionalSecretEnvironment(env, 'MICROSOFT_CLIENT_SECRET');
166
+ const tenantId = env.MICROSOFT_TENANT_ID?.trim();
167
+ const controlled = Boolean(clientId) || Boolean(tenantId) || clientSecret.value !== undefined;
168
+ if (!controlled)
169
+ return { controlled: false };
170
+ if (!clientId) {
171
+ throw new Error('MICROSOFT_CLIENT_ID is required when Microsoft OAuth environment overrides are used.');
141
172
  }
142
- return out;
173
+ return {
174
+ value: {
175
+ clientId,
176
+ tenantId: tenantId || 'common',
177
+ ...(clientSecret.value ? { clientSecret: clientSecret.value } : {}),
178
+ },
179
+ source: clientSecret.source === 'environment-file' ? 'environment-file' : 'environment',
180
+ controlled: true,
181
+ };
143
182
  }
144
- function applyDotEnvFile(file) {
145
- if (!existsSync(file))
146
- return;
147
- for (const [key, value] of Object.entries(parseEnvContent(readFileSync(file, 'utf8')))) {
148
- if (process.env[key] === undefined)
149
- process.env[key] = value;
150
- }
183
+ function environmentLicense(env) {
184
+ const configured = readOptionalSecretEnvironment(env, 'FLUXMAIL_LICENSE_KEY');
185
+ return { value: configured.value?.trim(), source: configured.source, controlled: configured.value !== undefined };
151
186
  }
152
- /**
153
- * Load .env.local and .env from the working directory. Real environment
154
- * variables always win; .env.local wins over .env.
155
- */
156
- export function loadDotEnv(cwd = process.cwd()) {
157
- applyDotEnvFile(path.join(cwd, '.env.local'));
158
- applyDotEnvFile(path.join(cwd, '.env'));
187
+ function baseConfigFromDeployment(deployment) {
188
+ return {
189
+ dataDir: deployment.dataDir,
190
+ dbPath: deployment.dbPath,
191
+ encryptionKey: deployment.encryptionKey,
192
+ port: deployment.port,
193
+ publicUrl: deployment.publicUrl,
194
+ publicUrlConfigured: deployment.publicUrlConfigured,
195
+ trustProxy: deployment.trustProxy,
196
+ oauthPort: deployment.oauthPort,
197
+ oauthHost: deployment.oauthHost,
198
+ maxAttachmentBytes: deployment.maxAttachmentBytes,
199
+ logLevel: deployment.logLevel,
200
+ logDestination: deployment.logDestination,
201
+ licenseServerUrl: deployment.licenseServerUrl,
202
+ };
159
203
  }
160
- /** Expand a leading "~" (e.g. FLUXMAIL_DATA_DIR=~/.fluxmail), which Node does not do. */
161
- export function expandHome(p) {
162
- if (p === '~')
163
- return homedir();
164
- if (p.startsWith('~/'))
165
- return path.join(homedir(), p.slice(2));
166
- return p;
204
+ function configFromDeployment(deployment) {
205
+ const google = environmentGoogle(deployment.environment);
206
+ const microsoft = environmentMicrosoft(deployment.environment);
207
+ const license = environmentLicense(deployment.environment);
208
+ return {
209
+ ...baseConfigFromDeployment(deployment),
210
+ google: google.value ?? { clientId: DEFAULT_GOOGLE_CLIENT_ID, clientSecret: DEFAULT_GOOGLE_CLIENT_SECRET },
211
+ ...(microsoft.value ? { microsoft: microsoft.value } : {}),
212
+ ...(license.value ? { licenseKey: license.value, licenseKeyFromEnvironment: true } : {}),
213
+ };
167
214
  }
168
- /** Resolve (and create) the data dir, honoring cwd .env files and FLUXMAIL_DATA_DIR. */
169
- export function resolveDataDir() {
170
- loadDotEnv();
171
- const fromEnv = readEnvironment('FLUXMAIL_DATA_DIR');
172
- const dataDir = fromEnv ? expandHome(fromEnv) : path.join(homedir(), '.fluxmail');
215
+ export function resolveDataDir(options = {}) {
216
+ const dataDir = resolveDataDirectory(options.env);
173
217
  mkdirSync(dataDir, { recursive: true });
174
218
  return dataDir;
175
219
  }
176
- export function configFilePath(dataDir) {
177
- return path.join(dataDir, 'config.env');
178
- }
179
- /** Apply settings persisted by `fluxmail config set` without overriding the current environment. */
180
- export function applyStoredConfig(dataDir) {
181
- applyDotEnvFile(configFilePath(dataDir));
182
- }
183
- /** Best-effort permission tightening: chmod fails on files owned by another user, but the read may still work. */
184
- function tryRestrictPermissions(file) {
185
- try {
186
- chmodSync(file, 0o600);
187
- }
188
- catch {
189
- // Reading (or a later write) will surface a real permission problem.
190
- }
191
- }
192
- /** Settings persisted by `fluxmail config set`, e.g. GOOGLE_CLIENT_ID. */
193
- export function readStoredConfig(dataDir) {
194
- const file = configFilePath(dataDir);
195
- if (!existsSync(file))
196
- return {};
197
- tryRestrictPermissions(file);
198
- return parseEnvContent(readFileSync(file, 'utf8'));
199
- }
200
- const CONFIG_KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/;
201
- export function setStoredConfig(dataDir, key, value) {
202
- if (!CONFIG_KEY_PATTERN.test(key)) {
203
- throw new Error(`Invalid key "${key}": use UPPER_SNAKE_CASE, e.g. GOOGLE_CLIENT_ID`);
204
- }
205
- if (key === 'FLUXMAIL_DATA_DIR') {
206
- throw new Error('FLUXMAIL_DATA_DIR cannot be stored in the data dir itself; set it in your shell or a .env file');
207
- }
208
- withConfigLock(dataDir, () => {
209
- const stored = readStoredConfig(dataDir);
210
- stored[key] = value;
211
- writeStoredConfig(dataDir, stored);
220
+ export function readLoggingSettings(dataDir = resolveDataDir()) {
221
+ const deployment = resolveDeploymentConfig({
222
+ env: { ...process.env, FLUXMAIL_DATA_DIR: dataDir },
223
+ defaultLicenseServerUrl: DEFAULT_LICENSE_SERVER_URL,
224
+ generateEncryptionKey: false,
212
225
  });
226
+ return { level: deployment.logLevel, destination: deployment.logDestination };
213
227
  }
214
- export function unsetStoredConfig(dataDir, key) {
215
- return withConfigLock(dataDir, () => {
216
- const stored = readStoredConfig(dataDir);
217
- if (!(key in stored))
218
- return false;
219
- delete stored[key];
220
- writeStoredConfig(dataDir, stored);
221
- return true;
228
+ export function resolveStoreLocation(options = {}) {
229
+ const deployment = resolveDeploymentConfig({
230
+ ...options,
231
+ defaultLicenseServerUrl: DEFAULT_LICENSE_SERVER_URL,
232
+ generateEncryptionKey: false,
222
233
  });
234
+ return { dataDir: deployment.dataDir, dbPath: deployment.dbPath, deployment };
223
235
  }
224
- export function maskStoredConfigValue(key, value) {
225
- if (!/SECRET|KEY|TOKEN|PASSWORD/i.test(key))
226
- return value;
227
- return value.length > 8 ? `${value.slice(0, 4)}…${value.slice(-4)}` : '********';
236
+ export function loadConfig(storeLocation) {
237
+ const initial = storeLocation?.deployment;
238
+ const deployment = initial
239
+ ? ensureDeploymentEncryptionKey(initial)
240
+ : resolveDeploymentConfig({
241
+ env: {
242
+ ...process.env,
243
+ ...(storeLocation?.dataDir ? { FLUXMAIL_DATA_DIR: storeLocation.dataDir } : {}),
244
+ ...(storeLocation?.dbPath ? { FLUXMAIL_DB_PATH: storeLocation.dbPath } : {}),
245
+ },
246
+ defaultLicenseServerUrl: DEFAULT_LICENSE_SERVER_URL,
247
+ generateEncryptionKey: true,
248
+ });
249
+ return configFromDeployment(deployment);
228
250
  }
229
- function writeStoredConfig(dataDir, values) {
230
- const file = configFilePath(dataDir);
231
- const temporary = path.join(dataDir, `.config.env.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
232
- const lines = Object.entries(values).map(([k, v]) => `${k}=${JSON.stringify(v)}`);
233
- let descriptor;
234
- try {
235
- descriptor = openSync(temporary, 'wx', 0o600);
236
- writeFileSync(descriptor, lines.join('\n') + (lines.length ? '\n' : ''), 'utf8');
237
- fsyncSync(descriptor);
238
- closeSync(descriptor);
239
- descriptor = undefined;
240
- renameSync(temporary, file);
241
- tryRestrictPermissions(file);
242
- trySyncDirectory(dataDir);
251
+ export class ConfigurationService {
252
+ deployment;
253
+ store;
254
+ config;
255
+ googleEnvironment;
256
+ microsoftEnvironment;
257
+ licenseEnvironment;
258
+ currentGoogle;
259
+ currentMicrosoft;
260
+ currentLicenseKey;
261
+ observedDataVersion = -1;
262
+ googleSource = 'built-in';
263
+ microsoftSource = null;
264
+ currentLicenseSource = null;
265
+ constructor(deployment, db) {
266
+ this.deployment = deployment;
267
+ this.store = new InstanceConfigStore(db, deployment.encryptionKey);
268
+ this.googleEnvironment = environmentGoogle(deployment.environment);
269
+ this.microsoftEnvironment = environmentMicrosoft(deployment.environment);
270
+ this.licenseEnvironment = environmentLicense(deployment.environment);
271
+ const config = baseConfigFromDeployment(deployment);
272
+ Object.defineProperties(config, {
273
+ google: {
274
+ enumerable: true,
275
+ get: () => {
276
+ this.refreshIfChanged();
277
+ return this.currentGoogle;
278
+ },
279
+ },
280
+ microsoft: {
281
+ enumerable: true,
282
+ get: () => {
283
+ this.refreshIfChanged();
284
+ return this.currentMicrosoft;
285
+ },
286
+ },
287
+ licenseKey: {
288
+ enumerable: true,
289
+ get: () => {
290
+ this.refreshIfChanged();
291
+ return this.currentLicenseKey;
292
+ },
293
+ },
294
+ licenseKeyFromEnvironment: {
295
+ enumerable: true,
296
+ value: this.licenseEnvironment.controlled,
297
+ },
298
+ });
299
+ this.config = config;
300
+ this.reload();
243
301
  }
244
- finally {
245
- if (descriptor !== undefined)
246
- closeSync(descriptor);
247
- rmSync(temporary, { force: true });
302
+ refreshIfChanged() {
303
+ if (this.store.dataVersion() !== this.observedDataVersion)
304
+ this.reload();
248
305
  }
249
- }
250
- function trySyncDirectory(directory) {
251
- let descriptor;
252
- try {
253
- descriptor = openSync(directory, 'r');
254
- fsyncSync(descriptor);
255
- }
256
- catch {
257
- // Some filesystems do not support fsync on directories.
258
- }
259
- finally {
260
- if (descriptor !== undefined)
261
- closeSync(descriptor);
262
- }
263
- }
264
- function withConfigLock(dataDir, callback) {
265
- return withFileLock(path.join(dataDir, '.config.lock'), {
266
- timeoutMs: CONFIG_LOCK_TIMEOUT_MS,
267
- staleMs: CONFIG_LOCK_STALE_MS,
268
- description: `the stored configuration at ${configFilePath(dataDir)}`,
269
- }, callback);
270
- }
271
- function decodeEncryptionKey(value, errorMessage) {
272
- if (!/^[0-9a-fA-F]{64}$/.test(value))
273
- throw new Error(errorMessage);
274
- return Buffer.from(value, 'hex');
275
- }
276
- function readPort(name, fallback) {
277
- const raw = readEnvironment(name);
278
- if (raw === undefined)
279
- return fallback;
280
- const value = Number(raw);
281
- if (!Number.isInteger(value) || value < 1 || value > 65_535) {
282
- throw new Error(`${name} must be an integer between 1 and 65535, got "${raw}"`);
283
- }
284
- return value;
285
- }
286
- function readMaxAttachmentBytes() {
287
- const raw = readEnvironment('FLUXMAIL_MAX_ATTACHMENT_MB');
288
- if (raw === undefined)
289
- return DEFAULT_MAX_ATTACHMENT_BYTES;
290
- const value = Number(raw);
291
- const hardMaxMegabytes = HARD_MAX_ATTACHMENT_BYTES / BYTES_PER_MEGABYTE;
292
- if (!Number.isInteger(value) || value < 1 || value > hardMaxMegabytes) {
293
- throw new Error(`FLUXMAIL_MAX_ATTACHMENT_MB must be an integer between 1 and ${hardMaxMegabytes}, got "${raw}"`);
294
- }
295
- return value * BYTES_PER_MEGABYTE;
296
- }
297
- function readTrustProxy() {
298
- const raw = readEnvironment('FLUXMAIL_TRUST_PROXY');
299
- if (raw === undefined)
300
- return false;
301
- const normalized = raw.trim().toLowerCase();
302
- if (normalized === '1' || normalized === 'true')
303
- return true;
304
- if (normalized === '0' || normalized === 'false')
305
- return false;
306
- throw new Error(`FLUXMAIL_TRUST_PROXY must be 0, 1, false, or true, got "${raw}"`);
307
- }
308
- function readPublicUrl(port) {
309
- const value = (readEnvironment('FLUXMAIL_PUBLIC_URL') ?? `http://localhost:${port}`).replace(/\/+$/, '');
310
- let parsed;
311
- try {
312
- parsed = new URL(value);
313
- }
314
- catch {
315
- throw new Error(`FLUXMAIL_PUBLIC_URL must be a valid HTTP or HTTPS URL, got "${value}"`);
316
- }
317
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
318
- throw new Error(`FLUXMAIL_PUBLIC_URL must use HTTP or HTTPS, got "${parsed.protocol}"`);
319
- }
320
- if (parsed.username || parsed.password) {
321
- throw new Error('FLUXMAIL_PUBLIC_URL cannot contain embedded credentials');
322
- }
323
- if (parsed.search || parsed.hash) {
324
- throw new Error('FLUXMAIL_PUBLIC_URL cannot contain a query string or fragment');
306
+ reload() {
307
+ while (true) {
308
+ const dataVersion = this.store.dataVersion();
309
+ const storedGoogle = this.googleEnvironment.controlled ? undefined : this.store.google();
310
+ const storedMicrosoft = this.microsoftEnvironment.controlled ? undefined : this.store.microsoft();
311
+ const storedLicense = this.licenseEnvironment.controlled ? undefined : this.store.licenseKey();
312
+ if (this.store.dataVersion() !== dataVersion)
313
+ continue;
314
+ this.currentGoogle = this.googleEnvironment.value ??
315
+ storedGoogle ?? { clientId: DEFAULT_GOOGLE_CLIENT_ID, clientSecret: DEFAULT_GOOGLE_CLIENT_SECRET };
316
+ this.googleSource = this.googleEnvironment.source ?? (storedGoogle ? 'stored' : 'built-in');
317
+ this.currentMicrosoft = this.microsoftEnvironment.value ?? storedMicrosoft;
318
+ this.microsoftSource = this.microsoftEnvironment.source ?? (storedMicrosoft ? 'stored' : null);
319
+ this.currentLicenseKey = this.licenseEnvironment.value ?? storedLicense;
320
+ this.currentLicenseSource = this.licenseEnvironment.source ?? (storedLicense ? 'stored' : null);
321
+ this.observedDataVersion = dataVersion;
322
+ return this.config;
323
+ }
325
324
  }
326
- return value;
327
- }
328
- function loadEncryptionKey(dataDir) {
329
- const fromEnv = readEnvironment('FLUXMAIL_ENCRYPTION_KEY');
330
- if (fromEnv) {
331
- return decodeEncryptionKey(fromEnv, 'FLUXMAIL_ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes)');
325
+ oauthStatus() {
326
+ this.refreshIfChanged();
327
+ return {
328
+ google: {
329
+ clientId: this.currentGoogle.clientId,
330
+ clientSecretConfigured: true,
331
+ source: this.googleSource,
332
+ mutable: !this.googleEnvironment.controlled,
333
+ },
334
+ outlook: {
335
+ clientId: this.currentMicrosoft?.clientId ?? null,
336
+ tenantId: this.currentMicrosoft?.tenantId ?? null,
337
+ clientSecretConfigured: Boolean(this.currentMicrosoft?.clientSecret),
338
+ source: this.microsoftSource,
339
+ mutable: !this.microsoftEnvironment.controlled,
340
+ },
341
+ };
332
342
  }
333
- // Auto-generate on first run so getting started requires zero key management.
334
- const keyPath = path.join(dataDir, 'encryption.key');
335
- if (existsSync(keyPath)) {
336
- tryRestrictPermissions(keyPath);
337
- const key = decodeEncryptionKey(readFileSync(keyPath, 'utf8').trim(), `Corrupt encryption key at ${keyPath}`);
338
- return key;
343
+ licenseSource() {
344
+ this.refreshIfChanged();
345
+ return this.currentLicenseSource;
339
346
  }
340
- return withFileLock(path.join(dataDir, '.encryption-key.lock'), {
341
- timeoutMs: CONFIG_LOCK_TIMEOUT_MS,
342
- staleMs: CONFIG_LOCK_STALE_MS,
343
- description: `the encryption key at ${keyPath}`,
344
- }, () => {
345
- if (existsSync(keyPath)) {
346
- tryRestrictPermissions(keyPath);
347
- return decodeEncryptionKey(readFileSync(keyPath, 'utf8').trim(), `Corrupt encryption key at ${keyPath}`);
347
+ setGoogle(value) {
348
+ if (this.googleEnvironment.controlled) {
349
+ throw new EnvironmentControlledSettingError('Google OAuth is controlled by environment variables and cannot be changed through the API.');
348
350
  }
349
- const key = randomBytes(32);
350
- const temporary = path.join(dataDir, `.encryption.key.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
351
- let descriptor;
352
- try {
353
- descriptor = openSync(temporary, 'wx', 0o600);
354
- writeFileSync(descriptor, key.toString('hex') + '\n', 'utf8');
355
- fsyncSync(descriptor);
356
- closeSync(descriptor);
357
- descriptor = undefined;
358
- renameSync(temporary, keyPath);
359
- trySyncDirectory(dataDir);
360
- return key;
351
+ this.store.setGoogle(value);
352
+ this.reload();
353
+ }
354
+ resetGoogle() {
355
+ if (this.googleEnvironment.controlled) {
356
+ throw new EnvironmentControlledSettingError('Google OAuth is controlled by environment variables and cannot be changed through the API.');
361
357
  }
362
- finally {
363
- if (descriptor !== undefined)
364
- closeSync(descriptor);
365
- rmSync(temporary, { force: true });
358
+ const removed = this.store.removeGoogle();
359
+ this.reload();
360
+ return removed;
361
+ }
362
+ setMicrosoft(value) {
363
+ if (this.microsoftEnvironment.controlled) {
364
+ throw new EnvironmentControlledSettingError('Microsoft OAuth is controlled by environment variables and cannot be changed through the API.');
366
365
  }
367
- });
368
- }
369
- export function resolveStoreLocation() {
370
- const dataDir = resolveDataDir();
371
- const databasePath = readEnvironment('FLUXMAIL_DB_PATH') ?? readStoredConfig(dataDir).FLUXMAIL_DB_PATH;
372
- return {
373
- dataDir,
374
- dbPath: databasePath ? expandHome(databasePath) : path.join(dataDir, 'fluxmail.db'),
375
- };
376
- }
377
- export function loadConfig(storeLocation = resolveStoreLocation()) {
378
- // Precedence: shell env > cwd .env.local > cwd .env > data-dir config.env.
379
- const { dataDir, dbPath } = storeLocation;
380
- const licenseKeyFromEnvironment = readEnvironment('FLUXMAIL_LICENSE_KEY') !== undefined;
381
- applyStoredConfig(dataDir);
382
- const port = readPort('FLUXMAIL_PORT', 8977);
383
- const oauthPort = readPort('FLUXMAIL_OAUTH_PORT', 8976);
384
- const publicUrlConfigured = readEnvironment('FLUXMAIL_PUBLIC_URL') !== undefined;
385
- const publicUrl = readPublicUrl(port);
386
- const config = {
387
- dataDir,
388
- dbPath,
389
- encryptionKey: loadEncryptionKey(dataDir),
390
- port,
391
- publicUrl,
392
- publicUrlConfigured,
393
- trustProxy: readTrustProxy(),
394
- oauthPort,
395
- oauthHost: readEnvironment('FLUXMAIL_OAUTH_HOST') ?? '127.0.0.1',
396
- maxAttachmentBytes: readMaxAttachmentBytes(),
397
- licenseServerUrl: (readEnvironment('FLUXMAIL_LICENSE_SERVER_URL') ?? DEFAULT_LICENSE_SERVER_URL).replace(/\/+$/, ''),
398
- };
399
- const licenseKey = readEnvironment('FLUXMAIL_LICENSE_KEY')?.trim();
400
- if (licenseKey) {
401
- config.licenseKey = licenseKey;
402
- config.licenseKeyFromEnvironment = licenseKeyFromEnvironment;
366
+ this.refreshIfChanged();
367
+ if (this.currentMicrosoft)
368
+ this.store.pinMicrosoftOAuthAppForAccounts(this.currentMicrosoft);
369
+ this.store.setMicrosoft(value);
370
+ this.reload();
403
371
  }
404
- const clientId = readEnvironment('GOOGLE_CLIENT_ID')?.trim();
405
- const clientSecret = readEnvironment('GOOGLE_CLIENT_SECRET')?.trim();
406
- if (!clientId && clientSecret) {
407
- throw new Error('GOOGLE_CLIENT_SECRET requires GOOGLE_CLIENT_ID.');
372
+ resetMicrosoft() {
373
+ if (this.microsoftEnvironment.controlled) {
374
+ throw new EnvironmentControlledSettingError('Microsoft OAuth is controlled by environment variables and cannot be changed through the API.');
375
+ }
376
+ this.refreshIfChanged();
377
+ if (this.currentMicrosoft)
378
+ this.store.pinMicrosoftOAuthAppForAccounts(this.currentMicrosoft);
379
+ const removed = this.store.removeMicrosoft();
380
+ this.reload();
381
+ return removed;
408
382
  }
409
- if (clientId && !clientSecret) {
410
- throw new Error('GOOGLE_CLIENT_ID requires GOOGLE_CLIENT_SECRET.');
383
+ setLicenseKey(value) {
384
+ if (this.licenseEnvironment.controlled) {
385
+ throw new EnvironmentControlledSettingError('The license is controlled by the environment and cannot be changed through the API.');
386
+ }
387
+ this.store.setLicenseKey(value);
388
+ this.reload();
411
389
  }
412
- config.google =
413
- clientId && clientSecret
414
- ? { clientId, clientSecret }
415
- : { clientId: DEFAULT_GOOGLE_CLIENT_ID, clientSecret: DEFAULT_GOOGLE_CLIENT_SECRET };
416
- const microsoftClientId = readEnvironment('MICROSOFT_CLIENT_ID')?.trim();
417
- if (microsoftClientId) {
418
- const microsoftClientSecret = readEnvironment('MICROSOFT_CLIENT_SECRET')?.trim();
419
- config.microsoft = {
420
- clientId: microsoftClientId,
421
- tenantId: readEnvironment('MICROSOFT_TENANT_ID')?.trim() || 'common',
422
- ...(microsoftClientSecret ? { clientSecret: microsoftClientSecret } : {}),
423
- };
390
+ removeLicenseKey() {
391
+ if (this.licenseEnvironment.controlled) {
392
+ throw new EnvironmentControlledSettingError('The license is controlled by the environment and cannot be changed through the API.');
393
+ }
394
+ const removed = this.store.removeLicenseKey();
395
+ this.reload();
396
+ return removed;
424
397
  }
425
- return config;
398
+ }
399
+ export function createConfigurationService(deployment, db) {
400
+ return new ConfigurationService(deployment, db);
426
401
  }
427
402
  //# sourceMappingURL=config.js.map