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' },
|
||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||
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' },
|
||||
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' },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
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 {
|
||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
||||
@@ -35,6 +35,7 @@ class ConfigManager {
|
||||
this.policyCache = {
|
||||
...DEFAULT_POLICY,
|
||||
...policy,
|
||||
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||
};
|
||||
} else {
|
||||
@@ -143,7 +144,12 @@ class ConfigManager {
|
||||
* Update the settings policy. Writes to disk.
|
||||
*/
|
||||
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>);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface FeatureGates {
|
||||
folderIconsEnabled: boolean;
|
||||
hoverActionsConfigEnabled: boolean;
|
||||
filesEnabled: boolean;
|
||||
contactsEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
@@ -58,6 +59,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
folderIconsEnabled: true,
|
||||
hoverActionsConfigEnabled: true,
|
||||
filesEnabled: true,
|
||||
contactsEnabled: true,
|
||||
};
|
||||
|
||||
export interface ThemePolicy {
|
||||
|
||||
+58
-11
@@ -1,6 +1,8 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { resolveEndpointAllowed } from './endpoint-guard';
|
||||
import { getInstanceId } from './state';
|
||||
import { getLoginCounts } from './login-tracker';
|
||||
import type {
|
||||
@@ -58,23 +60,67 @@ export function bucketCount(n: number): CountBucket {
|
||||
|
||||
async function readFeatures(): Promise<TelemetryFeatures> {
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
const gates = policy.features ?? {};
|
||||
const gates = configManager.getPolicy().features;
|
||||
const cfg = configManager.getAll();
|
||||
return {
|
||||
// Booleans only. We read whether a feature is enabled - never any
|
||||
// config value beyond a presence check.
|
||||
calendar: gates.calendarTasksEnabled !== false,
|
||||
contacts: true,
|
||||
files: gates.filesEnabled === true,
|
||||
extensions: gates.pluginsEnabled !== false,
|
||||
push_relay: !!cfg['pushRelayUrl'],
|
||||
oauth_enabled: !!cfg['oauthClientId'],
|
||||
smime_enabled: gates.smimeEnabled === true,
|
||||
webdav_enabled: gates.filesEnabled === true,
|
||||
calendar: gates.calendarTasksEnabled === true,
|
||||
contacts: gates.contactsEnabled === true,
|
||||
files: gates.filesEnabled === true,
|
||||
extensions: gates.pluginsEnabled === true,
|
||||
oauth_enabled: cfg['oauthEnabled'] === true,
|
||||
smime_enabled: gates.smimeEnabled === 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-
|
||||
// instance HMAC of every successful login plus the timestamp. Total = unique
|
||||
// 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 accounts = await getLoginCounts();
|
||||
const exts = await countExtensions();
|
||||
const stalwart_version = await detectStalwartVersion();
|
||||
const uptime_days = Math.min(
|
||||
365,
|
||||
Math.floor((Date.now() - processStartedAt) / 86_400_000),
|
||||
@@ -113,7 +160,7 @@ export async function buildPayload(): Promise<TelemetryPayload> {
|
||||
platform: detectPlatform(),
|
||||
node_version: process.versions.node,
|
||||
os_family: detectOs(),
|
||||
stalwart_version: process.env.STALWART_VERSION ?? null,
|
||||
stalwart_version,
|
||||
features,
|
||||
counts: {
|
||||
accounts: bucketCount(accounts.total),
|
||||
|
||||
@@ -12,10 +12,8 @@ export interface TelemetryFeatures {
|
||||
contacts: boolean;
|
||||
files: boolean;
|
||||
extensions: boolean;
|
||||
push_relay: boolean;
|
||||
oauth_enabled: boolean;
|
||||
smime_enabled: boolean;
|
||||
webdav_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TelemetryPayload {
|
||||
|
||||
Reference in New Issue
Block a user