feat: add contacts feature gate and update telemetry payload
This commit is contained in:
@@ -15,6 +15,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
|||||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||||
|
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||||
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
||||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
||||||
|
|
||||||
function getAdminDir(): string {
|
function getAdminDir(): string {
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
||||||
@@ -35,6 +35,7 @@ class ConfigManager {
|
|||||||
this.policyCache = {
|
this.policyCache = {
|
||||||
...DEFAULT_POLICY,
|
...DEFAULT_POLICY,
|
||||||
...policy,
|
...policy,
|
||||||
|
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -143,7 +144,12 @@ class ConfigManager {
|
|||||||
* Update the settings policy. Writes to disk.
|
* Update the settings policy. Writes to disk.
|
||||||
*/
|
*/
|
||||||
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
||||||
this.policyCache = { ...DEFAULT_POLICY, ...policy };
|
this.policyCache = {
|
||||||
|
...DEFAULT_POLICY,
|
||||||
|
...policy,
|
||||||
|
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||||
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||||
|
};
|
||||||
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface FeatureGates {
|
|||||||
folderIconsEnabled: boolean;
|
folderIconsEnabled: boolean;
|
||||||
hoverActionsConfigEnabled: boolean;
|
hoverActionsConfigEnabled: boolean;
|
||||||
filesEnabled: boolean;
|
filesEnabled: boolean;
|
||||||
|
contactsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||||
@@ -58,6 +59,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
|||||||
folderIconsEnabled: true,
|
folderIconsEnabled: true,
|
||||||
hoverActionsConfigEnabled: true,
|
hoverActionsConfigEnabled: true,
|
||||||
filesEnabled: true,
|
filesEnabled: true,
|
||||||
|
contactsEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ThemePolicy {
|
export interface ThemePolicy {
|
||||||
|
|||||||
+58
-11
@@ -1,6 +1,8 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { resolveEndpointAllowed } from './endpoint-guard';
|
||||||
import { getInstanceId } from './state';
|
import { getInstanceId } from './state';
|
||||||
import { getLoginCounts } from './login-tracker';
|
import { getLoginCounts } from './login-tracker';
|
||||||
import type {
|
import type {
|
||||||
@@ -58,23 +60,67 @@ export function bucketCount(n: number): CountBucket {
|
|||||||
|
|
||||||
async function readFeatures(): Promise<TelemetryFeatures> {
|
async function readFeatures(): Promise<TelemetryFeatures> {
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const policy = configManager.getPolicy();
|
const gates = configManager.getPolicy().features;
|
||||||
const gates = policy.features ?? {};
|
|
||||||
const cfg = configManager.getAll();
|
const cfg = configManager.getAll();
|
||||||
return {
|
return {
|
||||||
// Booleans only. We read whether a feature is enabled - never any
|
// Booleans only. We read whether a feature is enabled - never any
|
||||||
// config value beyond a presence check.
|
// config value beyond a presence check.
|
||||||
calendar: gates.calendarTasksEnabled !== false,
|
calendar: gates.calendarTasksEnabled === true,
|
||||||
contacts: true,
|
contacts: gates.contactsEnabled === true,
|
||||||
files: gates.filesEnabled === true,
|
files: gates.filesEnabled === true,
|
||||||
extensions: gates.pluginsEnabled !== false,
|
extensions: gates.pluginsEnabled === true,
|
||||||
push_relay: !!cfg['pushRelayUrl'],
|
oauth_enabled: cfg['oauthEnabled'] === true,
|
||||||
oauth_enabled: !!cfg['oauthClientId'],
|
smime_enabled: gates.smimeEnabled === true,
|
||||||
smime_enabled: gates.smimeEnabled === true,
|
|
||||||
webdav_enabled: gates.filesEnabled === true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STALWART_VERSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
let stalwartVersionCache: { version: string | null; fetchedAt: number } | null = null;
|
||||||
|
|
||||||
|
// Stalwart returns the version in the Server response header
|
||||||
|
// (e.g. "Stalwart Mail Server v0.16.0"). The /.well-known/jmap endpoint
|
||||||
|
// requires auth, but the header is on the 401 response too, so an
|
||||||
|
// unauthenticated GET is enough. Cached for a day to avoid hammering
|
||||||
|
// the JMAP server on every payload preview.
|
||||||
|
async function detectStalwartVersion(): Promise<string | null> {
|
||||||
|
if (process.env.STALWART_VERSION) return process.env.STALWART_VERSION;
|
||||||
|
if (stalwartVersionCache &&
|
||||||
|
Date.now() - stalwartVersionCache.fetchedAt < STALWART_VERSION_TTL_MS) {
|
||||||
|
return stalwartVersionCache.version;
|
||||||
|
}
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const serverUrl = configManager.get<string>('jmapServerUrl', '').trim();
|
||||||
|
if (!serverUrl) {
|
||||||
|
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const wellKnown = `${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`;
|
||||||
|
// Reuse the SSRF guard so a misconfigured JMAP_SERVER_URL pointing at an
|
||||||
|
// internal host doesn't get probed from telemetry context either.
|
||||||
|
const guard = await resolveEndpointAllowed(wellKnown);
|
||||||
|
if (!guard.ok) {
|
||||||
|
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(wellKnown, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
});
|
||||||
|
const server = res.headers.get('server') ?? '';
|
||||||
|
const m = server.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
|
||||||
|
const version = m?.[1] ?? null;
|
||||||
|
stalwartVersionCache = { version, fetchedAt: Date.now() };
|
||||||
|
return version;
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug?.('telemetry: stalwart version probe failed', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Account counts come from the local login tracker, which records a per-
|
// Account counts come from the local login tracker, which records a per-
|
||||||
// instance HMAC of every successful login plus the timestamp. Total = unique
|
// instance HMAC of every successful login plus the timestamp. Total = unique
|
||||||
// identities seen in the last 90 days; active7d = identities with a login in
|
// identities seen in the last 90 days; active7d = identities with a login in
|
||||||
@@ -99,6 +145,7 @@ export async function buildPayload(): Promise<TelemetryPayload> {
|
|||||||
const features = await readFeatures();
|
const features = await readFeatures();
|
||||||
const accounts = await getLoginCounts();
|
const accounts = await getLoginCounts();
|
||||||
const exts = await countExtensions();
|
const exts = await countExtensions();
|
||||||
|
const stalwart_version = await detectStalwartVersion();
|
||||||
const uptime_days = Math.min(
|
const uptime_days = Math.min(
|
||||||
365,
|
365,
|
||||||
Math.floor((Date.now() - processStartedAt) / 86_400_000),
|
Math.floor((Date.now() - processStartedAt) / 86_400_000),
|
||||||
@@ -113,7 +160,7 @@ export async function buildPayload(): Promise<TelemetryPayload> {
|
|||||||
platform: detectPlatform(),
|
platform: detectPlatform(),
|
||||||
node_version: process.versions.node,
|
node_version: process.versions.node,
|
||||||
os_family: detectOs(),
|
os_family: detectOs(),
|
||||||
stalwart_version: process.env.STALWART_VERSION ?? null,
|
stalwart_version,
|
||||||
features,
|
features,
|
||||||
counts: {
|
counts: {
|
||||||
accounts: bucketCount(accounts.total),
|
accounts: bucketCount(accounts.total),
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ export interface TelemetryFeatures {
|
|||||||
contacts: boolean;
|
contacts: boolean;
|
||||||
files: boolean;
|
files: boolean;
|
||||||
extensions: boolean;
|
extensions: boolean;
|
||||||
push_relay: boolean;
|
|
||||||
oauth_enabled: boolean;
|
oauth_enabled: boolean;
|
||||||
smime_enabled: boolean;
|
smime_enabled: boolean;
|
||||||
webdav_enabled: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelemetryPayload {
|
export interface TelemetryPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user