feat: migrate Stalwart management API to JMAP x: methods (0.16)

Drops the 0.15 REST management API and routes all account/auth/crypto/
principal operations through Stalwart 0.16's schema-driven JMAP
endpoint via a single passthrough (/api/account/stalwart/jmap).

- New client helper `stalwartJmap` + typed `requireResult`
- account-security-store rewritten against x:AccountPassword, x:AppPassword,
  x:AccountSettings, x:Account (with currentSecret for TOTP ops)
- Client-side TOTP setup via `otpauth`; server-generated app password
  secrets shown once on create
- Admin check switched to /api/account permissions
  (sysAccountQuery/sysTenantQuery/sysSystemSettingsGet)
- Removed sieve vacation-overwrite workaround (fixed upstream #1251)
- Deleted old REST routes, StalwartClient, stale tests; added new
  tests for passthrough + store
This commit is contained in:
Linus Rath
2026-04-21 17:29:23 +02:00
parent 9ad2facad3
commit 794001fdbd
25 changed files with 1189 additions and 1592 deletions
-185
View File
@@ -1,185 +0,0 @@
/**
* Stalwart Management API Client
*
* Provides typed access to Stalwart's /api/ endpoints for user self-service:
* - Password change (PATCH /principal/{name})
* - Display name update (PATCH /principal/{name})
* - App passwords (POST /account/auth)
* - TOTP 2FA management (POST /account/auth)
* - Encryption-at-rest (GET/POST /account/crypto)
* - Account auth info (GET /account/auth)
*/
export interface StalwartAuthInfo {
otpEnabled: boolean;
isAdminApp: boolean;
appPasswords: string[];
}
export interface StalwartCryptoInfo {
type: 'disabled' | 'pgp' | 'smime';
}
export interface StalwartPrincipal {
id: number;
type: string;
name: string;
description: string;
emails: string | string[];
secrets: string | string[];
quota: number;
roles: string[];
lists: string[];
}
export interface PrincipalUpdateAction {
action: 'set' | 'addItem' | 'removeItem';
field: string;
value: string | number;
}
export interface StalwartApiError {
error: string;
details: string;
reason?: string | null;
}
export class StalwartClient {
private baseUrl: string;
private authHeader: string;
constructor(serverUrl: string, authHeader: string) {
this.baseUrl = serverUrl.replace(/\/$/, '') + '/api';
this.authHeader = authHeader;
}
// eslint-disable-next-line no-undef
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
...init,
headers: {
'Authorization': this.authHeader,
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!response.ok) {
let errorDetail = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body.detail) errorDetail = body.detail;
else if (body.details) errorDetail = body.details;
else if (body.error) errorDetail = body.error;
} catch { /* use status code */ }
throw new Error(errorDetail);
}
return response.json();
}
/** Probe whether this server exposes Stalwart's management API */
async probe(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/account/auth`, {
method: 'GET',
headers: { 'Authorization': this.authHeader },
});
if (response.status === 401) return true; // API exists but needs auth
if (!response.ok) return false;
const data = await response.json();
return data.data !== undefined;
} catch {
return false;
}
}
/** GET /account/auth - Fetch 2FA and app password status */
async getAuthInfo(): Promise<StalwartAuthInfo> {
const result = await this.request<{ data: StalwartAuthInfo }>('/account/auth');
return result.data;
}
/** POST /account/auth - Update auth settings (TOTP, app passwords) */
async updateAuth(actions: Array<{ type: string; name?: string; password?: string; url?: string }>): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify(actions),
});
}
/** Enable TOTP - returns the TOTP URL for QR code generation */
async enableTotp(): Promise<string> {
const result = await this.request<{ data: string }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'enableOtpAuth' }]),
});
return result.data;
}
/** Disable TOTP */
async disableTotp(): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'disableOtpAuth' }]),
});
}
/** Add an app password */
async addAppPassword(name: string, password: string): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
});
}
/** Remove an app password */
async removeAppPassword(name: string): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'removeAppPassword', name }]),
});
}
/** GET /account/crypto - Fetch encryption-at-rest settings */
async getCryptoInfo(): Promise<StalwartCryptoInfo> {
const result = await this.request<{ data: StalwartCryptoInfo }>('/account/crypto');
return result.data;
}
/** POST /account/crypto - Update encryption-at-rest settings */
async updateCrypto(settings: { type: string; algo?: string; certs?: string }): Promise<void> {
await this.request<{ data: unknown }>('/account/crypto', {
method: 'POST',
body: JSON.stringify(settings),
});
}
/** GET /principal/{name} - Fetch principal details */
async getPrincipal(name: string): Promise<StalwartPrincipal> {
const result = await this.request<{ data: StalwartPrincipal }>(`/principal/${encodeURIComponent(name)}`);
return result.data;
}
/** PATCH /principal/{name} - Update principal fields */
async updatePrincipal(name: string, actions: PrincipalUpdateAction[]): Promise<void> {
await this.request<{ data: unknown }>(`/principal/${encodeURIComponent(name)}`, {
method: 'PATCH',
body: JSON.stringify(actions),
});
}
/** Change password via PATCH /principal/{name} */
async changePassword(name: string, newPassword: string): Promise<void> {
await this.updatePrincipal(name, [
{ action: 'set', field: 'secrets', value: newPassword },
]);
}
/** Update display name via PATCH /principal/{name} */
async updateDisplayName(name: string, displayName: string): Promise<void> {
await this.updatePrincipal(name, [
{ action: 'set', field: 'description', value: displayName },
]);
}
}
+2 -25
View File
@@ -4,9 +4,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
export interface StalwartCredentials {
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
apiUrl: string;
/** URL of the JMAP server (for JMAP operations like password verification) */
/** URL of the JMAP server (used for JMAP + management method calls) */
serverUrl: string;
authHeader: string;
username: string;
@@ -14,26 +12,6 @@ export interface StalwartCredentials {
slot: number;
}
/**
* Resolve the base URL for Stalwart management API requests.
*
* When the JMAP server sits behind a reverse proxy that only forwards
* JMAP paths, the `/api/account/*` and `/api/principal/*` management
* endpoints may not be exposed. In that case, operators can set
* `STALWART_API_URL` to point directly at the Stalwart HTTP listener
* (e.g. `https://admin.example.com`).
*/
function getStalwartApiUrl(jmapServerUrl: string): string {
const url = process.env.STALWART_API_URL || jmapServerUrl;
return url.replace(/\/+$/, '');
}
/**
* Extract credentials from the incoming request.
*
* Credentials are read from a verified, httpOnly auth-context cookie that is
* populated after a successful JMAP login or token refresh.
*/
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
const slot = parseInt(raw, 10);
@@ -55,8 +33,7 @@ export async function getStalwartCredentials(request: NextRequest): Promise<Stal
if (!context) continue;
return {
apiUrl: getStalwartApiUrl(context.serverUrl),
serverUrl: context.serverUrl,
serverUrl: context.serverUrl.replace(/\/+$/, ''),
authHeader: context.authHeader,
username: context.username,
hasSessionCookie: !!cookieStore.get(sessionCookieName(slot))?.value,
+66
View File
@@ -0,0 +1,66 @@
import { apiFetch } from '@/lib/browser-navigation';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
export type JmapMethodCall = [string, Record<string, unknown>, string];
export type JmapMethodResponse = [string, Record<string, unknown>, string];
export const STALWART_JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
export interface StalwartJmapError extends Error {
status: number;
methodError?: { type: string; description?: string };
}
function buildError(message: string, status: number, methodError?: StalwartJmapError['methodError']): StalwartJmapError {
const err = new Error(message) as StalwartJmapError;
err.status = status;
if (methodError) err.methodError = methodError;
return err;
}
/**
* Send a JMAP request to Stalwart via the server-side passthrough.
* The passthrough injects the stored basic-auth header so credentials
* stay in an httpOnly cookie.
*/
export async function stalwartJmap(methodCalls: JmapMethodCall[]): Promise<JmapMethodResponse[]> {
const response = await apiFetch('/api/account/stalwart/jmap', {
method: 'POST',
headers: { ...getActiveAccountSlotHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ using: STALWART_JMAP_USING, methodCalls }),
});
if (!response.ok) {
let message = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body?.error) message = body.error;
} catch { /* ignore */ }
throw buildError(message, response.status);
}
const data = await response.json();
const responses = (data.methodResponses ?? []) as JmapMethodResponse[];
const first = responses[0];
if (first && first[0] === 'error') {
const result = first[1] as { type?: string; description?: string };
throw buildError(result.description || result.type || 'JMAP error', 200, {
type: result.type || 'unknown',
description: result.description,
});
}
return responses;
}
export function requireResult<T = Record<string, unknown>>(
responses: JmapMethodResponse[],
expectedMethod: string,
): T {
const match = responses.find(r => r[0] === expectedMethod);
if (!match) {
throw buildError(`Expected method ${expectedMethod} in response`, 200);
}
return match[1] as T;
}