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:
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth/active-account-slot', () => ({
|
||||
getActiveAccountSlotHeaders: vi.fn(() => ({ 'X-JMAP-Cookie-Slot': '0' })),
|
||||
}));
|
||||
|
||||
import { stalwartJmap, requireResult, STALWART_JMAP_USING } from '@/lib/stalwart/jmap-passthrough';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('stalwartJmap', () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('POSTs to /api/account/stalwart/jmap with the standard using array', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: [] }));
|
||||
|
||||
await stalwartJmap([['x:Account/get', { accountId: 'a', ids: ['a'] }, '0']]);
|
||||
|
||||
expect(mockedFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockedFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/account/stalwart/jmap');
|
||||
expect(init.method).toBe('POST');
|
||||
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.using).toEqual(STALWART_JMAP_USING);
|
||||
expect(body.methodCalls).toEqual([['x:Account/get', { accountId: 'a', ids: ['a'] }, '0']]);
|
||||
});
|
||||
|
||||
it('forwards the active account slot header', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: [] }));
|
||||
|
||||
await stalwartJmap([['x:Account/get', {}, '0']]);
|
||||
|
||||
const init = mockedFetch.mock.calls[0][1];
|
||||
expect(init.headers['X-JMAP-Cookie-Slot']).toBe('0');
|
||||
expect(init.headers['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('returns methodResponses on success', async () => {
|
||||
const responses = [['x:AccountPassword/get', { list: [{ id: 'singleton' }] }, '0']];
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: responses }));
|
||||
|
||||
const result = await stalwartJmap([['x:AccountPassword/get', { accountId: 'a', ids: ['singleton'] }, '0']]);
|
||||
|
||||
expect(result).toEqual(responses);
|
||||
});
|
||||
|
||||
it('throws with status and message when the passthrough returns non-OK', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(401, { error: 'Not authenticated' }));
|
||||
|
||||
await expect(stalwartJmap([['x:Account/get', {}, '0']])).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: 'Not authenticated',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws with HTTP fallback message when error body is unparseable', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(new Response('oh no', { status: 500 }));
|
||||
|
||||
await expect(stalwartJmap([['x:Account/get', {}, '0']])).rejects.toMatchObject({
|
||||
status: 500,
|
||||
message: 'HTTP 500',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when first method response is a JMAP-level error', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, {
|
||||
methodResponses: [['error', { type: 'forbidden', description: 'Current secret must be provided' }, '0']],
|
||||
}));
|
||||
|
||||
await expect(stalwartJmap([['x:AccountPassword/set', {}, '0']])).rejects.toMatchObject({
|
||||
status: 200,
|
||||
message: 'Current secret must be provided',
|
||||
methodError: { type: 'forbidden', description: 'Current secret must be provided' },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to error type when description is absent', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, {
|
||||
methodResponses: [['error', { type: 'unknownMethod' }, '0']],
|
||||
}));
|
||||
|
||||
await expect(stalwartJmap([['x:Nope/get', {}, '0']])).rejects.toMatchObject({
|
||||
methodError: { type: 'unknownMethod' },
|
||||
message: 'unknownMethod',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireResult', () => {
|
||||
it('returns the arguments of the matching method', () => {
|
||||
const responses: Array<[string, Record<string, unknown>, string]> = [
|
||||
['x:Account/get', { list: [{ id: 'a' }] }, '0'],
|
||||
['x:AppPassword/query', { ids: ['p1'] }, '1'],
|
||||
];
|
||||
|
||||
const result = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query');
|
||||
expect(result.ids).toEqual(['p1']);
|
||||
});
|
||||
|
||||
it('throws when the expected method is missing', () => {
|
||||
const responses: Array<[string, Record<string, unknown>, string]> = [
|
||||
['x:Account/get', {}, '0'],
|
||||
];
|
||||
|
||||
expect(() => requireResult(responses, 'x:AppPassword/query')).toThrow(/x:AppPassword\/query/);
|
||||
});
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { StalwartClient } from '../stalwart/client';
|
||||
|
||||
function mockFetchResponse(status: number, body?: unknown): Response {
|
||||
return new Response(body ? JSON.stringify(body) : null, {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('StalwartClient', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
let client: StalwartClient;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
client = new StalwartClient('https://mail.example.com/', 'Basic dXNlcjpwYXNz');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('strips trailing slash from server URL', () => {
|
||||
const c = new StalwartClient('https://mail.example.com/', 'Basic abc');
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } }));
|
||||
c.getAuthInfo();
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://mail.example.com/api/account/auth',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probe', () => {
|
||||
it('returns true when server responds with data field', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false } }));
|
||||
const result = await client.probe();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when server returns 401 (API exists but needs auth)', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(401));
|
||||
const result = await client.probe();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when server returns 404', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(404));
|
||||
const result = await client.probe();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on network error', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new TypeError('Network error'));
|
||||
const result = await client.probe();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when response has no data field', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { something: 'else' }));
|
||||
const result = await client.probe();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthInfo', () => {
|
||||
it('returns auth info on success', async () => {
|
||||
const authInfo = { otpEnabled: true, isAdminApp: false, appPasswords: ['app1'] };
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: authInfo }));
|
||||
|
||||
const result = await client.getAuthInfo();
|
||||
expect(result).toEqual(authInfo);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://mail.example.com/api/account/auth',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': 'Basic dXNlcjpwYXNz',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { detail: 'Forbidden' }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('Forbidden');
|
||||
});
|
||||
|
||||
it('throws with HTTP status when error body is unparseable', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('not json', { status: 500 }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableTotp', () => {
|
||||
it('sends enableOtpAuth action and returns TOTP URL', async () => {
|
||||
const totpUrl = 'otpauth://totp/user@example.com?secret=ABC123';
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl }));
|
||||
|
||||
const result = await client.enableTotp();
|
||||
expect(result).toBe(totpUrl);
|
||||
|
||||
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(callBody).toEqual([{ type: 'enableOtpAuth' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableTotp', () => {
|
||||
it('sends disableOtpAuth action', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.disableTotp();
|
||||
|
||||
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(callBody).toEqual([{ type: 'disableOtpAuth' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAppPassword', () => {
|
||||
it('sends addAppPassword action with name and password', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.addAppPassword('Thunderbird', 'secret123');
|
||||
|
||||
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(callBody).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret123' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAppPassword', () => {
|
||||
it('sends removeAppPassword action with name', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.removeAppPassword('Thunderbird');
|
||||
|
||||
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(callBody).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCryptoInfo', () => {
|
||||
it('returns crypto info on success', async () => {
|
||||
const cryptoInfo = { type: 'pgp' as const };
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: cryptoInfo }));
|
||||
|
||||
const result = await client.getCryptoInfo();
|
||||
expect(result).toEqual(cryptoInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCrypto', () => {
|
||||
it('sends crypto settings', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.updateCrypto({ type: 'pgp' });
|
||||
|
||||
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(callBody).toEqual({ type: 'pgp' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrincipal', () => {
|
||||
it('returns principal data on success', async () => {
|
||||
const principal = {
|
||||
id: 1, type: 'individual', name: 'testuser',
|
||||
description: 'Test User', emails: ['test@example.com'],
|
||||
secrets: [], quota: 1000000, roles: ['user'], lists: [],
|
||||
};
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: principal }));
|
||||
|
||||
const result = await client.getPrincipal('testuser');
|
||||
expect(result).toEqual(principal);
|
||||
});
|
||||
|
||||
it('encodes special characters in username', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
|
||||
|
||||
await client.getPrincipal('user@example.com');
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://mail.example.com/api/principal/user%40example.com',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePrincipal', () => {
|
||||
it('sends PATCH with action array', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.updatePrincipal('testuser', [
|
||||
{ action: 'set', field: 'description', value: 'New Name' },
|
||||
]);
|
||||
|
||||
const call = fetchSpy.mock.calls[0];
|
||||
expect(call[0]).toBe('https://mail.example.com/api/principal/testuser');
|
||||
expect(call[1]?.method).toBe('PATCH');
|
||||
const body = JSON.parse(call[1]?.body as string);
|
||||
expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('sends set secrets action via updatePrincipal', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.changePassword('testuser', 'newPassword123');
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body).toEqual([{ action: 'set', field: 'secrets', value: 'newPassword123' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDisplayName', () => {
|
||||
it('sends set description action via updatePrincipal', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.updateDisplayName('testuser', 'John Doe');
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body).toEqual([{ action: 'set', field: 'description', value: 'John Doe' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request error handling', () => {
|
||||
it('parses error.detail from response body', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { detail: 'Invalid request format' }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('Invalid request format');
|
||||
});
|
||||
|
||||
it('parses error.details from response body', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { details: 'Bad stuff' }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('Bad stuff');
|
||||
});
|
||||
|
||||
it('parses error.error from response body', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'Something wrong' }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('Something wrong');
|
||||
});
|
||||
|
||||
it('falls back to HTTP status code on non-JSON error', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('plain text', { status: 502 }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 502');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,10 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────────────
|
||||
|
||||
hasAccountCapability(_capability: string, _accountId?: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
getCapabilities(): Record<string, unknown> {
|
||||
return {
|
||||
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface IJMAPClient {
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────────────
|
||||
getCapabilities(): Record<string, unknown>;
|
||||
hasAccountCapability(capability: string, accountId?: string): boolean;
|
||||
getMaxSizeUpload(): number;
|
||||
getMaxCallsInRequest(): number;
|
||||
getMaxObjectsInGet(): number;
|
||||
|
||||
@@ -2558,6 +2558,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
return capability in this.capabilities;
|
||||
}
|
||||
|
||||
/** Check whether a capability is present on the primary account. */
|
||||
hasAccountCapability(capability: string, accountId?: string): boolean {
|
||||
const id = accountId || this.accountId;
|
||||
const caps = this.session?.accounts?.[id]?.accountCapabilities;
|
||||
return !!caps && capability in caps;
|
||||
}
|
||||
|
||||
getMaxSizeUpload(): number {
|
||||
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxSizeUpload?: number } | undefined;
|
||||
return coreCapability?.maxSizeUpload || 0;
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user