Skip to content

Commit 419382d

Browse files
committed
feat: add contacts feature gate and update telemetry payload
1 parent 8935b81 commit 419382d

5 files changed

Lines changed: 69 additions & 15 deletions

File tree

app/admin/policy/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
1515
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
1616
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
1717
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
18+
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
1819
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
1920
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
2021
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },

lib/admin/config-manager.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
33
import path from 'node:path';
44
import { logger } from '@/lib/logger';
55
import { readFileEnv } from '@/lib/read-file-env';
6-
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
6+
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
77

88
function getAdminDir(): string {
99
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
@@ -35,6 +35,7 @@ class ConfigManager {
3535
this.policyCache = {
3636
...DEFAULT_POLICY,
3737
...policy,
38+
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
3839
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
3940
};
4041
} else {
@@ -143,7 +144,12 @@ class ConfigManager {
143144
* Update the settings policy. Writes to disk.
144145
*/
145146
async setPolicy(policy: SettingsPolicy): Promise<void> {
146-
this.policyCache = { ...DEFAULT_POLICY, ...policy };
147+
this.policyCache = {
148+
...DEFAULT_POLICY,
149+
...policy,
150+
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
151+
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
152+
};
147153
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
148154
}
149155

lib/admin/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface FeatureGates {
3939
folderIconsEnabled: boolean;
4040
hoverActionsConfigEnabled: boolean;
4141
filesEnabled: boolean;
42+
contactsEnabled: boolean;
4243
}
4344

4445
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -58,6 +59,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
5859
folderIconsEnabled: true,
5960
hoverActionsConfigEnabled: true,
6061
filesEnabled: true,
62+
contactsEnabled: true,
6163
};
6264

6365
export interface ThemePolicy {

lib/telemetry/payload.ts

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { readFileSync } from 'node:fs';
22
import path from 'node:path';
33
import { configManager } from '@/lib/admin/config-manager';
4+
import { logger } from '@/lib/logger';
5+
import { resolveEndpointAllowed } from './endpoint-guard';
46
import { getInstanceId } from './state';
57
import { getLoginCounts } from './login-tracker';
68
import type {
@@ -58,23 +60,67 @@ export function bucketCount(n: number): CountBucket {
5860

5961
async 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),

lib/telemetry/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@ export interface TelemetryFeatures {
1212
contacts: boolean;
1313
files: boolean;
1414
extensions: boolean;
15-
push_relay: boolean;
1615
oauth_enabled: boolean;
1716
smime_enabled: boolean;
18-
webdav_enabled: boolean;
1917
}
2018

2119
export interface TelemetryPayload {

0 commit comments

Comments
 (0)