- {t("sign_out_now")}
+ // The safe answer comes first, so the focus trap starts on it and
+ // Enter keeps the user signed in; row-reverse shows it on the right.
+
From 00fdedbbc102028db74d0597f50b0aab552a6ada Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:31:20 +0100
Subject: [PATCH 06/11] test: keep the org specs' admin sign-in across runs
---
integration/tests/helpers/org.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/integration/tests/helpers/org.ts b/integration/tests/helpers/org.ts
index 3c14996fe..bd6d17631 100644
--- a/integration/tests/helpers/org.ts
+++ b/integration/tests/helpers/org.ts
@@ -12,6 +12,7 @@
*/
import { type Browser, type BrowserContext, type Page, expect } from '@playwright/test';
import { existsSync, mkdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
import path from 'node:path';
import { WEBMAIL_URL } from './config';
import { accountSwitcher, neutralizeDevOverlay } from './app';
@@ -44,7 +45,9 @@ export function contextOptions(phone = false) {
};
}
-const stateDir = path.join(process.cwd(), 'integration', 'test-results');
+// Not under integration/test-results: Playwright empties that folder on
+// every run, which would cost an admin login per run.
+const stateDir = path.join(tmpdir(), 'bulwark-org-it');
const adminStatePath = () => path.join(stateDir, `org-admin-${new URL(WEBMAIL_URL).port || '80'}.json`);
/**
From defe4e93f3ee99eb13c2008fca8f763654243582 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:42:19 +0100
Subject: [PATCH 07/11] fix: bind the sign-in time to the refresh token and
count unstamped sessions from a fixed time
---
app/api/auth/session/route.ts | 38 ++--
app/api/auth/sso/complete/route.ts | 4 +-
app/api/auth/stalwart-context/route.ts | 22 +--
app/api/auth/token/route.ts | 38 ++--
app/api/auth/totp-token-exchange/route.ts | 9 +-
lib/__tests__/auth-crypto.test.ts | 4 +-
lib/__tests__/impersonation-session.test.ts | 2 +-
lib/__tests__/session-max-age-routes.test.ts | 191 ++++++++++++-------
lib/auth/session-age.ts | 159 ++++++++++++---
lib/session-policy/constants.ts | 6 +
lib/stalwart/auth-context.ts | 19 +-
11 files changed, 329 insertions(+), 163 deletions(-)
create mode 100644 lib/session-policy/constants.ts
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 6a0ce0a23..e68645a30 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -23,7 +23,7 @@ import { revokeWopiTokens } from '@/lib/wopi/revocation';
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
-import { isPastMaxAge, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
+import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
function sessionCookieOptions() {
return {
@@ -77,30 +77,25 @@ function getSlot(request: NextRequest): number {
/**
* The organisation's maximum session length for a remembered session
* (lib/auth/session-age.ts). A cookie from before sign-in times were
- * recorded counts as signed in now and gets the time written into it.
- * Returns the sign-in time, or null when the session is too old: its
- * cookies are cleared then.
+ * recorded counts as signed in when the server first enforced the limits,
+ * a fixed time, so a kept copy of it runs out like any other. Returns the
+ * sign-in time, or the limit when the session is too old: its cookies are
+ * cleared then.
*/
async function checkSessionAge(
cookieStore: Awaited>,
slot: number,
credentials: SessionCredentials,
): Promise<{ signedInAt: number } | { maxHours: number }> {
- const cookieName = sessionCookieName(slot);
- if (credentials.signedInAt === undefined) {
- const signedInAt = Date.now();
- const secret = credentials.token !== undefined ? { token: credentials.token } : credentials.password;
- cookieStore.set(cookieName, encryptSession(credentials.serverUrl, credentials.username, secret, signedInAt), sessionCookieOptions());
- return { signedInAt };
- }
+ const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince();
// An impersonation login is `target%master`; the policy is the target's.
const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]);
- if (isPastMaxAge(credentials.signedInAt, maxHours)) {
- cookieStore.delete(cookieName);
+ if (isPastMaxAge(signedInAt, maxHours)) {
+ cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
return { maxHours };
}
- return { signedInAt: credentials.signedInAt };
+ return { signedInAt };
}
function sessionTooOld(maxHours: number) {
@@ -209,14 +204,14 @@ export async function POST(request: NextRequest) {
username,
authHeader,
...(accountName !== username ? { accountName } : {}),
- signedInAt,
});
void recordLogin(username, normalizedServerUrl);
const warning = insecureCookieHint(request);
if (warning) logger.warn(`session: ${warning}`);
- return NextResponse.json(warning ? { ok: true, warning } : { ok: true });
+ // The browser counts the maximum session length from the server's time.
+ return NextResponse.json(warning ? { ok: true, warning, signedInAt } : { ok: true, signedInAt });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json(
@@ -242,7 +237,7 @@ export async function GET(request: NextRequest) {
}
const credentials = decryptSession(token);
- if (!credentials) {
+ if (!credentials || holdsMasterPassword(credentials)) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
@@ -255,13 +250,12 @@ export async function GET(request: NextRequest) {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: sessionAuthHeader(credentials),
- signedInAt: age.signedInAt,
});
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
const { serverUrl, username } = credentials;
return NextResponse.json(
- { serverUrl, username },
+ { serverUrl, username, signedInAt: age.signedInAt },
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
);
} catch (error) {
@@ -309,12 +303,10 @@ export async function PUT(request: NextRequest) {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: sessionAuthHeader(credentials),
- signedInAt: age.signedInAt,
});
- // The credentials as they were stored, without the bookkeeping.
- const { signedInAt: _signedInAt, ...restored } = credentials;
- return NextResponse.json(restored, {
+ // The browser counts the maximum session length from the server's time.
+ return NextResponse.json({ ...credentials, signedInAt: age.signedInAt }, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
} catch (error) {
diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts
index 5ae1dc11c..f79fdb6b9 100644
--- a/app/api/auth/sso/complete/route.ts
+++ b/app/api/auth/sso/complete/route.ts
@@ -106,6 +106,7 @@ export async function POST(request: NextRequest) {
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
+ const signedInAt = Date.now();
// For the mobile handoff flow the tokens are handed back to the app
// verbatim - we deliberately don't write any cookies on the webmail
@@ -117,7 +118,7 @@ export async function POST(request: NextRequest) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
- writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
+ writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token });
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (pendingServerId) {
@@ -169,6 +170,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
+ signed_in_at: signedInAt,
});
} catch (error) {
// Clean up pending cookie on any error
diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts
index 43c884d1e..fe25532d3 100644
--- a/app/api/auth/stalwart-context/route.ts
+++ b/app/api/auth/stalwart-context/route.ts
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { cookies } from 'next/headers';
-import { readStalwartAuthContextFromStore, setStalwartAuthContext } from '@/lib/stalwart/auth-context';
+import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { readSignInStamp, writeSignInStamp } from '@/lib/auth/session-age';
+import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
@@ -103,24 +104,21 @@ export async function POST(request: NextRequest) {
// Just signed in: an older refusal of this credential no longer applies.
recordStoredCredentialStatus(authHeader, 200);
- // The context is written again on every renewed token and restore, so
- // the sign-in time is carried over: from this slot's context for the
- // same account, else from the token sign-in's stamp, else it is now.
+ // A token sign-in's stamp (lib/auth/session-age.ts) knows no account:
+ // the token routes never see one. The first verified account names it,
+ // so the maximum session length of its domain applies on renewal.
const cookieStore = await cookies();
- const previous = readStalwartAuthContextFromStore(cookieStore, slot);
- const stamp = readSignInStamp(cookieStore, slot);
- const ownStamp = stamp && (!stamp.account || stamp.account === accountName) ? stamp : null;
- const samePrevious = previous && previous.serverUrl === normalizedServerUrl && previous.username === username ? previous : null;
- const signedInAt = samePrevious?.signedInAt ?? ownStamp?.signedInAt ?? Date.now();
- // The token routes know no account; the first verified one is the stamp's.
- if (ownStamp && !ownStamp.account) writeSignInStamp(cookieStore, slot, { ...ownStamp, account: accountName });
+ const refreshToken = cookieStore.get(refreshTokenCookieName(slot))?.value;
+ const stamp = refreshToken ? readSignInStamp(cookieStore, slot, refreshToken) : null;
+ if (refreshToken && stamp && !stamp.account) {
+ writeSignInStamp(cookieStore, slot, { signedInAt: stamp.signedInAt, refreshToken, account: accountName });
+ }
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
...(accountName !== username ? { accountName } : {}),
- signedInAt,
});
void recordLogin(username, normalizedServerUrl);
diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts
index 942514fde..b2ea53c3e 100644
--- a/app/api/auth/token/route.ts
+++ b/app/api/auth/token/route.ts
@@ -34,8 +34,8 @@ import {
clearSignInStamp,
isPastMaxAge,
maxSessionHoursFor,
- readSignInStamp,
SESSION_TOO_OLD,
+ tokenSignedInAt,
writeSignInStamp,
} from '@/lib/auth/session-age';
import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
@@ -151,18 +151,21 @@ export async function POST(request: NextRequest) {
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
+ // A sign-in: the maximum session length counts from here, on the server
+ // and, from this answer, in the browser.
+ const signedInAt = Date.now();
const response = NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
+ signed_in_at: signedInAt,
});
const cookieStore = await cookies();
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
- // A sign-in: the maximum session length counts from here.
- writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
+ writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token });
}
const cachedAccessToken = cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600, [tokens.refresh_token, serverId]);
storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, cachedAccessToken, serverId]);
@@ -201,20 +204,23 @@ export async function PUT(request: NextRequest) {
}
// The organisation's maximum session length (lib/auth/session-age.ts).
- // A refresh token from before sign-in times were recorded counts as
- // signed in now.
- const stamp = readSignInStamp(cookieStore, slot);
- if (!stamp) {
- writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
- } else {
+ // A refresh token without a matching stamp (from before stamps, or one
+ // whose stamp was deleted) counts from the fixed time the server first
+ // enforced the limits, never from now.
+ const age = await tokenSignedInAt(cookieStore, slot, refreshToken);
+ if (age) {
const context = readStalwartAuthContextFromStore(cookieStore, slot);
- const maxHours = await maxSessionHoursFor(stamp.account ?? context?.accountName ?? context?.username ?? null);
- if (isPastMaxAge(stamp.signedInAt, maxHours)) {
+ const account = age.stamp?.account ?? context?.accountName ?? context?.username ?? null;
+ const maxHours = await maxSessionHoursFor(account);
+ if (isPastMaxAge(age.signedInAt, maxHours)) {
await signOutSlot(cookieStore, slot, request.nextUrl.basePath, false);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: SESSION_TOO_OLD, maxHours }, { status: 401 });
}
+ // Bind the time to this token from now on.
+ if (!age.stamp) writeSignInStamp(cookieStore, slot, { signedInAt: age.signedInAt, refreshToken, ...(account ? { account } : {}) });
}
+ const signedIn = age ? { signed_in_at: age.signedInAt } : {};
// A session restore calls this to get its token back, not because the
// current one expired. Serving the cached token avoids spending a refresh
@@ -231,6 +237,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({
access_token: cached.accessToken,
expires_in: cached.expiresIn,
+ ...signedIn,
});
}
}
@@ -279,6 +286,14 @@ export async function PUT(request: NextRequest) {
if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
+ // The stamp follows the rotated token, with the same sign-in time and lifetime.
+ if (age) {
+ writeSignInStamp(cookieStore, slot, {
+ signedInAt: age.signedInAt,
+ refreshToken: tokens.refresh_token,
+ ...(age.stamp?.account ? { account: age.stamp.account } : {}),
+ });
+ }
}
const expiresIn = tokens.expires_in || 3600;
@@ -292,6 +307,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({
access_token: tokens.access_token,
expires_in: expiresIn,
+ ...signedIn,
});
} catch (error) {
logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' });
diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts
index bdceadbf7..9b61b428f 100644
--- a/app/api/auth/totp-token-exchange/route.ts
+++ b/app/api/auth/totp-token-exchange/route.ts
@@ -209,13 +209,15 @@ async function storeAndRespond(
rememberMe: boolean,
): Promise {
const cookieStore = await cookies();
+ // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
+ const signedInAt = Date.now();
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
// Without "Remember me" the sign-in ends with the browser session.
const { maxAge: _maxAge, ...sessionOptions } = getCookieOptions();
- cookieStore.set(cookieName, tokens.refresh_token, rememberMe ? getCookieOptions() : sessionOptions);
- // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
- writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
+ const options = rememberMe ? getCookieOptions() : sessionOptions;
+ cookieStore.set(cookieName, tokens.refresh_token, options);
+ writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token }, options);
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (serverId) {
@@ -228,5 +230,6 @@ async function storeAndRespond(
access_token: tokens.access_token,
expires_in: tokens.expires_in || 3600,
has_refresh_token: !!tokens.refresh_token,
+ signed_in_at: signedInAt,
});
}
diff --git a/lib/__tests__/auth-crypto.test.ts b/lib/__tests__/auth-crypto.test.ts
index e30d869b5..5d296c0e1 100644
--- a/lib/__tests__/auth-crypto.test.ts
+++ b/lib/__tests__/auth-crypto.test.ts
@@ -36,8 +36,8 @@ describe('encryptSession / decryptSession', () => {
});
it('produces a base64 token with a random IV (two encrypts differ, both decrypt equal)', () => {
- const a = encryptSession('https://x', 'u', 'p');
- const b = encryptSession('https://x', 'u', 'p');
+ const a = encryptSession('https://x', 'u', 'p', 1000);
+ const b = encryptSession('https://x', 'u', 'p', 1000);
expect(a).not.toBe(b);
expect(Buffer.from(a, 'base64').toString('base64')).toBe(a); // valid base64
expect(decryptSession(a)).toEqual(decryptSession(b));
diff --git a/lib/__tests__/impersonation-session.test.ts b/lib/__tests__/impersonation-session.test.ts
index b70abd176..aa44a2171 100644
--- a/lib/__tests__/impersonation-session.test.ts
+++ b/lib/__tests__/impersonation-session.test.ts
@@ -110,7 +110,7 @@ describe('impersonation handoff', () => {
const put = await readSession();
expect(put.status).toBe(200);
const body = await put.json();
- expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET });
+ expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET, signedInAt: expect.any(Number) });
expect(JSON.stringify(body)).not.toContain(MASTER_PASSWORD);
expect([...jar.values()].join()).not.toContain(MASTER_PASSWORD);
});
diff --git a/lib/__tests__/session-max-age-routes.test.ts b/lib/__tests__/session-max-age-routes.test.ts
index 5b3dd23b8..8048e2a28 100644
--- a/lib/__tests__/session-max-age-routes.test.ts
+++ b/lib/__tests__/session-max-age-routes.test.ts
@@ -1,14 +1,19 @@
// @vitest-environment node
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
import type { SettingsPolicy } from '@/lib/admin/types';
/**
* The organisation's maximum session length on the server: the routes that
* restore a remembered sign-in or renew a provider token refuse a session
- * older than `maxHours` of the account's domain scopes, and a cookie from
- * before sign-in times were recorded counts as signed in when first seen.
+ * older than `maxHours` of the account's domain scopes. A cookie or token
+ * without a sign-in time counts from one fixed time, when the server first
+ * enforced the limits, so deleting the stamp or keeping an old cookie never
+ * makes a session younger.
*/
const SECRET = 's'.repeat(64);
@@ -43,7 +48,6 @@ vi.mock('@/lib/auth/verify-jmap-auth', () => ({
resolveJmapIdentity: async (serverUrl: string, _auth: string, username: string) => ({ serverUrl, accountName: username }),
}));
-const tokenFetch = vi.fn();
vi.mock('@/lib/oauth/token-exchange', () => ({
exchangeCodeForTokens: async () => ({ access_token: 'first-access', refresh_token: 'refresh-1', expires_in: 300 }),
buildOAuthParams: (params: Record) => new URLSearchParams(params),
@@ -54,8 +58,12 @@ vi.mock('@/lib/oauth/token-exchange', () => ({
DEFAULT_CLIENT_ID: 'bulwark-webmail',
}));
-const { encryptSession, decryptSession, decryptPayload } = await import('@/lib/auth/crypto');
-const { readSignInStamp, writeSignInStamp } = await import('@/lib/auth/session-age');
+const stateDir = mkdtempSync(path.join(tmpdir(), 'bw-session-age-'));
+process.env.ADMIN_STATE_DIR = stateDir;
+afterAll(() => rmSync(stateDir, { recursive: true, force: true }));
+
+const { encryptSession, decryptSession } = await import('@/lib/auth/crypto');
+const { readSignInStamp, writeSignInStamp, limitsEnforcedSince, resetLimitsEnforcedSince } = await import('@/lib/auth/session-age');
const sessionRoute = await import('@/app/api/auth/session/route');
const tokenRoute = await import('@/app/api/auth/token/route');
const contextRoute = await import('@/app/api/auth/stalwart-context/route');
@@ -82,19 +90,33 @@ function legacySessionCookie(username: string, password: string): string {
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64');
}
+const store = async () => (await (await import('next/headers')).cookies()) as never;
const restore = () => sessionRoute.PUT(new NextRequest('https://webmail.example/api/auth/session', { method: 'PUT', headers: SAME_ORIGIN }));
const peek = () => sessionRoute.GET(new NextRequest('https://webmail.example/api/auth/session'));
-const refresh = () => tokenRoute.PUT(new NextRequest('https://webmail.example/api/auth/token?slot=0', { method: 'PUT', headers: SAME_ORIGIN }));
-const context = () => decryptPayload(jar.get('jmap_stalwart_ctx')!, 'session-context');
+const refresh = () => tokenRoute.PUT(new NextRequest('https://webmail.example/api/auth/token?slot=0&force=true', { method: 'PUT', headers: SAME_ORIGIN }));
+const signInWithProvider = () => tokenRoute.POST(new NextRequest('https://webmail.example/api/auth/token?slot=0', {
+ method: 'POST',
+ headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
+ body: JSON.stringify({ code: 'c', code_verifier: 'v', redirect_uri: 'https://webmail.example/auth/callback', slot: 0 }),
+}));
-beforeEach(() => {
+let issued = 0;
+const tokenFetch = vi.fn();
+
+beforeEach(async () => {
jar.clear();
+ rmSync(path.join(stateDir, 'session-limits-since'), { force: true });
+ resetLimitsEnforcedSince();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(NOW);
+ // The server enforces the limits from NOW on.
+ expect(await limitsEnforcedSince()).toBe(NOW);
policy = policyWithDomainMax(8);
+ issued = 0;
tokenFetch.mockReset();
tokenFetch.mockImplementation(async (url: string) => {
- if (url.endsWith('/token')) return Response.json({ access_token: 'new-access', expires_in: 300 });
+ // The provider rotates the refresh token on every renewal.
+ if (url.endsWith('/token')) return Response.json({ access_token: 'new-access', refresh_token: `rotated-${++issued}`, expires_in: 300 });
return new Response(null, { status: 200 });
});
vi.stubGlobal('fetch', tokenFetch);
@@ -105,24 +127,31 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+describe('the time the limits were first enforced', () => {
+ it('is recorded once and kept across restarts', async () => {
+ vi.setSystemTime(NOW + 5 * HOUR);
+ resetLimitsEnforcedSince();
+ expect(await limitsEnforcedSince()).toBe(NOW);
+ });
+});
+
describe('remembered sessions (/api/auth/session)', () => {
- it('records the sign-in time when the session is remembered', async () => {
+ it('records the sign-in time when the session is remembered, and answers with it', async () => {
const res = await sessionRoute.POST(new NextRequest('https://webmail.example/api/auth/session?slot=0', {
method: 'POST',
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' }),
}));
expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ ok: true, signedInAt: NOW });
expect(decryptSession(jar.get('jmap_session')!)?.signedInAt).toBe(NOW);
- expect(context()?.signedInAt).toBe(NOW);
});
- it('restores a session younger than the maximum, without the sign-in time in the answer', async () => {
+ it('restores a session younger than the maximum and says when it signed in', async () => {
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 7 * HOUR));
const res = await restore();
expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' });
- expect(context()?.signedInAt).toBe(NOW - 7 * HOUR);
+ expect(await res.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw', signedInAt: NOW - 7 * HOUR });
});
it('refuses a session older than its domain allows and clears its cookies', async () => {
@@ -150,41 +179,46 @@ describe('remembered sessions (/api/auth/session)', () => {
expect((await restore()).status).toBe(401);
});
- it('counts a cookie without a sign-in time as signed in when first seen', async () => {
- jar.set('jmap_session', legacySessionCookie('org-alice@example.org', 'pw'));
-
- const first = await restore();
- expect(first.status).toBe(200);
- expect(await first.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' });
- expect(decryptSession(jar.get('jmap_session')!)?.signedInAt).toBe(NOW);
+ it('counts a cookie without a sign-in time from the fixed time, without rewriting it', async () => {
+ const legacy = legacySessionCookie('org-alice@example.org', 'pw');
+ jar.set('jmap_session', legacy);
vi.setSystemTime(NOW + 7 * HOUR);
- expect((await restore()).status).toBe(200);
+ const first = await restore();
+ expect(first.status).toBe(200);
+ expect((await first.json()).signedInAt).toBe(NOW);
+ expect(jar.get('jmap_session')).toBe(legacy);
+
vi.setSystemTime(NOW + 8 * HOUR);
expect((await restore()).status).toBe(401);
});
+
+ it('refuses a kept copy of an old cookie replayed later', async () => {
+ const legacy = legacySessionCookie('org-alice@example.org', 'pw');
+ // First seen only now, nine hours after the server started enforcing.
+ vi.setSystemTime(NOW + 9 * HOUR);
+ jar.set('jmap_session', legacy);
+ expect((await restore()).status).toBe(401);
+ jar.set('jmap_session', legacy);
+ expect((await peek()).status).toBe(401);
+ });
});
describe('provider tokens (/api/auth/token)', () => {
- it('records the sign-in time when a provider sign-in stores its refresh token', async () => {
- // A leftover stamp from an earlier sign-in in this slot.
- const store = (await (await import('next/headers')).cookies()) as never;
- writeSignInStamp(store, 0, { signedInAt: NOW - 20 * HOUR, account: 'org-bob@example.org' });
-
- const res = await tokenRoute.POST(new NextRequest('https://webmail.example/api/auth/token?slot=0', {
- method: 'POST',
- headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
- body: JSON.stringify({ code: 'c', code_verifier: 'v', redirect_uri: 'https://webmail.example/auth/callback', slot: 0 }),
- }));
+ it('records the sign-in time with the refresh token when a provider sign-in stores it', async () => {
+ const res = await signInWithProvider();
expect(res.status).toBe(200);
- expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW });
+ expect((await res.json()).signed_in_at).toBe(NOW);
+ expect(readSignInStamp(await store(), 0, 'refresh-1')).toMatchObject({ signedInAt: NOW });
+ // The stamp belongs to that token only.
+ expect(readSignInStamp(await store(), 0, 'another-token')).toBeNull();
});
it('refuses to renew a token sign-in older than the maximum and revokes it', async () => {
jar.set('jmap_rt', 'refresh-1');
- const store = (await (await import('next/headers')).cookies()) as never;
- writeSignInStamp(store, 0, { signedInAt: NOW - 8 * HOUR, account: 'org-alice@example.org' });
+ writeSignInStamp(await store(), 0, { signedInAt: NOW + HOUR, refreshToken: 'refresh-1', account: 'org-alice@example.org' });
+ vi.setSystemTime(NOW + 9 * HOUR);
const res = await refresh();
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'session_expired', maxHours: 8 });
@@ -194,47 +228,70 @@ describe('provider tokens (/api/auth/token)', () => {
expect(tokenFetch).not.toHaveBeenCalledWith('https://id.example.org/token', expect.anything());
});
- it('renews a token sign-in younger than the maximum', async () => {
- jar.set('jmap_rt', 'refresh-1');
- const store = (await (await import('next/headers')).cookies()) as never;
- writeSignInStamp(store, 0, { signedInAt: NOW - 7 * HOUR, account: 'org-alice@example.org' });
+ it('renews a younger one, and the stamp follows the rotated token with the same time', async () => {
+ await signInWithProvider();
+ vi.setSystemTime(NOW + 3 * HOUR);
const res = await refresh();
expect(res.status).toBe(200);
- expect((await res.json()).access_token).toBe('new-access');
+ expect(await res.json()).toMatchObject({ access_token: 'new-access', signed_in_at: NOW });
+ expect(jar.get('jmap_rt')).toBe('rotated-1');
+ expect(readSignInStamp(await store(), 0, 'rotated-1')).toMatchObject({ signedInAt: NOW });
+
+ vi.setSystemTime(NOW + 7 * HOUR);
+ expect((await refresh()).status).toBe(200);
+ vi.setSystemTime(NOW + 8 * HOUR);
+ expect((await refresh()).status).toBe(401);
});
- it('counts a refresh token without a sign-in time as signed in when first seen', async () => {
- jar.set('jmap_rt', 'refresh-1');
+ it('counts a refresh token without a stamp from the fixed time and binds that time to it', async () => {
+ jar.set('jmap_rt', 'refresh-legacy');
+ vi.setSystemTime(NOW + HOUR);
+ const res = await refresh();
+ expect(res.status).toBe(200);
+ expect((await res.json()).signed_in_at).toBe(NOW);
+ expect(readSignInStamp(await store(), 0, 'rotated-1')).toMatchObject({ signedInAt: NOW });
+
+ vi.setSystemTime(NOW + 8 * HOUR);
+ expect((await refresh()).status).toBe(401);
+ });
+
+ it('does not make a session younger when its stamp is deleted', async () => {
+ vi.setSystemTime(NOW + 5 * HOUR);
+ await signInWithProvider();
+ jar.delete('jmap_signed_in');
+
+ vi.setSystemTime(NOW + 8 * HOUR);
+ const res = await refresh();
+ expect(res.status).toBe(401);
+ });
+
+ it('applies the domain of the account the stamp names', async () => {
+ jar.set('jmap_rt', 'refresh-1');
+ writeSignInStamp(await store(), 0, { signedInAt: NOW, refreshToken: 'refresh-1', account: 'org-dave@org-two.test' });
+ vi.setSystemTime(NOW + 100 * HOUR);
expect((await refresh()).status).toBe(200);
- const store = (await (await import('next/headers')).cookies()) as never;
- expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW });
+ });
+
+ it('does not take a stamp written for another refresh token', async () => {
+ jar.set('jmap_rt', 'refresh-2');
+ writeSignInStamp(await store(), 0, { signedInAt: NOW + 7 * HOUR, refreshToken: 'refresh-1' });
+
+ vi.setSystemTime(NOW + 8 * HOUR);
+ expect((await refresh()).status).toBe(401);
});
});
describe('the auth context (/api/auth/stalwart-context)', () => {
- const post = () => contextRoute.POST(new NextRequest('https://webmail.example/api/auth/stalwart-context', {
- method: 'POST',
- headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
- body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', authHeader: 'Bearer new', slot: 0 }),
- }));
-
- it('keeps the sign-in time when a renewed token is written again', async () => {
- jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 3 * HOUR));
- await restore();
- vi.setSystemTime(NOW + HOUR);
-
- expect((await post()).status).toBe(200);
- expect(context()?.signedInAt).toBe(NOW - 3 * HOUR);
- });
-
- it("takes a token sign-in's time from its stamp and names the account on it", async () => {
- const store = (await (await import('next/headers')).cookies()) as never;
- writeSignInStamp(store, 0, { signedInAt: NOW - 2 * HOUR });
-
- expect((await post()).status).toBe(200);
- expect(context()?.signedInAt).toBe(NOW - 2 * HOUR);
- expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW - 2 * HOUR, account: 'org-alice@example.org' });
+ it("names the account on a token sign-in's stamp", async () => {
+ await signInWithProvider();
+ const res = await contextRoute.POST(new NextRequest('https://webmail.example/api/auth/stalwart-context', {
+ method: 'POST',
+ headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
+ body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', authHeader: 'Bearer new', slot: 0 }),
+ }));
+ expect(res.status).toBe(200);
+ expect(readSignInStamp(await store(), 0, 'refresh-1')).toMatchObject({ signedInAt: NOW, account: 'org-alice@example.org' });
});
});
diff --git a/lib/auth/session-age.ts b/lib/auth/session-age.ts
index e5689e889..ba7f97e8a 100644
--- a/lib/auth/session-age.ts
+++ b/lib/auth/session-age.ts
@@ -8,41 +8,58 @@
* back: the routes that restore a remembered sign-in (/api/auth/session) or
* renew a provider token (/api/auth/token) refuse it and clear its cookies.
*
- * The sign-in time travels in the remembered-session cookie (`signedInAt`
- * in lib/auth/crypto.ts), in the auth-context cookie, and for token
- * sign-ins in a small cookie of its own written when the refresh token is
- * (the auth-context cookie ends with the browser, the refresh token lasts
- * 30 days). A cookie written before this existed has no sign-in time: it
- * counts as signed in when the server first sees it after the upgrade,
- * which then writes the time into it.
+ * The sign-in time travels in the remembered-session cookie (`signedInAt` in
+ * lib/auth/crypto.ts) and, for token sign-ins, in a stamp cookie of its own
+ * that is bound to the refresh token by a hash and rewritten with it on
+ * every rotation. A cookie or token without a sign-in time (one written
+ * before this existed, or a stamp that was deleted or doesn't match the
+ * token) counts as signed in at one fixed time: when the server first
+ * enforced the limits (limitsEnforcedSince). Deleting the stamp therefore
+ * never makes a session younger.
*
* The server knows an account's address, not its groups, so only Everyone
* and the domain scopes count here; a group scope's value is enforced by the
* client alone.
*/
+import { createHash } from 'node:crypto';
+import { readFile, writeFile } from 'node:fs/promises';
import type { cookies } from 'next/headers';
import { configManager } from '@/lib/admin/config-manager';
+import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
import { resolvePolicy } from '@/lib/admin/policy-scopes';
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
+import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { logger } from '@/lib/logger';
+export { SESSION_TOO_OLD } from '@/lib/session-policy/constants';
+
type CookieStore = Awaited>;
const HOUR_MS = 60 * 60 * 1000;
-/** What a route answers when it refuses a session that is too old. */
-export const SESSION_TOO_OLD = 'session_expired';
+const positiveHours = (value: unknown): number => (typeof value === 'number' && value > 0 ? value : 0);
-/** The maximum session length (hours, 0 = none) for an account's address. */
+/**
+ * The maximum session length (hours, 0 = none) for an account's address.
+ * Without an address (a token sign-in whose stamp names no account, which
+ * includes one whose stamp was deleted) the strictest value of Everyone and
+ * every domain entry applies, so losing the account never loosens the limit.
+ */
export async function maxSessionHoursFor(account: string | null | undefined): Promise {
try {
await configManager.ensureLoaded?.();
+ const raw = configManager.getPolicy();
const email = account?.trim().toLowerCase() ?? '';
- const policy = resolvePolicy(configManager.getPolicy(), email ? { email, groups: [] } : null).policy;
- const hours = policy.organisation?.session?.maxHours;
- return typeof hours === 'number' && hours > 0 ? hours : 0;
+ if (email) return positiveHours(resolvePolicy(raw, { email, groups: [] }).policy.organisation?.session?.maxHours);
+ const values = [
+ positiveHours(resolvePolicy(raw, null).policy.organisation?.session?.maxHours),
+ ...(raw.scopes ?? [])
+ .filter((scope) => (scope.match?.domains?.length ?? 0) > 0)
+ .map((scope) => positiveHours(scope.policy?.organisation?.session?.maxHours)),
+ ].filter((hours) => hours > 0);
+ return values.length > 0 ? Math.min(...values) : 0;
} catch (error) {
logger.warn('Could not read the maximum session length', { error: error instanceof Error ? error.message : 'Unknown error' });
return 0;
@@ -50,47 +67,120 @@ export async function maxSessionHoursFor(account: string | null | undefined): Pr
}
/** Whether a session signed in at `signedInAt` has outlived `maxHours`. */
-export function isPastMaxAge(signedInAt: number | undefined, maxHours: number, now = Date.now()): boolean {
- if (!maxHours || typeof signedInAt !== 'number') return false;
+export function isPastMaxAge(signedInAt: number, maxHours: number, now = Date.now()): boolean {
+ if (!maxHours) return false;
return now - signedInAt >= maxHours * HOUR_MS;
}
-export function validSignedInAt(value: unknown): number | undefined {
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
+// ── The fixed time for sessions without a sign-in time ─────────────────────
+
+const ENFORCED_SINCE_FILE = 'session-limits-since';
+const PROCESS_START = Date.now() - Math.round(process.uptime() * 1000);
+let enforcedSince: Promise | null = null;
+
+function parseTime(text: string): number | null {
+ const value = Number(text.trim());
+ return Number.isFinite(value) && value > 0 ? value : null;
+}
+
+async function readRecorded(path: string): Promise {
+ try {
+ return parseTime(await readFile(path, 'utf8'));
+ } catch {
+ return null;
+ }
}
/**
- * The sign-in time of a token sign-in, kept as long as its refresh token.
- * `account` is filled in once the auth context names the account.
+ * When this server first enforced the session limits, recorded once in the
+ * admin state dir. Falls back to the process start when the file can't be
+ * written (a read-only state dir), which restarts the count on a restart.
+ */
+export function limitsEnforcedSince(): Promise {
+ enforcedSince ??= (async () => {
+ const path = getStatePath(ENFORCED_SINCE_FILE);
+ const recorded = await readRecorded(path);
+ if (recorded) return recorded;
+ const now = Date.now();
+ try {
+ await ensureStateDir();
+ // 'wx': a second process starting at the same moment keeps the first one's time.
+ await writeFile(path, String(now), { flag: 'wx' });
+ return now;
+ } catch {
+ const other = await readRecorded(path);
+ if (other) return other;
+ logger.warn('Could not record when the session limits were first enforced; counting from the process start');
+ return PROCESS_START;
+ }
+ })();
+ return enforcedSince;
+}
+
+/** Test helper. */
+export function resetLimitsEnforcedSince(): void {
+ enforcedSince = null;
+}
+
+// ── The sign-in stamp of a token sign-in ───────────────────────────────────
+
+/**
+ * The sign-in time of a token sign-in, bound to the refresh token it was
+ * written with. `account` is filled in once the auth context names it.
*/
export interface SignInStamp {
signedInAt: number;
+ tokenHash: string;
account?: string;
}
+export function refreshTokenHash(refreshToken: string): string {
+ return createHash('sha256').update(refreshToken).digest('base64url');
+}
+
export function signInStampCookieName(slot: number): string {
return slot === 0 ? 'jmap_signed_in' : `jmap_signed_in_${slot}`;
}
-export function readSignInStamp(cookieStore: CookieStore, slot: number): SignInStamp | null {
+/** The slot's stamp, or null when it is missing, unreadable, or written for another refresh token. */
+export function readSignInStamp(cookieStore: CookieStore, slot: number, refreshToken: string): SignInStamp | null {
const value = cookieStore.get(signInStampCookieName(slot))?.value;
if (!value) return null;
try {
const payload = decryptPayload(value, 'session-age');
- const signedInAt = validSignedInAt(payload?.signedInAt);
- if (!signedInAt) return null;
- return typeof payload?.account === 'string' ? { signedInAt, account: payload.account } : { signedInAt };
+ const signedInAt = payload?.signedInAt;
+ if (typeof signedInAt !== 'number' || !Number.isFinite(signedInAt)) return null;
+ if (payload?.tokenHash !== refreshTokenHash(refreshToken)) return null;
+ const account = typeof payload.account === 'string' ? payload.account : undefined;
+ return { signedInAt, tokenHash: payload.tokenHash, ...(account ? { account } : {}) };
} catch {
return null;
}
}
-/** Without a session secret nothing can be sealed; the client's timer still applies. */
-export function writeSignInStamp(cookieStore: CookieStore, slot: number, stamp: SignInStamp): void {
+let missingSecretLogged = false;
+
+/**
+ * Written whenever the refresh token cookie is, with that cookie's options.
+ * Without a session secret nothing can be sealed; the client's timer still applies.
+ */
+export function writeSignInStamp(
+ cookieStore: CookieStore,
+ slot: number,
+ stamp: { signedInAt: number; refreshToken: string; account?: string },
+ options: Record = getCookieOptions(),
+): void {
+ if (!hasSessionSecret()) {
+ if (!missingSecretLogged) {
+ missingSecretLogged = true;
+ logger.warn('No session secret: the maximum session length is enforced by the browser only');
+ }
+ return;
+ }
try {
- const payload: Record = { signedInAt: stamp.signedInAt };
+ const payload: Record = { signedInAt: stamp.signedInAt, tokenHash: refreshTokenHash(stamp.refreshToken) };
if (stamp.account) payload.account = stamp.account;
- cookieStore.set(signInStampCookieName(slot), encryptPayload(payload, 'session-age'), getCookieOptions());
+ cookieStore.set(signInStampCookieName(slot), encryptPayload(payload, 'session-age'), options);
} catch (error) {
logger.warn('Could not record the sign-in time', { error: error instanceof Error ? error.message : 'Unknown error' });
}
@@ -100,3 +190,18 @@ export function clearSignInStamp(cookieStore: CookieStore, slot: number): void {
const name = signInStampCookieName(slot);
if (cookieStore.get(name)) cookieStore.delete(name);
}
+
+/**
+ * When a token sign-in happened: its stamp's time, or the fixed time when
+ * there is no stamp for this refresh token. Null without a session secret,
+ * when the server can't keep sign-in times at all.
+ */
+export async function tokenSignedInAt(
+ cookieStore: CookieStore,
+ slot: number,
+ refreshToken: string,
+): Promise<{ signedInAt: number; stamp: SignInStamp | null } | null> {
+ if (!hasSessionSecret()) return null;
+ const stamp = readSignInStamp(cookieStore, slot, refreshToken);
+ return { signedInAt: stamp?.signedInAt ?? await limitsEnforcedSince(), stamp };
+}
diff --git a/lib/session-policy/constants.ts b/lib/session-policy/constants.ts
new file mode 100644
index 000000000..dab0c03a7
--- /dev/null
+++ b/lib/session-policy/constants.ts
@@ -0,0 +1,6 @@
+/**
+ * What the sign-in routes answer (`error`) when they refuse a session that
+ * reached the organisation's maximum length. The server (lib/auth/session-age.ts)
+ * and the browser (stores/auth-store.ts) both read it from here.
+ */
+export const SESSION_TOO_OLD = 'session_expired';
diff --git a/lib/stalwart/auth-context.ts b/lib/stalwart/auth-context.ts
index 3a34f1901..7d22b3f63 100644
--- a/lib/stalwart/auth-context.ts
+++ b/lib/stalwart/auth-context.ts
@@ -14,12 +14,6 @@ export interface StalwartAuthContext {
* data such as synced settings is keyed on this.
*/
accountName?: string;
- /**
- * When the user signed in (ms), for the organisation's maximum session
- * length (lib/auth/session-age.ts). Carried over when the context is
- * written again for the same account (a renewed token, a restore).
- */
- signedInAt?: number;
}
type CookieStore = Awaited>;
@@ -45,8 +39,7 @@ function isValidContext(payload: unknown): payload is StalwartAuthContext {
return typeof candidate.serverUrl === 'string'
&& typeof candidate.username === 'string'
&& typeof candidate.authHeader === 'string'
- && (candidate.accountName === undefined || typeof candidate.accountName === 'string')
- && (candidate.signedInAt === undefined || typeof candidate.signedInAt === 'number');
+ && (candidate.accountName === undefined || typeof candidate.accountName === 'string');
}
function getSessionCookieOptions() {
@@ -66,14 +59,8 @@ export function readStalwartAuthContextFromStore(
// Hand back exactly the declared shape: the envelope's own bookkeeping
// stays in the envelope rather than riding along into the credentials.
- const { serverUrl, username, authHeader, accountName, signedInAt } = payload;
- return {
- serverUrl,
- username,
- authHeader,
- ...(accountName ? { accountName } : {}),
- ...(typeof signedInAt === 'number' ? { signedInAt } : {}),
- };
+ const { serverUrl, username, authHeader, accountName } = payload;
+ return accountName ? { serverUrl, username, authHeader, accountName } : { serverUrl, username, authHeader };
}
export function setStalwartAuthContextInStore(
From 65e83e4b0dd3f1c5848f47fafc5e6ae52d0b8f35 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:53:56 +0100
Subject: [PATCH 08/11] fix: show the session warning above every overlay and
count the server's sign-in time
---
app/(main)/[locale]/login/page.tsx | 3 +
.../composer-sign-out-flush.test.tsx | 2 +-
.../__tests__/session-timeout-dialog.test.tsx | 48 ++++++++
.../__tests__/session-timeout-guard.test.tsx | 96 +++++++++++++++-
components/session/session-timeout-dialog.tsx | 28 ++++-
components/session/session-timeout-guard.tsx | 103 ++++++++++++++----
lib/__tests__/compose-flush-registry.test.ts | 8 +-
lib/compose-flush-registry.ts | 10 +-
lib/session-policy/__tests__/limits.test.ts | 7 +-
lib/session-policy/limits.ts | 11 +-
lib/session-policy/sign-out-reason.ts | 18 ++-
locales/de/common.json | 4 +-
locales/en/common.json | 4 +-
...auth-store-restore-session-too-old.test.ts | 31 ++++++
stores/auth-store.ts | 68 +++++++++---
15 files changed, 373 insertions(+), 68 deletions(-)
create mode 100644 components/session/__tests__/session-timeout-dialog.test.tsx
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx
index 84a214c2c..7da1f499b 100644
--- a/app/(main)/[locale]/login/page.tsx
+++ b/app/(main)/[locale]/login/page.tsx
@@ -1244,6 +1244,9 @@ function LoginPageContent() {
: sessionEnd?.kind === "max"
? tSessionLimits("reason_max", { hours: sessionEnd.hours })
: t("session_expired")}
+ {sessionEnd?.draftsUnsaved && (
+ {tSessionLimits("drafts_not_saved")}
+ )}
{
let result: Awaited> | undefined;
await act(async () => { result = await flushComposers(5000); });
- expect(result).toEqual({ total: 1, settled: 1, timedOut: false });
+ expect(result).toEqual({ total: 1, settled: 1, failed: 0, timedOut: false });
expect(createDraft).toHaveBeenCalledTimes(1);
expect(createDraft.mock.calls[0][1]).toBe('Half-written more');
});
diff --git a/components/session/__tests__/session-timeout-dialog.test.tsx b/components/session/__tests__/session-timeout-dialog.test.tsx
new file mode 100644
index 000000000..ad4603516
--- /dev/null
+++ b/components/session/__tests__/session-timeout-dialog.test.tsx
@@ -0,0 +1,48 @@
+import { act, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SessionTimeoutDialog } from '../session-timeout-dialog';
+
+const noop = () => {};
+
+describe('session timeout dialog', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-10-28T09:00:00Z'));
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('sits at the end of the body above plugin dialogs and the office editor', () => {
+ const { container } = render(
+
+
+
,
+ );
+ const overlay = screen.getByTestId('session-timeout-dialog');
+ expect(container.contains(overlay)).toBe(false);
+ expect(overlay.parentElement).toBe(document.body);
+ const z = Number(/z-\[(\d+)\]/.exec(overlay.className)?.[1]);
+ expect(z).toBeGreaterThan(100000);
+ });
+
+ it('does not read out every second, only the 30 and 10 second marks', () => {
+ render();
+ const message = screen.getByText('idle_message');
+ expect(message.closest('[aria-live]')).toBeNull();
+ const live = document.querySelector('[aria-live]')!;
+ expect(live.textContent).toBe('');
+ act(() => { vi.advanceTimersByTime(5_000); });
+ expect(live.textContent).toBe('seconds_left');
+ });
+
+ it('points aria-describedby only at a message that is there', () => {
+ const { rerender } = render();
+ const dialog = screen.getByRole('alertdialog');
+ const described = dialog.getAttribute('aria-describedby');
+ expect(described && document.getElementById(described)).toBeTruthy();
+
+ rerender();
+ expect(screen.getByRole('alertdialog').hasAttribute('aria-describedby')).toBe(false);
+ });
+});
diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx
index 4b1baccb1..6016df443 100644
--- a/components/session/__tests__/session-timeout-guard.test.tsx
+++ b/components/session/__tests__/session-timeout-guard.test.tsx
@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS } from '../session-timeout-guard';
+import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS, FRAME_FOCUS_CHECK_MS } from '../session-timeout-guard';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { usePolicyStore } from '@/stores/policy-store';
@@ -222,7 +222,10 @@ describe('session timeout guard', () => {
render();
await advance(0);
- await advance(9 * 60 * SECOND);
+ // Ten minutes left, ended a minute early for clock skew, warned a minute before that.
+ await advance(8 * 60 * SECOND - 1);
+ expect(screen.queryByText('max_title')).toBeNull();
+ await advance(1);
expect(screen.getByText('max_title')).toBeTruthy();
fireEvent.click(screen.getByText('dismiss'));
expect(screen.queryByText('max_title')).toBeNull();
@@ -231,4 +234,93 @@ describe('session timeout guard', () => {
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 1 });
});
+
+ it('signs out right away after a reload that follows a pause longer than the limit', async () => {
+ // Recorded by a tab before the reload, five minutes ago; signed in long before.
+ localStorage.setItem(LAST_ACTIVITY_KEY, String(START - 5 * 60 * SECOND));
+ usePolicyStore.setState({ rawPolicy: policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }) });
+ useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org', START - 60 * 60 * SECOND)], activeAccountId: 'alice' });
+ useAuthStore.setState({
+ isAuthenticated: true, isDemoMode: false, activeAccountId: 'alice',
+ client: fakeClient('org-alice@example.org') as never, logoutAll: logoutAll as never,
+ });
+ render();
+ // Moving the mouse while the page loads doesn't bring the session back.
+ fireEvent.pointerDown(window);
+ await advance(0);
+
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
+ });
+
+ it('says on the login page when a draft could not be saved', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ registerComposeFlush(async () => { throw new Error('save refused'); });
+
+ await advance(60 * SECOND);
+ await advance(0);
+ expect(logoutAll).toHaveBeenCalled();
+ expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1, draftsUnsaved: true });
+ });
+
+ it('does not count scrolling as activity', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ await advance(20 * SECOND);
+ fireEvent.scroll(window);
+ await advance(10 * SECOND);
+ expect(screen.getByText('idle_title')).toBeTruthy();
+ });
+
+ it('counts the focus on a cross-origin frame as activity, but not on a same-origin one', async () => {
+ const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true);
+ try {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ const editor = document.createElement('iframe');
+ Object.defineProperty(editor, 'contentDocument', { get: () => null });
+ document.body.appendChild(editor);
+ editor.focus();
+ expect(document.activeElement).toBe(editor);
+
+ await advance(45 * SECOND);
+ expect(screen.queryByText('idle_title')).toBeNull();
+
+ // A message body (same origin) clicked into and left doesn't keep the session.
+ const body = document.createElement('iframe');
+ document.body.appendChild(body);
+ body.focus();
+ await advance(30 * SECOND + FRAME_FOCUS_CHECK_MS);
+ expect(screen.getByText('idle_title')).toBeTruthy();
+ editor.remove();
+ body.remove();
+ } finally {
+ hasFocus.mockRestore();
+ }
+ });
+
+ it('asks again for the policy subject of a client that had none', async () => {
+ const policy = policyWith({ idleMinutes: 0, maxHours: 0, warnSeconds: 30 }, [
+ { id: 'example', label: 'example.org', match: { domains: ['example.org'] }, policy: { organisation: { session: { idleMinutes: 1 } } } },
+ ]);
+ let username = '';
+ const client = { ...fakeClient(''), getUsername: () => username };
+ usePolicyStore.setState({ rawPolicy: policy });
+ useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org')], activeAccountId: 'alice' });
+ useAuthStore.setState({
+ isAuthenticated: true, isDemoMode: false, activeAccountId: 'alice', connectedAccountsRevision: 1,
+ client: client as never, logoutAll: logoutAll as never,
+ });
+ render();
+ await advance(0);
+ await advance(2 * 60 * SECOND);
+ expect(logoutAll).not.toHaveBeenCalled();
+
+ // The session is complete now: the subject is known and the domain's minute applies.
+ username = 'org-alice@example.org';
+ await act(async () => { useAuthStore.setState({ connectedAccountsRevision: 2 }); });
+ await advance(0);
+ fireEvent.keyDown(window, { key: 'a' });
+ await advance(60 * SECOND);
+ await advance(0);
+ expect(logoutAll).toHaveBeenCalled();
+ });
});
diff --git a/components/session/session-timeout-dialog.tsx b/components/session/session-timeout-dialog.tsx
index f41b65613..edcd06e6c 100644
--- a/components/session/session-timeout-dialog.tsx
+++ b/components/session/session-timeout-dialog.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useId, useState } from "react";
+import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { Button } from "@/components/ui/button";
@@ -22,6 +23,9 @@ function secondsLeft(deadline: number | null): number {
return deadline === null ? 0 : Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
}
+/** Screen readers hear the dialog when it opens, then only at these marks, not every second. */
+const ANNOUNCE_AT = [30, 10];
+
/**
* The warning before an organisation session limit signs the user out. For
* the idle timeout it asks whether to stay; the maximum session length
@@ -31,6 +35,7 @@ export function SessionTimeoutDialog({ mode, deadline, maxHours, onStay, onDismi
const t = useTranslations("session_limits");
const id = useId();
const [seconds, setSeconds] = useState(() => secondsLeft(deadline));
+ const [announced, setAnnounced] = useState(null);
const dialogRef = useFocusTrap({
isActive: true,
onEscape: mode === "idle" ? onStay : mode === "max" ? onDismiss : undefined,
@@ -44,16 +49,25 @@ export function SessionTimeoutDialog({ mode, deadline, maxHours, onStay, onDismi
return () => clearInterval(interval);
}, [deadline, mode]);
+ useEffect(() => {
+ const mark = ANNOUNCE_AT.find((at) => seconds === at);
+ if (mark !== undefined) setAnnounced(mark);
+ }, [seconds]);
+
+ if (typeof document === "undefined") return null;
+
const title = mode === "max" ? t("max_title") : mode === "idle" ? t("idle_title") : t("signing_out");
- return (
-
+ // Portalled to the end of the body above everything else, the office
+ // editor (z-100) and plugin dialogs (z-100000) included.
+ return createPortal(
+
+ ,
+ document.body,
);
}
diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx
index cb43453e6..2a805f02e 100644
--- a/components/session/session-timeout-guard.tsx
+++ b/components/session/session-timeout-guard.tsx
@@ -18,32 +18,58 @@ import {
type SessionLimits,
} from "@/lib/session-policy/limits";
import { createSessionTimer, type SessionEndKind, type SessionTimer, type SessionTimerState } from "@/lib/session-policy/session-timer";
-import { rememberSessionEnd } from "@/lib/session-policy/sign-out-reason";
+import { rememberSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason";
import { SessionTimeoutDialog } from "./session-timeout-dialog";
-/** How long the sign-out waits for the open composers to save their drafts. */
+/** How long the sign-out waits for the open composers to save their drafts (and for sends in flight). */
export const DRAFT_FLUSH_TIMEOUT_MS = 8000;
/** Local activity reaches the timer and the other tabs at most this often. */
const ACTIVITY_THROTTLE_MS = 1000;
-const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel", "scroll", "focus"] as const;
+/**
+ * Bubbling input events, caught on the way down. Scrolling and focus moves
+ * inside the page don't count: the page scrolls and refocuses on its own.
+ */
+const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel"] as const;
+/**
+ * Input inside a cross-origin frame (the office editor, plugin panels)
+ * never reaches this window. While such a frame has the focus, that counts
+ * as activity, checked this often.
+ */
+export const FRAME_FOCUS_CHECK_MS = 5000;
-const subjects = new WeakMap>();
-function subjectFor(client: IJMAPClient): Promise {
- let subject = subjects.get(client);
- if (!subject) {
- subject = policySubjectForClient(client).catch(() => null);
- subjects.set(client, subject);
+function crossOriginFrameHasFocus(): boolean {
+ if (typeof document === "undefined" || !document.hasFocus()) return false;
+ const element = document.activeElement;
+ if (!(element instanceof HTMLIFrameElement)) return false;
+ try {
+ // Same-origin frames (the message body) are readable and don't count:
+ // a message clicked into and left would keep the session alive.
+ return element.contentDocument === null;
+ } catch {
+ return true;
}
+}
+
+/** Policy subjects per client and connection round; a failed or empty answer is asked again. */
+const subjects = new WeakMap }>();
+function subjectFor(client: IJMAPClient, revision: number): Promise {
+ const cached = subjects.get(client);
+ if (cached && cached.revision === revision) return cached.subject;
+ const subject = policySubjectForClient(client).catch(() => null);
+ subjects.set(client, { revision, subject });
+ void subject.then((value) => {
+ if (value === null && subjects.get(client)?.subject === subject) subjects.delete(client);
+ });
return subject;
}
/** The limits of every account signed in here, each with its own domain and group scopes. */
-async function collectLimits(): Promise {
+async function collectLimits(revision: number): Promise {
const auth = useAuthStore.getState();
const clients = new Map(auth.getAllConnectedClients());
if (auth.client && auth.activeAccountId && !clients.has(auth.activeAccountId)) clients.set(auth.activeAccountId, auth.client);
const accounts: AccountSession[] = await Promise.all([...clients.entries()].map(async ([accountId, client]) => {
- const subject = await subjectFor(client);
+ const subject = await subjectFor(client, revision);
const session = usePolicyStore.getState().resolveFor(subject).organisation?.session;
return { session, signedInAt: signedInAtFor(accountId) };
}));
@@ -64,6 +90,13 @@ function signedInAtFor(accountId: string): number | undefined {
return now;
}
+function endReason(kind: SessionEndKind, limits: SessionLimits, draftsUnsaved: boolean): SessionEndReason {
+ const drafts = draftsUnsaved ? { draftsUnsaved: true } : {};
+ return kind === "max"
+ ? { kind: "max", hours: limits.max?.hours ?? 0, ...drafts }
+ : { kind: "idle", minutes: limits.idle?.minutes ?? 0, ...drafts };
+}
+
const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: null };
/**
@@ -81,25 +114,36 @@ export function SessionTimeoutGuard() {
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
const accountsKey = useAccountStore((s) => s.accounts.map((a) => `${a.id}|${a.signedInAt ?? ""}|${a.isConnected}`).join(","));
+ // The last activity any tab recorded, read once before this tab's own
+ // listeners can write it: a reload after a pause longer than the limit
+ // signs out right away, whatever the user does while the page loads.
+ const [mount] = useState(() => ({ at: Date.now(), lastActivity: readLastActivity() }));
const [limits, setLimits] = useState(NO_SESSION_LIMITS);
+ const [limitsReady, setLimitsReady] = useState(false);
const [state, setState] = useState(IDLE_STATE);
const [ending, setEnding] = useState(false);
const [dismissedMax, setDismissedMax] = useState(null);
const timerRef = useRef(null);
+ const timersStartedRef = useRef(0);
const channelRef = useRef(null);
const limitsRef = useRef(limits);
limitsRef.current = limits;
+ const limitsReadyRef = useRef(limitsReady);
+ limitsReadyRef.current = limitsReady;
const endingRef = useRef(false);
const active = isAuthenticated && !isDemoMode;
useEffect(() => {
if (!active) {
setLimits(NO_SESSION_LIMITS);
+ setLimitsReady(false);
return;
}
let cancelled = false;
- void collectLimits().then((next) => {
- if (!cancelled) setLimits((prev) => (sameLimits(prev, next) ? prev : next));
+ void collectLimits(revision).then((next) => {
+ if (cancelled) return;
+ setLimits((prev) => (sameLimits(prev, next) ? prev : next));
+ setLimitsReady(true);
});
return () => { cancelled = true; };
}, [active, client, revision, rawPolicy, accountsKey]);
@@ -113,15 +157,15 @@ export function SessionTimeoutGuard() {
endingRef.current = true;
setEnding(true);
const current = limitsRef.current;
- rememberSessionEnd(kind === "max"
- ? { kind: "max", hours: current.max?.hours ?? 0 }
- : { kind: "idle", minutes: current.idle?.minutes ?? 0 });
- await flushComposers(DRAFT_FLUSH_TIMEOUT_MS);
+ // Said first, in case another tab's sign-out takes this one to the login page meanwhile.
+ rememberSessionEnd(endReason(kind, current, false));
+ const flushed = await flushComposers(DRAFT_FLUSH_TIMEOUT_MS);
+ if (flushed.failed > 0 || flushed.timedOut) rememberSessionEnd(endReason(kind, current, true));
await useAuthStore.getState().logoutAll({ endProviderSession: false });
}, []);
// The clock, while there is a limit to count.
- const hasLimits = active && (!!limits.idle || !!limits.max);
+ const hasLimits = active && limitsReady && (!!limits.idle || !!limits.max);
useEffect(() => {
if (!hasLimits) {
timerRef.current?.dispose();
@@ -133,18 +177,20 @@ export function SessionTimeoutGuard() {
timerRef.current.setLimits(limits);
return;
}
- // A tab opened or reloaded after a long pause starts from the pause, a
- // fresh sign-in from the sign-in.
+ // The first clock of this page starts from the activity recorded before
+ // the page loaded, a fresh sign-in from the sign-in. A clock started
+ // later (limits turned on meanwhile) from what the tabs recorded since.
+ const stored = timersStartedRef.current === 0 ? mount.lastActivity ?? mount.at : readLastActivity() ?? Date.now();
const newestSignIn = Math.max(0, ...useAccountStore.getState().accounts.map((a) => a.signedInAt ?? 0));
- const lastActivity = Math.max(readLastActivity() ?? Date.now(), newestSignIn);
+ timersStartedRef.current += 1;
timerRef.current = createSessionTimer({
limits,
- lastActivity,
+ lastActivity: Math.max(stored, newestSignIn),
onChange: setState,
onExpire: (kind) => { void endSession(kind); },
});
setState(timerRef.current.state());
- }, [hasLimits, limits, endSession]);
+ }, [hasLimits, limits, endSession, mount]);
useEffect(() => () => {
timerRef.current?.dispose();
@@ -164,7 +210,9 @@ export function SessionTimeoutGuard() {
channelRef.current = channel;
let lastSeen = 0;
const onActivity = () => {
- if (endingRef.current) return;
+ // Nothing counts until the limits of the signed-in accounts are known:
+ // the clock starts from what was recorded before.
+ if (endingRef.current || !limitsReadyRef.current) return;
const at = Date.now();
if (at - lastSeen < ACTIVITY_THROTTLE_MS) return;
const timer = timerRef.current;
@@ -180,11 +228,18 @@ export function SessionTimeoutGuard() {
const onVisibility = () => {
if (document.visibilityState === "visible") timerRef.current?.check();
};
+ const frameCheck = setInterval(() => {
+ if (crossOriginFrameHasFocus()) onActivity();
+ }, FRAME_FOCUS_CHECK_MS);
for (const event of ACTIVITY_EVENTS) window.addEventListener(event, onActivity, { capture: true, passive: true });
+ // The window coming back into focus, not focus moving between elements.
+ window.addEventListener("focus", onActivity);
document.addEventListener("visibilitychange", onVisibility);
return () => {
for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, onActivity, { capture: true });
+ window.removeEventListener("focus", onActivity);
document.removeEventListener("visibilitychange", onVisibility);
+ clearInterval(frameCheck);
channel.close();
channelRef.current = null;
};
diff --git a/lib/__tests__/compose-flush-registry.test.ts b/lib/__tests__/compose-flush-registry.test.ts
index 3858bb358..c87cbad79 100644
--- a/lib/__tests__/compose-flush-registry.test.ts
+++ b/lib/__tests__/compose-flush-registry.test.ts
@@ -25,7 +25,7 @@ describe('compose flush registry', () => {
const done = flushComposers(5000);
await vi.advanceTimersByTimeAsync(100);
- await expect(done).resolves.toEqual({ total: 2, settled: 2, timedOut: false });
+ await expect(done).resolves.toEqual({ total: 2, settled: 2, failed: 0, timedOut: false });
expect(order.sort()).toEqual(['a', 'b']);
});
@@ -39,20 +39,20 @@ describe('compose flush registry', () => {
await vi.advanceTimersByTimeAsync(2999);
expect(result).toBeNull();
await vi.advanceTimersByTimeAsync(1);
- expect(result).toEqual({ total: 2, settled: 1, timedOut: true });
+ expect(result).toEqual({ total: 2, settled: 1, failed: 0, timedOut: true });
expect(quick).toHaveBeenCalledTimes(1);
});
it('goes on when a flush fails', async () => {
registerComposeFlush(async () => { throw new Error('save refused'); });
- await expect(flushComposers(1000)).resolves.toEqual({ total: 1, settled: 1, timedOut: false });
+ await expect(flushComposers(1000)).resolves.toEqual({ total: 1, settled: 1, failed: 1, timedOut: false });
});
it('forgets a composer that unregistered', async () => {
const flush = vi.fn(async () => {});
const unregister = registerComposeFlush(flush);
unregister();
- await expect(flushComposers(1000)).resolves.toEqual({ total: 0, settled: 0, timedOut: false });
+ await expect(flushComposers(1000)).resolves.toEqual({ total: 0, settled: 0, failed: 0, timedOut: false });
expect(flush).not.toHaveBeenCalled();
});
diff --git a/lib/compose-flush-registry.ts b/lib/compose-flush-registry.ts
index 8af821e9e..d9ea445f8 100644
--- a/lib/compose-flush-registry.ts
+++ b/lib/compose-flush-registry.ts
@@ -50,19 +50,23 @@ export interface FlushResult {
total: number;
/** Flushes that finished (or failed) in time. */
settled: number;
+ /** Flushes that reported a save the server didn't take (threw). */
+ failed: number;
timedOut: boolean;
}
/** Run every registered flush and wait for them, but no longer than `timeoutMs`. */
export async function flushComposers(timeoutMs: number): Promise {
const pending = [...flushes.values()];
- if (pending.length === 0) return { total: 0, settled: 0, timedOut: false };
+ if (pending.length === 0) return { total: 0, settled: 0, failed: 0, timedOut: false };
let settled = 0;
+ let failed = 0;
const all = Promise.all(pending.map(async (flush) => {
try {
await flush();
} catch {
- // A failed save is the composer's to report; the sign-out goes on.
+ // The sign-out goes on; the login page says a draft wasn't saved.
+ failed += 1;
}
settled += 1;
}));
@@ -72,7 +76,7 @@ export async function flushComposers(timeoutMs: number): Promise {
new Promise((resolve) => { timer = setTimeout(() => resolve(true), timeoutMs); }),
]);
clearTimeout(timer);
- return { total: pending.length, settled, timedOut };
+ return { total: pending.length, settled, failed, timedOut };
}
/** Test helper: forget every registration. */
diff --git a/lib/session-policy/__tests__/limits.test.ts b/lib/session-policy/__tests__/limits.test.ts
index 676a30e08..341d3d31c 100644
--- a/lib/session-policy/__tests__/limits.test.ts
+++ b/lib/session-policy/__tests__/limits.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { sessionLimits } from '../limits';
+import { MAX_SKEW_MARGIN_MS, sessionLimits } from '../limits';
const HOUR = 60 * 60 * 1000;
const session = (idleMinutes: number, maxHours: number, warnSeconds = 60) => ({ idleMinutes, maxHours, warnSeconds });
@@ -19,13 +19,14 @@ describe('session limits of the signed-in accounts', () => {
expect(sessionLimits([])).toEqual({ idle: null, max: null });
});
- it('ends at the first maximum that runs out, counted from each sign-in', () => {
+ it('ends at the first maximum that runs out, counted from each sign-in, a minute early', () => {
const limits = sessionLimits([
{ session: session(0, 8), signedInAt: 10 * HOUR },
{ session: session(0, 12), signedInAt: 0 },
{ session: session(0, 4, 30), signedInAt: 9 * HOUR },
]);
- expect(limits.max).toEqual({ at: 12 * HOUR, hours: 12, warnSeconds: 60 });
+ expect(limits.max).toEqual({ at: 12 * HOUR - 60 * 1000, hours: 12, warnSeconds: 60 });
+ expect(MAX_SKEW_MARGIN_MS).toBe(60 * 1000);
});
it('needs a sign-in time to count a maximum', () => {
diff --git a/lib/session-policy/limits.ts b/lib/session-policy/limits.ts
index 6a939f507..d748d6088 100644
--- a/lib/session-policy/limits.ts
+++ b/lib/session-policy/limits.ts
@@ -18,7 +18,7 @@ export interface AccountSession {
export interface SessionLimits {
idle: { minutes: number; warnSeconds: number } | null;
- /** `at`: when the first account's maximum session length runs out. */
+ /** `at`: when the first account's maximum session length runs out, a minute early (MAX_SKEW_MARGIN_MS). */
max: { at: number; hours: number; warnSeconds: number } | null;
}
@@ -26,6 +26,13 @@ export const NO_SESSION_LIMITS: SessionLimits = { idle: null, max: null };
const HOUR_MS = 60 * 60 * 1000;
+/**
+ * The browser ends a maximum session length this much before the server
+ * would refuse it, so a clock that runs a little behind the server's still
+ * warns first.
+ */
+export const MAX_SKEW_MARGIN_MS = 60 * 1000;
+
export function sessionLimits(accounts: readonly AccountSession[]): SessionLimits {
let idle: SessionLimits['idle'] = null;
let max: SessionLimits['max'] = null;
@@ -36,7 +43,7 @@ export function sessionLimits(accounts: readonly AccountSession[]): SessionLimit
idle = { minutes: session.idleMinutes, warnSeconds };
}
if (session.maxHours > 0 && typeof account.signedInAt === 'number') {
- const at = account.signedInAt + session.maxHours * HOUR_MS;
+ const at = account.signedInAt + session.maxHours * HOUR_MS - MAX_SKEW_MARGIN_MS;
if (!max || at < max.at) max = { at, hours: session.maxHours, warnSeconds };
}
}
diff --git a/lib/session-policy/sign-out-reason.ts b/lib/session-policy/sign-out-reason.ts
index b52166520..370b3ebb1 100644
--- a/lib/session-policy/sign-out-reason.ts
+++ b/lib/session-policy/sign-out-reason.ts
@@ -4,8 +4,8 @@
*/
export type SessionEndReason =
- | { kind: 'idle'; minutes: number }
- | { kind: 'max'; hours: number };
+ | { kind: 'idle'; minutes: number; draftsUnsaved?: boolean }
+ | { kind: 'max'; hours: number; draftsUnsaved?: boolean };
const KEY = 'session_end_reason';
@@ -17,6 +17,15 @@ export function rememberSessionEnd(reason: SessionEndReason): void {
}
}
+/** Takes a reason back: an account is signed in after all. */
+export function clearSessionEnd(): void {
+ try {
+ window.sessionStorage.removeItem(KEY);
+ } catch {
+ /* storage unavailable */
+ }
+}
+
/** The reason, once; null when the user wasn't signed out by a session limit. */
export function consumeSessionEnd(): SessionEndReason | null {
try {
@@ -24,8 +33,9 @@ export function consumeSessionEnd(): SessionEndReason | null {
window.sessionStorage.removeItem(KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Record;
- if (parsed.kind === 'idle' && typeof parsed.minutes === 'number') return { kind: 'idle', minutes: parsed.minutes };
- if (parsed.kind === 'max' && typeof parsed.hours === 'number') return { kind: 'max', hours: parsed.hours };
+ const drafts = parsed.draftsUnsaved === true ? { draftsUnsaved: true } : {};
+ if (parsed.kind === 'idle' && typeof parsed.minutes === 'number') return { kind: 'idle', minutes: parsed.minutes, ...drafts };
+ if (parsed.kind === 'max' && typeof parsed.hours === 'number') return { kind: 'max', hours: parsed.hours, ...drafts };
return null;
} catch {
return null;
diff --git a/locales/de/common.json b/locales/de/common.json
index 5d888afd7..6d6b42629 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -86,7 +86,9 @@
"dismiss": "OK",
"signing_out": "Entwürfe werden gespeichert und Sie werden abgemeldet …",
"reason_idle": "Sie wurden nach {minutes, plural, one {# Minute} other {# Minuten}} ohne Aktivität abgemeldet.",
- "reason_max": "Sie wurden abgemeldet, weil Ihre Sitzung die Höchstdauer von {hours, plural, one {# Stunde} other {# Stunden}} erreicht hat."
+ "reason_max": "Sie wurden abgemeldet, weil Ihre Sitzung die Höchstdauer von {hours, plural, one {# Stunde} other {# Stunden}} erreicht hat.",
+ "drafts_not_saved": "Mindestens eine geöffnete Nachricht konnte nicht als Entwurf gespeichert werden.",
+ "seconds_left": "Noch {seconds, plural, one {# Sekunde} other {# Sekunden}}"
},
"sidebar": {
"close": "Schließen",
diff --git a/locales/en/common.json b/locales/en/common.json
index f9ca53762..81fc096fe 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -86,7 +86,9 @@
"dismiss": "OK",
"signing_out": "Saving drafts and signing out…",
"reason_idle": "You were signed out after {minutes, plural, one {# minute} other {# minutes}} without activity.",
- "reason_max": "You were signed out because your session reached its maximum length of {hours, plural, one {# hour} other {# hours}}."
+ "reason_max": "You were signed out because your session reached its maximum length of {hours, plural, one {# hour} other {# hours}}.",
+ "drafts_not_saved": "One or more open messages could not be saved as drafts.",
+ "seconds_left": "{seconds, plural, one {# second} other {# seconds}} left"
},
"sidebar": {
"close": "Close",
diff --git a/stores/__tests__/auth-store-restore-session-too-old.test.ts b/stores/__tests__/auth-store-restore-session-too-old.test.ts
index 333775cf5..b2ff21a38 100644
--- a/stores/__tests__/auth-store-restore-session-too-old.test.ts
+++ b/stores/__tests__/auth-store-restore-session-too-old.test.ts
@@ -14,6 +14,15 @@ vi.mock('@/lib/jmap/client', () => {
onRateLimit() {}
getRateLimitRemainingMs() { return 0; }
disconnect() {}
+ getUsername() { return this.username; }
+ async getIdentities() { return []; }
+ supportsContacts() { return false; }
+ supportsPrincipals() { return false; }
+ supportsVacationResponse() { return false; }
+ supportsCalendars() { return false; }
+ supportsSieve() { return false; }
+ getSharedAccounts() { return []; }
+ getAccountInfo() { return undefined; }
}
class RateLimitError extends Error {}
class RequestTimeoutError extends Error {}
@@ -62,3 +71,25 @@ it('says so for a provider sign-in whose renewal the server refused', async () =
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 8 });
});
+
+it('says nothing when another account comes back, and takes the server sign-in time', async () => {
+ const bob = {
+ ...account('basic'), id: 'bob@example.org@mail.example.org', username: 'bob@example.org', email: 'bob@example.org', cookieSlot: 1, isDefault: false,
+ };
+ vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
+ const url = String(input);
+ if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
+ if (url.includes('/api/auth/session?slot=1') && init?.method === 'PUT') {
+ return new Response(JSON.stringify({ serverUrl: 'https://mail.example.org', username: 'bob@example.org', password: 'pw', signedInAt: 1234 }), { status: 200 });
+ }
+ if (url.includes('/api/auth/session') && init?.method === 'PUT') return tooOld();
+ return new Response('{}', { status: 200 });
+ }));
+ useAccountStore.setState({ accounts: [account('basic'), bob] as never, activeAccountId: account('basic').id, defaultAccountId: account('basic').id });
+
+ await useAuthStore.getState().checkAuth();
+ await vi.waitFor(() => expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([bob.id]));
+ await vi.waitFor(() => expect(useAccountStore.getState().accounts[0].signedInAt).toBe(1234));
+
+ expect(consumeSessionEnd()).toBeNull();
+});
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index b8b084d5d..03e20a93c 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -24,7 +24,8 @@ import { useSearchHistoryStore } from './search-history-store';
import { useCalendarNotificationStore } from './calendar-notification-store';
import { usePolicyStore } from './policy-store';
import { policySubjectForClient } from '@/lib/admin/policy-subject';
-import { rememberSessionEnd } from '@/lib/session-policy/sign-out-reason';
+import { clearSessionEnd, rememberSessionEnd } from '@/lib/session-policy/sign-out-reason';
+import { SESSION_TOO_OLD } from '@/lib/session-policy/constants';
import type { Identity } from '@/lib/jmap/types';
import { authHooks } from '@/lib/plugin-hooks';
import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
@@ -420,7 +421,7 @@ async function exchangePasswordForTokens(params: {
// server_id isn't passed - the route looks up the server entry by
// serverUrl, so per-server OAuth still applies for password+TOTP.
body: JSON.stringify({ serverUrl, username, password, totp, slot, redirectUri, rememberMe }),
- });
+ }).then((res) => noteSessionAnswer(res, slot));
}
try {
const tokens = await liteTokenLogin({ serverUrl, username, password, totp, redirectUri });
@@ -460,7 +461,7 @@ async function exchangeOAuthCode(params: {
slot,
...(serverId ? { server_id: serverId } : {}),
}),
- });
+ }).then((res) => noteSessionAnswer(res, slot));
}
// The login page parked where and as whom to redeem the code
// (lib/auth/lite-oauth.ts); without it this is not a flow we started. A
@@ -530,7 +531,7 @@ async function persistSession(slot: number, session: RememberedSession): Promise
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...session, slot }),
signal,
- }));
+ })).then((answer) => noteSessionAnswer(answer, slot));
if (!res.ok) debug.error('Failed to store session: server returned', res.status);
} catch (err) {
debug.error('Failed to store session:', err);
@@ -538,16 +539,45 @@ async function persistSession(slot: number, session: RememberedSession): Promise
}
/**
- * A restore or renewal the server refused because the session reached the
- * organisation's maximum length (lib/auth/session-age.ts): the login page
- * says so.
+ * When the server says each cookie slot signed in, from the sign-in and
+ * restore answers. The organisation's maximum session length counts from
+ * the server's time in the browser too, so the server never refuses a
+ * session before the browser warned (lib/auth/session-age.ts).
*/
-async function noteSessionTooOld(res: Response): Promise {
- if (res.status !== 401 || typeof res.clone !== 'function') return res;
+const serverSignedInAt = new Map();
+
+/**
+ * The server's sign-in time for a sign-in to `slot` that just happened, or
+ * the browser's clock. An older answer belongs to an earlier sign-in.
+ */
+export function signedInAtForSlot(slot: number | undefined): number {
+ const now = Date.now();
+ const server = slot !== undefined ? serverSignedInAt.get(slot) : undefined;
+ return server !== undefined && Math.abs(now - server) < 10 * 60 * 1000 ? server : now;
+}
+
+/**
+ * Reads what a sign-in, restore or renewal answer says about the session:
+ * the server's sign-in time, or that it refused the session because it
+ * reached the organisation's maximum length. The login page says so only
+ * when no other account stays signed in here; a later successful restore
+ * takes the reason back.
+ */
+async function noteSessionAnswer(res: Response, slot: number | undefined): Promise {
+ if (typeof res.clone !== 'function' || (!res.ok && res.status !== 401)) return res;
try {
- const data = await res.clone().json() as { error?: unknown; maxHours?: unknown };
- if (data?.error === 'session_expired' && typeof data.maxHours === 'number') {
- rememberSessionEnd({ kind: 'max', hours: data.maxHours });
+ const data = await res.clone().json() as { error?: unknown; maxHours?: unknown; signedInAt?: unknown; signed_in_at?: unknown };
+ if (res.ok) {
+ clearSessionEnd();
+ const at = typeof data?.signedInAt === 'number' ? data.signedInAt : data?.signed_in_at;
+ if (typeof at === 'number' && Number.isFinite(at) && slot !== undefined) {
+ serverSignedInAt.set(slot, at);
+ const account = useAccountStore.getState().accounts.find((a) => a.cookieSlot === slot);
+ if (account && account.signedInAt !== at) useAccountStore.getState().updateAccount(account.id, { signedInAt: at });
+ }
+ } else if (data?.error === SESSION_TOO_OLD && typeof data.maxHours === 'number') {
+ const othersSignedIn = useAccountStore.getState().accounts.some((a) => a.cookieSlot !== slot && a.isConnected);
+ if (!othersSignedIn) rememberSessionEnd({ kind: 'max', hours: data.maxHours });
}
} catch {
/* not JSON */
@@ -561,7 +591,7 @@ async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}
if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401);
if (!IS_LITE) {
return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { method: 'PUT', signal }))
- .then(noteSessionTooOld);
+ .then((res) => noteSessionAnswer(res, slot));
}
// Like the route's cookie cache: a restore resumes with the token it had,
// since a renewal before the refresh token's `nbf` is refused (#552).
@@ -584,7 +614,7 @@ async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}
async function fetchSlotSession(slot?: number): Promise {
if (!IS_LITE) {
return apiFetch(slot === undefined ? '/api/auth/session' : `/api/auth/session?slot=${slot}`, { method: 'PUT' })
- .then(noteSessionTooOld);
+ .then((res) => noteSessionAnswer(res, slot ?? 0));
}
const stored = readLiteBasicSession(slot ?? 0);
return stored ? jsonResponse(stored) : jsonResponse({ error: 'no_session' }, 401);
@@ -1289,8 +1319,10 @@ export const LOGOUT_HOOK_BUDGET_MS = 2000;
function notifySignedIn(state: { activeAccountId: string | null; username: string | null }): void {
if (!state.activeAccountId) return;
- // The organisation's maximum session length counts from a sign-in, never from a restore.
- useAccountStore.getState().updateAccount(state.activeAccountId, { signedInAt: Date.now() });
+ // The organisation's maximum session length counts from a sign-in, never
+ // from a restore, and from the server's time when it said.
+ const slot = useAccountStore.getState().getAccountById(state.activeAccountId)?.cookieSlot;
+ useAccountStore.getState().updateAccount(state.activeAccountId, { signedInAt: signedInAtForSlot(slot) });
void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' });
}
@@ -1939,7 +1971,7 @@ export const useAuthStore = create()(
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, state, slot }),
- }),
+ }).then((res) => noteSessionAnswer(res, slot)),
fetchConfig(),
]);
@@ -3118,7 +3150,7 @@ export const useAuthStore = create()(
bindClientStatusHandlers(client, set, get, accountId);
accountStore.updateAccount(accountId, {
awaitingSignIn: false, isConnected: true, hasError: false, errorMessage: undefined,
- rememberMe, lastLoginAt: Date.now(), signedInAt: Date.now(),
+ rememberMe, lastLoginAt: Date.now(), signedInAt: signedInAtForSlot(cookieSlot),
});
void syncAccountDisplayName(accountId, client);
set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 }));
From ecbbe137c4f802234ceeb1523d0fa33d2cd32e17 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:06:05 +0100
Subject: [PATCH 09/11] fix: wait for sends and keep quick replies as drafts
before an automatic sign-out
---
.../composer-sign-out-flush.test.tsx | 47 +++++++++++-
.../__tests__/viewer-send-failures.test.tsx | 16 ++++
components/email/email-composer.tsx | 15 +++-
components/email/email-viewer.tsx | 17 +++++
components/mail/mail-app.tsx | 67 ++++-------------
components/pro/pro-email-tab-body.tsx | 8 ++
.../use-minimised-composer-flush.test.tsx | 53 +++++++++++++
hooks/use-minimised-composer-flush.ts | 26 +++++++
.../__tests__/quick-reply-envelope.test.ts | 46 ++++++++++++
lib/outgoing/quick-reply-envelope.ts | 74 +++++++++++++++++++
10 files changed, 312 insertions(+), 57 deletions(-)
create mode 100644 hooks/__tests__/use-minimised-composer-flush.test.tsx
create mode 100644 hooks/use-minimised-composer-flush.ts
create mode 100644 lib/outgoing/__tests__/quick-reply-envelope.test.ts
create mode 100644 lib/outgoing/quick-reply-envelope.ts
diff --git a/components/email/__tests__/composer-sign-out-flush.test.tsx b/components/email/__tests__/composer-sign-out-flush.test.tsx
index 2f55cdf10..be454dee1 100644
--- a/components/email/__tests__/composer-sign-out-flush.test.tsx
+++ b/components/email/__tests__/composer-sign-out-flush.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, fireEvent, act } from '@testing-library/react';
+import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import React from 'react';
import { EmailComposer, type ComposerDraftData } from '../email-composer';
@@ -70,7 +70,6 @@ vi.mock('@/stores/account-store', () => {
vi.mock('@/stores/email-store', () => {
const state = {
- draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
@@ -247,6 +246,50 @@ describe('composer flush before an automatic sign-out', () => {
expect(createDraft.mock.calls[0][1]).toBe('Half-written');
});
+ it('waits for a send in flight and saves no draft of the sent message', async () => {
+ let finishSend!: () => void;
+ const onSend = vi.fn(() => new Promise((resolve) => { finishSend = resolve; }));
+ render();
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+ fireEvent.click(screen.getAllByTestId('composer-send')[0]);
+ await waitFor(() => expect(onSend).toHaveBeenCalled());
+ const savesBeforeSignOut = createDraft.mock.calls.length;
+
+ let done = false;
+ const flushed = flushComposers(5000).then((r) => { done = true; return r; });
+ await new Promise((r) => setTimeout(r, 300));
+ expect(done).toBe(false);
+
+ await act(async () => { finishSend(); });
+ await expect(flushed).resolves.toMatchObject({ total: 1, failed: 0, timedOut: false });
+ expect(createDraft).toHaveBeenCalledTimes(savesBeforeSignOut);
+ });
+
+ it('reports a save the server refused, so the login page can say so', async () => {
+ createDraft.mockRejectedValue(new Error('refused'));
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render();
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+
+ let result: Awaited> | undefined;
+ await act(async () => { result = await flushComposers(5000); });
+ expect(result).toMatchObject({ total: 1, failed: 1 });
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it('hands an already-saved state back as saved', async () => {
+ const onSaveState = vi.fn();
+ const view = render();
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+ await act(async () => { await flushComposers(5000); });
+ view.unmount();
+
+ expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Half-written more', unsaved: false }));
+ });
+
it('marks the state it hands to the host on unmount as unsaved', () => {
const onSaveState = vi.fn();
const view = render();
diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx
index 27250067a..fb791f74a 100644
--- a/components/email/__tests__/viewer-send-failures.test.tsx
+++ b/components/email/__tests__/viewer-send-failures.test.tsx
@@ -6,6 +6,7 @@ import { toast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
+import { flushComposers, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
// Sending from the viewer - quick reply, "Send now" / "Reschedule" of a
// scheduled message, and read receipts - used to fail silently or as an
@@ -110,6 +111,21 @@ describe('viewer send failures', () => {
expect((box as HTMLTextAreaElement).value).toBe('Thanks!');
});
+ it('keeps typed quick-reply text as a draft before an automatic sign-out', async () => {
+ resetComposeFlushRegistry();
+ const onSaveQuickReplyDraft = vi.fn().mockResolvedValue(undefined);
+ render(
+ ,
+ );
+ await act(async () => { await flushComposers(1000); });
+ expect(onSaveQuickReplyDraft).not.toHaveBeenCalled();
+
+ fireEvent.change(screen.getByPlaceholderText('quick_reply_placeholder'), { target: { value: 'On it.' } });
+ await act(async () => { await flushComposers(1000); });
+ expect(onSaveQuickReplyDraft).toHaveBeenCalledWith('On it.');
+ });
+
const scheduled = () => makeEmail({
isScheduled: true,
scheduledUndoStatus: 'pending',
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 11f303fd3..6da50a5b9 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -142,6 +142,9 @@ import { toUnicodeEmail } from "@/lib/idn";
*
blocks are separated by a blank line, matching their visual rendering (#421).
* Quotes get "> " and list items their markers, like any plain-text mail.
*/
+/** How often an automatic sign-out looks whether a send in flight has finished. */
+const SEND_WAIT_POLL_MS = 100;
+
function htmlToPlainText(html: string): string {
return htmlToPlainTextShared(html, { paragraphSpacing: true, mailMarkup: true });
}
@@ -1656,7 +1659,9 @@ export function EmailComposer({
mode,
replyTo,
priority: priorityRef.current,
- unsaved: true,
+ // Edits the last save didn't cover; a minimised composer with
+ // none needs no save before an automatic sign-out.
+ unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current,
});
}
};
@@ -2709,8 +2714,13 @@ export function EmailComposer({
saveDraftRef.current = saveDraft;
// An automatic sign-out saves what the server doesn't have yet, with the
- // same save the close and send paths use.
+ // same save the close and send paths use. A send in flight goes first:
+ // the sign-out waits for it (within its own time limit), and a message
+ // that went out needs no draft. A save the server refused throws, so the
+ // login page can say a draft wasn't saved.
useEffect(() => registerComposeFlush(async () => {
+ while (isSendingRef.current) await new Promise((resolve) => setTimeout(resolve, SEND_WAIT_POLL_MS));
+ if (sentRef.current) return;
const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current;
if (!unsaved) return;
if (saveTimeoutRef.current) {
@@ -2718,6 +2728,7 @@ export function EmailComposer({
saveTimeoutRef.current = null;
}
await saveDraftRef.current();
+ if (saveFailedRef.current) throw new Error('The draft was not saved');
}), []);
// The draft as plugins see it: onDraftChange, the frames above and below
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 54a8409d6..7ab8f5dac 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -102,6 +102,7 @@ import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
+import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
@@ -175,6 +176,8 @@ interface EmailViewerProps {
onSetTag?: (emailId: string, tagId: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean, source?: EmailBlobContext) => void;
onQuickReply?: (body: string) => Promise;
+ /** Keeps the typed quick reply as a draft before an automatic sign-out (lib/compose-flush-registry.ts). */
+ onSaveQuickReplyDraft?: (body: string) => Promise;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onMoveToMailbox?: (mailboxId: string) => void;
@@ -1044,6 +1047,7 @@ export function EmailViewer({
onSetTag,
onDownloadAttachment,
onQuickReply,
+ onSaveQuickReplyDraft,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
@@ -1369,6 +1373,19 @@ export function EmailViewer({
const [quickReplyText, setQuickReplyText] = useState("");
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
+ // An automatic sign-out keeps typed quick-reply text as a draft; text on
+ // its way out as a reply needs none.
+ const quickReplyDraftRef = useRef({ text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft });
+ quickReplyDraftRef.current = { text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft };
+ const hasQuickReplyDraft = quickReplyText.trim().length > 0 && !!onSaveQuickReplyDraft;
+ useEffect(() => {
+ if (!hasQuickReplyDraft) return;
+ return registerComposeFlush(async () => {
+ const { text, sending, save } = quickReplyDraftRef.current;
+ if (sending || !text.trim() || !save) return;
+ await save(text);
+ });
+ }, [hasQuickReplyDraft]);
const handleSendQuickReply = async () => {
if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return;
setIsSendingQuickReply(true);
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 49581ae72..30bdb9dd7 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -115,8 +115,7 @@ import { isFilePreviewable } from "@/lib/file-preview";
import { prepareQuickReply } from "@/lib/outgoing/quick-reply";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
-import { findDraftIdentityId, findReplyIdentityId, resolveComposeAccountEmail } from "@/lib/reply-identity";
-import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
+import { resolveComposeAccountEmail } from "@/lib/reply-identity";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft } from "@/components/icons";
import { ResizeHandle } from "@/components/layout/resize-handle";
@@ -159,7 +158,8 @@ import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-lin
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import type { QuoteHeader } from "@/lib/plugin-types";
import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
-import { registerComposeFlush, waitForComposeFlush } from "@/lib/compose-flush-registry";
+import { useMinimisedComposerFlush } from "@/hooks/use-minimised-composer-flush";
+import { quickReplyEnvelope, saveQuickReplyDraft } from "@/lib/outgoing/quick-reply-envelope";
export interface MailAppProps {
/**
@@ -199,9 +199,6 @@ function freshMailboxes(previous: Mailbox[], timeoutMs = 10_000): Promise = {};
@@ -2272,16 +2269,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A minimised composer's edits live only in pendingDraft. Before an
// automatic sign-out it is opened again and saves them itself.
- const minimisedUnsaved = !showComposer && pendingDraft?.unsaved === true;
- useEffect(() => {
- if (!minimisedUnsaved) return;
- return registerComposeFlush(async () => {
- const reopened = waitForComposeFlush(MINIMISED_COMPOSER_REOPEN_MS);
- setShowComposer(true);
- const flush = await reopened;
- await flush?.();
- });
- }, [minimisedUnsaved]);
+ useMinimisedComposerFlush(!showComposer && pendingDraft?.unsaved === true, () => setShowComposer(true));
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
@@ -3864,49 +3852,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
}, [previewAttachment]);
+ // Before an automatic sign-out: the typed quick reply is kept as a draft.
+ const handleSaveQuickReplyDraft = async (body: string) => {
+ if (!client || !selectedEmail) return;
+ await saveQuickReplyDraft({ client, email: selectedEmail, identities, body, replyPrefix: t('email_composer.prefix.reply') });
+ };
+
const handleQuickReply = async (body: string) => {
if (!client || !selectedEmail) return;
- // Quick reply follows the same addressing rules as the composer: Reply-To
- // over From, and for our own messages in a thread the original recipients
- // instead of ourselves (#703).
- const ownIdentityEmails = identities.map(i => i.email).filter(Boolean);
- const replySource = {
- from: selectedEmail.from,
- replyToAddresses: selectedEmail.replyTo,
- to: selectedEmail.to,
- cc: selectedEmail.cc,
- };
- const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
- .map(r => r.email)
- .filter((email): email is string => Boolean(email));
+ // Quick reply follows the composer's addressing rules and sends from the
+ // address the message was delivered to (lib/outgoing/quick-reply-envelope.ts).
+ const { recipients, sendingIdentity, primaryIdentity } = quickReplyEnvelope(selectedEmail, identities);
if (recipients.length === 0) {
throw new Error("No sender email found");
}
-
- const primaryIdentity = identities[0];
-
- // Send from the address the message was delivered to, so a reply out of a
- // shared or aliased mailbox does not go out as the account owner. Our own
- // message keeps the identity it was sent from - the recipients are the
- // other party, so resolving from them would send as their address.
- //
- // Quick reply resolves the user's OWN identities only. It deliberately does
- // NOT take the domain catch-all `From:` rewrite that the full composer
- // offers: this surface is a bare text box with no From row, so a rewritten
- // From would be applied with nothing on screen to show it - or to correct
- // it. A catch-all delivery quick-replies as the matching own identity, and
- // the user can open the full composer when they need the rewrite.
- const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails)
- ? findDraftIdentityId(identities, selectedEmail.from?.[0])
- : null;
- const sendingIdentityId = selfSentIdentityId ?? findReplyIdentityId(identities, {
- to: selectedEmail.to,
- cc: selectedEmail.cc,
- bcc: selectedEmail.bcc,
- });
- const sendingIdentity =
- (sendingIdentityId ? identities.find((i) => i.id === sendingIdentityId) : undefined) || primaryIdentity;
const headerFromEmail = sendingIdentity?.email;
const headerFromName = sendingIdentity?.name || undefined;
@@ -5465,6 +5425,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
+ onSaveQuickReplyDraft={handleSaveQuickReplyDraft}
onBack={handleMobileBack}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index 255f9fa39..8c8288b87 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -24,6 +24,7 @@ import { cn } from "@/lib/utils";
import { getDraftTextBody } from '@/lib/email-body-selection';
import { messagePriorityFromHeaders } from '@/lib/message-priority';
import { useArchiveActionEnabled } from '@/lib/archive-gate';
+import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
interface ProEmailTabBodyProps {
tabId: string;
@@ -399,6 +400,12 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
handleReply(body);
}, [handleReply]);
+ // Before an automatic sign-out: the typed quick reply is kept as a draft.
+ const handleSaveQuickReplyDraft = useCallback(async (body: string) => {
+ if (!client || !email) return;
+ await saveQuickReplyDraft({ client, email, identities, body, replyPrefix: t('email_composer.prefix.reply') });
+ }, [client, email, identities, t]);
+
const handleEditDraft = useCallback(() => {
if (!email) return;
@@ -473,6 +480,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
onSetTag={handleSetTag}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
+ onSaveQuickReplyDraft={handleSaveQuickReplyDraft}
onEditDraft={handleEditDraft}
onMoveToMailbox={handleMoveToMailbox}
currentUserEmail={client?.getUsername()}
diff --git a/hooks/__tests__/use-minimised-composer-flush.test.tsx b/hooks/__tests__/use-minimised-composer-flush.test.tsx
new file mode 100644
index 000000000..61031d1ae
--- /dev/null
+++ b/hooks/__tests__/use-minimised-composer-flush.test.tsx
@@ -0,0 +1,53 @@
+import { renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { MINIMISED_COMPOSER_REOPEN_MS, useMinimisedComposerFlush } from '../use-minimised-composer-flush';
+import { flushComposers, registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
+
+/**
+ * mail-app keeps a minimised composer's edits in memory only. Before an
+ * automatic sign-out the hook opens the composer again and lets it save.
+ */
+describe('minimised composer flush', () => {
+ beforeEach(() => {
+ resetComposeFlushRegistry();
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('opens the minimised composer again and runs its own save', async () => {
+ const save = vi.fn(async () => {});
+ // Opening the composer mounts it, and it registers its flush.
+ const reopen = vi.fn(() => { registerComposeFlush(save); });
+ renderHook(() => useMinimisedComposerFlush(true, reopen));
+
+ const result = await flushComposers(8000);
+
+ expect(reopen).toHaveBeenCalledTimes(1);
+ expect(save).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({ total: 1, settled: 1, failed: 0, timedOut: false });
+ });
+
+ it('registers nothing without unsaved minimised edits', async () => {
+ const reopen = vi.fn();
+ renderHook(() => useMinimisedComposerFlush(false, reopen));
+
+ await expect(flushComposers(8000)).resolves.toMatchObject({ total: 0 });
+ expect(reopen).not.toHaveBeenCalled();
+ });
+
+ it('counts a composer that does not come back as a draft not saved', async () => {
+ renderHook(() => useMinimisedComposerFlush(true, () => {}));
+
+ const done = flushComposers(8000);
+ await vi.advanceTimersByTimeAsync(MINIMISED_COMPOSER_REOPEN_MS);
+ await expect(done).resolves.toEqual({ total: 1, settled: 1, failed: 1, timedOut: false });
+ });
+
+ it('stops registering once the composer is open again', async () => {
+ const { rerender } = renderHook(({ minimised }) => useMinimisedComposerFlush(minimised, () => {}), { initialProps: { minimised: true } });
+ rerender({ minimised: false });
+ await expect(flushComposers(8000)).resolves.toMatchObject({ total: 0 });
+ });
+});
diff --git a/hooks/use-minimised-composer-flush.ts b/hooks/use-minimised-composer-flush.ts
new file mode 100644
index 000000000..28b1ccccd
--- /dev/null
+++ b/hooks/use-minimised-composer-flush.ts
@@ -0,0 +1,26 @@
+import { useEffect, useRef } from 'react';
+import { registerComposeFlush, waitForComposeFlush } from '@/lib/compose-flush-registry';
+
+/** How long an automatic sign-out waits for a minimised composer to open again and register. */
+export const MINIMISED_COMPOSER_REOPEN_MS = 3000;
+
+/**
+ * A minimised composer's edits live only in its host's memory. While there
+ * are unsaved ones, an automatic sign-out opens the composer again (`reopen`)
+ * and lets it save itself (lib/compose-flush-registry.ts). A composer that
+ * doesn't come back in time counts as a draft that wasn't saved.
+ */
+export function useMinimisedComposerFlush(minimisedUnsaved: boolean, reopen: () => void): void {
+ const reopenRef = useRef(reopen);
+ reopenRef.current = reopen;
+ useEffect(() => {
+ if (!minimisedUnsaved) return;
+ return registerComposeFlush(async () => {
+ const reopened = waitForComposeFlush(MINIMISED_COMPOSER_REOPEN_MS);
+ reopenRef.current();
+ const flush = await reopened;
+ if (!flush) throw new Error('The minimised message did not open again');
+ await flush();
+ });
+ }, [minimisedUnsaved]);
+}
diff --git a/lib/outgoing/__tests__/quick-reply-envelope.test.ts b/lib/outgoing/__tests__/quick-reply-envelope.test.ts
new file mode 100644
index 000000000..cf6d32a44
--- /dev/null
+++ b/lib/outgoing/__tests__/quick-reply-envelope.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from 'vitest';
+import { quickReplyEnvelope, saveQuickReplyDraft } from '../quick-reply-envelope';
+import type { Email, Identity } from '@/lib/jmap/types';
+
+const me: Identity = { id: 'id-me', name: 'Org Alice', email: 'org-alice@example.org', mayDelete: false };
+const team: Identity = { id: 'id-team', name: 'Org Team', email: 'org-team@example.org', mayDelete: false };
+
+const message = {
+ id: 'm1',
+ subject: 'Quarterly numbers',
+ from: [{ name: 'Bob', email: 'org-bob@example.org' }],
+ replyTo: [{ name: 'Bob (desk)', email: 'desk@example.org' }],
+ to: [{ name: 'Team', email: 'org-team@example.org' }],
+ cc: [],
+ messageId: ['abc@example.org'],
+ references: ['root@example.org'],
+} as unknown as Email;
+
+describe('quick reply addressing', () => {
+ it('answers Reply-To from the identity the message was delivered to', () => {
+ const envelope = quickReplyEnvelope(message, [me, team]);
+ expect(envelope.recipients).toEqual(['desk@example.org']);
+ expect(envelope.sendingIdentity?.id).toBe('id-team');
+ expect(envelope.primaryIdentity?.id).toBe('id-me');
+ });
+});
+
+describe('saving a quick reply as a draft before an automatic sign-out', () => {
+ it('saves the typed text as a threaded reply draft', async () => {
+ const createDraft = vi.fn().mockResolvedValue('d1');
+ await saveQuickReplyDraft({ client: { createDraft } as never, email: message, identities: [me, team], body: 'On it.', replyPrefix: 'Re:' });
+
+ expect(createDraft).toHaveBeenCalledTimes(1);
+ const [to, subject, body, , , identityId, fromEmail, draftId, , fromName, htmlBody, inReplyTo, references] = createDraft.mock.calls[0];
+ expect(to).toEqual(['desk@example.org']);
+ expect(subject).toBe('Re: Quarterly numbers');
+ expect(body).toBe('On it.');
+ expect(identityId).toBe('id-team');
+ expect(fromEmail).toBe('org-team@example.org');
+ expect(fromName).toBe('Org Team');
+ expect(draftId).toBeUndefined();
+ expect(htmlBody).toBeUndefined();
+ expect(inReplyTo).toEqual(['abc@example.org']);
+ expect(references).toEqual(['root@example.org', 'abc@example.org']);
+ });
+});
diff --git a/lib/outgoing/quick-reply-envelope.ts b/lib/outgoing/quick-reply-envelope.ts
new file mode 100644
index 000000000..9b863865c
--- /dev/null
+++ b/lib/outgoing/quick-reply-envelope.ts
@@ -0,0 +1,74 @@
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Email, Identity } from '@/lib/jmap/types';
+import { computeReplyThreadingHeaders } from '@/lib/email-threading';
+import { findDraftIdentityId, findReplyIdentityId } from '@/lib/reply-identity';
+import { buildReplyRecipients, isSelfSent } from '@/lib/reply-recipients';
+import { buildReplySubject } from '@/lib/subject-prefix';
+
+type ReplySource = Pick;
+
+export interface QuickReplyEnvelope {
+ recipients: string[];
+ /** The identity the reply goes out as, the primary one when none matches. */
+ sendingIdentity: Identity | undefined;
+ primaryIdentity: Identity | undefined;
+}
+
+/**
+ * Who a quick reply goes to and from whom: the composer's addressing rules
+ * (Reply-To over From; for our own messages in a thread, the original
+ * recipients instead of ourselves, #703), from the address the message was
+ * delivered to, or for our own message the identity it was sent from.
+ *
+ * Quick reply resolves the user's OWN identities only. It deliberately does
+ * NOT take the domain catch-all `From:` rewrite that the full composer
+ * offers: this surface is a bare text box with no From row, so a rewritten
+ * From would be applied with nothing on screen to show it - or to correct
+ * it.
+ */
+export function quickReplyEnvelope(email: ReplySource, identities: Identity[]): QuickReplyEnvelope {
+ const ownIdentityEmails = identities.map((i) => i.email).filter(Boolean);
+ const replySource = { from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc };
+ const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
+ .map((r) => r.email)
+ .filter((address): address is string => Boolean(address));
+ const primaryIdentity = identities[0];
+ const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails)
+ ? findDraftIdentityId(identities, email.from?.[0])
+ : null;
+ const sendingIdentityId = selfSentIdentityId ?? findReplyIdentityId(identities, { to: email.to, cc: email.cc, bcc: email.bcc });
+ const sendingIdentity = (sendingIdentityId ? identities.find((i) => i.id === sendingIdentityId) : undefined) || primaryIdentity;
+ return { recipients, sendingIdentity, primaryIdentity };
+}
+
+/**
+ * Saves the text typed into a quick reply as a draft reply, before an
+ * automatic sign-out (lib/compose-flush-registry.ts). Plain text, threaded
+ * like the reply would be; the signature is added when it is sent.
+ */
+export async function saveQuickReplyDraft(input: {
+ client: IJMAPClient;
+ email: Email;
+ identities: Identity[];
+ body: string;
+ replyPrefix: string;
+}): Promise {
+ const { client, email, identities, body, replyPrefix } = input;
+ const { recipients, sendingIdentity } = quickReplyEnvelope(email, identities);
+ const threading = computeReplyThreadingHeaders({ messageId: email.messageId, references: email.references });
+ await client.createDraft(
+ recipients,
+ buildReplySubject(email.subject || '', replyPrefix),
+ body,
+ undefined,
+ undefined,
+ sendingIdentity?.id,
+ sendingIdentity?.email,
+ undefined,
+ undefined,
+ sendingIdentity?.name || undefined,
+ undefined,
+ threading?.inReplyTo,
+ threading?.references,
+ );
+}
From 629e96b6bdcae032c9710bf6864d81819cc7dc31 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:18:24 +0100
Subject: [PATCH 10/11] fix: stop counting focus in the office editor or a
plugin frame as activity
---
.../__tests__/session-timeout-guard.test.tsx | 34 ++++++++++---------
components/session/session-timeout-guard.tsx | 27 +++------------
2 files changed, 22 insertions(+), 39 deletions(-)
diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx
index 6016df443..90b040cd3 100644
--- a/components/session/__tests__/session-timeout-guard.test.tsx
+++ b/components/session/__tests__/session-timeout-guard.test.tsx
@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS, FRAME_FOCUS_CHECK_MS } from '../session-timeout-guard';
+import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS } from '../session-timeout-guard';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { usePolicyStore } from '@/stores/policy-store';
@@ -271,28 +271,30 @@ describe('session timeout guard', () => {
expect(screen.getByText('idle_title')).toBeTruthy();
});
- it('counts the focus on a cross-origin frame as activity, but not on a same-origin one', async () => {
+ it('does not count a focused cross-origin frame as activity, and warns above its overlay', async () => {
const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true);
+ // The office editor: a cross-origin frame in a z-[100000] overlay.
+ const overlay = document.createElement('div');
+ overlay.className = 'fixed inset-0 z-[100000]';
+ const editor = document.createElement('iframe');
+ Object.defineProperty(editor, 'contentDocument', { get: () => null });
+ overlay.appendChild(editor);
+ document.body.appendChild(overlay);
try {
await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
- const editor = document.createElement('iframe');
- Object.defineProperty(editor, 'contentDocument', { get: () => null });
- document.body.appendChild(editor);
editor.focus();
expect(document.activeElement).toBe(editor);
- await advance(45 * SECOND);
- expect(screen.queryByText('idle_title')).toBeNull();
-
- // A message body (same origin) clicked into and left doesn't keep the session.
- const body = document.createElement('iframe');
- document.body.appendChild(body);
- body.focus();
- await advance(30 * SECOND + FRAME_FOCUS_CHECK_MS);
- expect(screen.getByText('idle_title')).toBeTruthy();
- editor.remove();
- body.remove();
+ // Left focused, the editor keeps nothing alive: warned at 30 s, signed out at 60 s.
+ await advance(30 * SECOND);
+ const warning = screen.getByTestId('session-timeout-dialog');
+ expect(Number(/z-\[(\d+)\]/.exec(warning.className)?.[1])).toBeGreaterThan(100000);
+ // Later in the body than the overlay, so it paints on top at a higher z-index too.
+ expect(overlay.compareDocumentPosition(warning) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ await advance(30 * SECOND);
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
} finally {
+ overlay.remove();
hasFocus.mockRestore();
}
});
diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx
index 2a805f02e..f8a5e6006 100644
--- a/components/session/session-timeout-guard.tsx
+++ b/components/session/session-timeout-guard.tsx
@@ -28,27 +28,12 @@ const ACTIVITY_THROTTLE_MS = 1000;
/**
* Bubbling input events, caught on the way down. Scrolling and focus moves
* inside the page don't count: the page scrolls and refocuses on its own.
+ * Input inside a frame (the office editor, plugin panels) never reaches this
+ * window and doesn't count either: a focused frame left alone would keep the
+ * session open forever. Users working there see the warning, which sits
+ * above every overlay, and choose to stay.
*/
const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel"] as const;
-/**
- * Input inside a cross-origin frame (the office editor, plugin panels)
- * never reaches this window. While such a frame has the focus, that counts
- * as activity, checked this often.
- */
-export const FRAME_FOCUS_CHECK_MS = 5000;
-
-function crossOriginFrameHasFocus(): boolean {
- if (typeof document === "undefined" || !document.hasFocus()) return false;
- const element = document.activeElement;
- if (!(element instanceof HTMLIFrameElement)) return false;
- try {
- // Same-origin frames (the message body) are readable and don't count:
- // a message clicked into and left would keep the session alive.
- return element.contentDocument === null;
- } catch {
- return true;
- }
-}
/** Policy subjects per client and connection round; a failed or empty answer is asked again. */
const subjects = new WeakMap }>();
@@ -228,9 +213,6 @@ export function SessionTimeoutGuard() {
const onVisibility = () => {
if (document.visibilityState === "visible") timerRef.current?.check();
};
- const frameCheck = setInterval(() => {
- if (crossOriginFrameHasFocus()) onActivity();
- }, FRAME_FOCUS_CHECK_MS);
for (const event of ACTIVITY_EVENTS) window.addEventListener(event, onActivity, { capture: true, passive: true });
// The window coming back into focus, not focus moving between elements.
window.addEventListener("focus", onActivity);
@@ -239,7 +221,6 @@ export function SessionTimeoutGuard() {
for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, onActivity, { capture: true });
window.removeEventListener("focus", onActivity);
document.removeEventListener("visibilitychange", onVisibility);
- clearInterval(frameCheck);
channel.close();
channelRef.current = null;
};
From f07a993c6df2a76d6f395dc894938862728d3c28 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:28:59 +0100
Subject: [PATCH 11/11] docs: add session limits to the changelog
---
CHANGELOG.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9bab4990..9dec4f872 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## Unreleased
+
+### Features
+
+- **Organisation**: Admins can sign users out after a time without activity or a fixed time after sign-in, per domain or group. A countdown with "Stay signed in" comes first, open and minimised messages and quick replies are saved as drafts, and the login page says why
+
## 1.18.0 (2026-10-26)
1.18.0 works with Stalwart 1.0, released today, as well as with 0.16. Sign-in, calendars, contacts, files and sending were adapted to what changed in 1.0, and a Stalwart that was moved from 0.16 to 1.0 with Stalwart's migration tool keeps working with the same open tabs. It also fixes the findings of a security review of the whole webmail, more places where an action in a view that mixes accounts could hit another account's message with the same id, and a series of calendar bugs around repeating events. Plugins can show their own search results and a count on their buttons, quick reply can be turned off, and a new device offers to restore your saved account list.