+ Unique accounts that have logged in over the last 90 days. Identities are stored as a + per-instance HMAC, never as plaintext usernames. These are the numbers reported in the + heartbeat as bucketed ranges. +
+
diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts
index a3de8eb1..2bbab5af 100644
--- a/app/api/admin/telemetry/route.ts
+++ b/app/api/admin/telemetry/route.ts
@@ -10,6 +10,7 @@ import {
sendOnce,
reschedule,
DEFAULT_ENDPOINT,
+ getLoginCounts,
} from '@/lib/telemetry';
/**
@@ -23,7 +24,10 @@ export async function GET() {
if ('error' in auth) return auth.error;
const { consent, source, state } = await effectiveConsent();
- const payload = await buildPayload();
+ const [payload, accountCounts] = await Promise.all([
+ buildPayload(),
+ getLoginCounts(),
+ ]);
return NextResponse.json(
{
@@ -35,6 +39,7 @@ export async function GET() {
nextScheduledAt: state.nextScheduledAt,
defaultEndpoint: DEFAULT_ENDPOINT,
payloadPreview: payload,
+ accountCounts,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index e9f9a7cf..0fbd07f7 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -10,6 +10,7 @@ import {
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
+import { recordLogin } from '@/lib/telemetry/login-tracker';
const COOKIE_OPTIONS = {
...getCookieOptions(),
@@ -50,6 +51,8 @@ export async function POST(request: NextRequest) {
authHeader,
});
+ void recordLogin(username, normalizedServerUrl);
+
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts
index 89894e2c..138cacc2 100644
--- a/app/api/auth/stalwart-context/route.ts
+++ b/app/api/auth/stalwart-context/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
+import { recordLogin } from '@/lib/telemetry/login-tracker';
function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
@@ -32,6 +33,8 @@ export async function POST(request: NextRequest) {
authHeader,
});
+ void recordLogin(username, normalizedServerUrl);
+
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts
index 4cede47a..f311faec 100644
--- a/app/api/auth/totp-token-exchange/route.ts
+++ b/app/api/auth/totp-token-exchange/route.ts
@@ -7,6 +7,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
+import { recordLogin } from '@/lib/telemetry/login-tracker';
/**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -117,7 +118,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
}
- return await attemptAllStrategies(tokenEndpoint, username, password, slot);
+ return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot);
} catch (error) {
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -126,6 +127,7 @@ export async function POST(request: NextRequest) {
async function attemptAllStrategies(
tokenEndpoint: string,
+ serverUrl: string,
username: string,
password: string,
slot: number,
@@ -144,6 +146,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
+ void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
@@ -155,6 +158,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
+ void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
@@ -166,6 +170,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (Basic Auth header)');
+ void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'Basic Auth header', error: result.error });
@@ -177,6 +182,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
+ void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
diff --git a/lib/telemetry/index.ts b/lib/telemetry/index.ts
index cec835b8..bdc8ae11 100644
--- a/lib/telemetry/index.ts
+++ b/lib/telemetry/index.ts
@@ -3,6 +3,7 @@ export { buildPayload, markProcessStart } from './payload';
export {
loadState, saveState, getInstanceId, effectiveConsent,
} from './state';
+export { recordLogin, getLoginCounts } from './login-tracker';
export type {
TelemetryPayload, TelemetryStateFile, ConsentState,
Platform, OsFamily, CountBucket, TelemetryFeatures,
diff --git a/lib/telemetry/login-tracker.ts b/lib/telemetry/login-tracker.ts
new file mode 100644
index 00000000..3534ca36
--- /dev/null
+++ b/lib/telemetry/login-tracker.ts
@@ -0,0 +1,125 @@
+import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import { createHmac } from 'node:crypto';
+import { logger } from '@/lib/logger';
+import { getInstanceId } from './state';
+
+// We never store usernames or server URLs in the clear. Each login is
+// recorded as HMAC-SHA256(username + '@' + serverUrl, instance_id), so the
+// file on disk cannot be cross-correlated with any other instance and is
+// not PII even if leaked.
+
+interface LoginRecord {
+ id: string;
+ lastLoginAt: string;
+}
+
+interface LoginsFile {
+ records: LoginRecord[];
+}
+
+const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
+const RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
+
+let cache: LoginsFile | null = null;
+
+function getDir(): string {
+ return process.env.TELEMETRY_DATA_DIR || path.join(process.cwd(), 'data', 'telemetry');
+}
+
+function loginsPath(): string {
+ return path.join(getDir(), 'logins.json');
+}
+
+async function ensureDir(): Promise