11import { readFileSync } from 'node:fs' ;
22import path from 'node:path' ;
33import { configManager } from '@/lib/admin/config-manager' ;
4+ import { logger } from '@/lib/logger' ;
5+ import { resolveEndpointAllowed } from './endpoint-guard' ;
46import { getInstanceId } from './state' ;
57import { getLoginCounts } from './login-tracker' ;
68import type {
@@ -58,23 +60,67 @@ export function bucketCount(n: number): CountBucket {
5860
5961async function readFeatures ( ) : Promise < TelemetryFeatures > {
6062 await configManager . ensureLoaded ( ) ;
61- const policy = configManager . getPolicy ( ) ;
62- const gates = policy . features ?? { } ;
63+ const gates = configManager . getPolicy ( ) . features ;
6364 const cfg = configManager . getAll ( ) ;
6465 return {
6566 // Booleans only. We read whether a feature is enabled - never any
6667 // config value beyond a presence check.
67- calendar : gates . calendarTasksEnabled !== false ,
68- contacts : true ,
69- files : gates . filesEnabled === true ,
70- extensions : gates . pluginsEnabled !== false ,
71- push_relay : ! ! cfg [ 'pushRelayUrl' ] ,
72- oauth_enabled : ! ! cfg [ 'oauthClientId' ] ,
73- smime_enabled : gates . smimeEnabled === true ,
74- webdav_enabled : gates . filesEnabled === true ,
68+ calendar : gates . calendarTasksEnabled === true ,
69+ contacts : gates . contactsEnabled === true ,
70+ files : gates . filesEnabled === true ,
71+ extensions : gates . pluginsEnabled === true ,
72+ oauth_enabled : cfg [ 'oauthEnabled' ] === true ,
73+ smime_enabled : gates . smimeEnabled === true ,
7574 } ;
7675}
7776
77+ const STALWART_VERSION_TTL_MS = 24 * 60 * 60 * 1000 ;
78+ let stalwartVersionCache : { version : string | null ; fetchedAt : number } | null = null ;
79+
80+ // Stalwart returns the version in the Server response header
81+ // (e.g. "Stalwart Mail Server v0.16.0"). The /.well-known/jmap endpoint
82+ // requires auth, but the header is on the 401 response too, so an
83+ // unauthenticated GET is enough. Cached for a day to avoid hammering
84+ // the JMAP server on every payload preview.
85+ async function detectStalwartVersion ( ) : Promise < string | null > {
86+ if ( process . env . STALWART_VERSION ) return process . env . STALWART_VERSION ;
87+ if ( stalwartVersionCache &&
88+ Date . now ( ) - stalwartVersionCache . fetchedAt < STALWART_VERSION_TTL_MS ) {
89+ return stalwartVersionCache . version ;
90+ }
91+ await configManager . ensureLoaded ( ) ;
92+ const serverUrl = configManager . get < string > ( 'jmapServerUrl' , '' ) . trim ( ) ;
93+ if ( ! serverUrl ) {
94+ stalwartVersionCache = { version : null , fetchedAt : Date . now ( ) } ;
95+ return null ;
96+ }
97+ const wellKnown = `${ serverUrl . replace ( / \/ + $ / , '' ) } /.well-known/jmap` ;
98+ // Reuse the SSRF guard so a misconfigured JMAP_SERVER_URL pointing at an
99+ // internal host doesn't get probed from telemetry context either.
100+ const guard = await resolveEndpointAllowed ( wellKnown ) ;
101+ if ( ! guard . ok ) {
102+ stalwartVersionCache = { version : null , fetchedAt : Date . now ( ) } ;
103+ return null ;
104+ }
105+ try {
106+ const res = await fetch ( wellKnown , {
107+ method : 'GET' ,
108+ signal : AbortSignal . timeout ( 3000 ) ,
109+ } ) ;
110+ const server = res . headers . get ( 'server' ) ?? '' ;
111+ const m = server . match ( / ( \d + \. \d + \. \d + (?: - [ \w . ] + ) ? ) / ) ;
112+ const version = m ?. [ 1 ] ?? null ;
113+ stalwartVersionCache = { version, fetchedAt : Date . now ( ) } ;
114+ return version ;
115+ } catch ( err ) {
116+ logger . debug ?.( 'telemetry: stalwart version probe failed' , {
117+ error : err instanceof Error ? err . message : String ( err ) ,
118+ } ) ;
119+ stalwartVersionCache = { version : null , fetchedAt : Date . now ( ) } ;
120+ return null ;
121+ }
122+ }
123+
78124// Account counts come from the local login tracker, which records a per-
79125// instance HMAC of every successful login plus the timestamp. Total = unique
80126// identities seen in the last 90 days; active7d = identities with a login in
@@ -99,6 +145,7 @@ export async function buildPayload(): Promise<TelemetryPayload> {
99145 const features = await readFeatures ( ) ;
100146 const accounts = await getLoginCounts ( ) ;
101147 const exts = await countExtensions ( ) ;
148+ const stalwart_version = await detectStalwartVersion ( ) ;
102149 const uptime_days = Math . min (
103150 365 ,
104151 Math . floor ( ( Date . now ( ) - processStartedAt ) / 86_400_000 ) ,
@@ -113,7 +160,7 @@ export async function buildPayload(): Promise<TelemetryPayload> {
113160 platform : detectPlatform ( ) ,
114161 node_version : process . versions . node ,
115162 os_family : detectOs ( ) ,
116- stalwart_version : process . env . STALWART_VERSION ?? null ,
163+ stalwart_version,
117164 features,
118165 counts : {
119166 accounts : bucketCount ( accounts . total ) ,
0 commit comments