fluxmail 0.4.1 → 0.6.0

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