From 419382d25d4fdb2cb141328cc054d02cf3b8fddc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:54:11 +0200 Subject: [PATCH] feat: add contacts feature gate and update telemetry payload --- app/admin/policy/page.tsx | 1 + lib/admin/config-manager.ts | 10 ++++-- lib/admin/types.ts | 2 ++ lib/telemetry/payload.ts | 69 +++++++++++++++++++++++++++++++------ lib/telemetry/types.ts | 2 -- 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx index 0204b2d4..6e0ed174 100644 --- a/app/admin/policy/page.tsx +++ b/app/admin/policy/page.tsx @@ -15,6 +15,7 @@ const FEATURE_GATE_LABELS: Partial { - 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); } diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 07171f31..5bcfa39c 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -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 { diff --git a/lib/telemetry/payload.ts b/lib/telemetry/payload.ts index d3f8e2e8..5add320c 100644 --- a/lib/telemetry/payload.ts +++ b/lib/telemetry/payload.ts @@ -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 { 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 { + 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('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 { 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 { 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), diff --git a/lib/telemetry/types.ts b/lib/telemetry/types.ts index bfb476ab..12c0ba47 100644 --- a/lib/telemetry/types.ts +++ b/lib/telemetry/types.ts @@ -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 {