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
-87
View File
@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/auth
* Proxy to Stalwart GET /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart auth info failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch auth info' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/auth
* Proxy to Stalwart POST /api/account/auth
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart auth update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
-87
View File
@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/crypto
* Proxy to Stalwart GET /api/account/crypto
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch crypto info' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/crypto
* Proxy to Stalwart POST /api/account/crypto
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart crypto update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* POST /api/account/stalwart/jmap
*
* Passthrough to Stalwart's JMAP endpoint using the stored basic-auth
* context so the browser does not need access to the user's credentials.
*
* Body: standard JMAP request `{ using: string[], methodCalls: [...] }`
*
* In Stalwart 0.16 all management operations (password change, app
* passwords, API keys, account settings, etc.) are exposed as JMAP
* methods under the `x:` namespace on the same endpoint.
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.text();
const response = await fetch(`${creds.serverUrl}/jmap/`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body,
});
const responseText = await response.text();
return new NextResponse(responseText, {
status: response.status,
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
});
} catch (error) {
logger.error('Stalwart JMAP passthrough error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -1,93 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_COOKIE_MAX_AGE,
};
/**
* POST /api/account/stalwart/password
* Change user password via Stalwart PATCH /api/principal/{name}
*
* Body: { currentPassword: string, newPassword: string }
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
// Verify current password by attempting to authenticate
const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`;
const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': verifyAuth },
});
if (!verifyResponse.ok) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 });
}
// Change password via Stalwart principal API
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify([
{ action: 'set', field: 'secrets', value: newPassword },
]),
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart password change failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to change password', details: text },
{ status: response.status }
);
}
// If session cookie exists, update it with the new password
const cookieStore = await cookies();
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
}
if (creds.authHeader.startsWith('Basic ')) {
setStalwartAuthContextInStore(cookieStore, creds.slot, {
serverUrl: creds.serverUrl,
username: creds.username,
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
});
}
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -1,96 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/principal
* Proxy to Stalwart GET /api/principal/{username}
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch principal' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/account/stalwart/principal
* Proxy to Stalwart PATCH /api/principal/{username}
* Body: PrincipalUpdateAction[] (array of {action, field, value})
*/
export async function PATCH(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
// Prevent secrets field from being changed through this endpoint (use /password instead)
if (Array.isArray(body)) {
const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets');
if (hasSecrets) {
return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 });
}
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart principal update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
-44
View File
@@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/probe
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwart: false });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json({ isStalwart: false });
}
const data = await response.json();
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
return NextResponse.json({ isStalwart });
} catch {
clearTimeout(timeout);
return NextResponse.json({ isStalwart: false });
}
} catch (error) {
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ isStalwart: false });
}
}
+22 -4
View File
@@ -7,20 +7,38 @@ import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Check if the current user is a Stalwart admin by probing an admin-only endpoint.
* Permissions that indicate Stalwart admin privileges.
* If the authenticated user has at least one of these, they can manage
* system-level resources and are considered an admin.
*/
const ADMIN_PERMISSIONS = [
'sysAccountQuery',
'sysTenantQuery',
'sysSystemSettingsGet',
];
/**
* Check if the current user is a Stalwart admin by inspecting the
* permissions list returned by Stalwart's /api/account endpoint.
*/
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
try {
const creds = await getStalwartCredentials(request);
if (!creds) return false;
// Probe admin-only endpoint: listing principals requires admin privileges
const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, {
const response = await fetch(`${creds.serverUrl}/api/account`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
const isAdmin = response.ok;
if (!response.ok) {
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin: false });
return false;
}
const data = await response.json() as { permissions?: string[] };
const permissions = Array.isArray(data.permissions) ? data.permissions : [];
const isAdmin = ADMIN_PERMISSIONS.some(p => permissions.includes(p));
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin });
return isAdmin;
} catch (error) {
-41
View File
@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/admin/stalwart-check
* Check if the currently logged-in user is a Stalwart admin.
* Probes the admin-only principal-list endpoint - if the user can access it, they're an admin.
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
// Probe an admin-only endpoint: listing principals requires admin privileges.
// Use limit=1 to minimize payload.
const url = `${creds.apiUrl}/api/principal?limit=1`;
const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
const isStalwartAdmin = response.ok;
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
return NextResponse.json({ isStalwartAdmin }, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Stalwart admin check error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
}
const davPath = request.headers.get('X-WebDAV-Path') || '/';
const baseUrl = creds.apiUrl.replace(/\/$/, '');
const baseUrl = creds.serverUrl.replace(/\/$/, '');
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
// Build headers for the upstream request
+3 -2
View File
@@ -223,11 +223,12 @@ export function NavigationRail({
let cancelled = false;
const headers = getActiveAccountSlotHeaders();
if (!headers['X-JMAP-Cookie-Slot']) return;
apiFetch('/api/admin/stalwart-check', { headers })
apiFetch('/api/admin/auth', { headers })
.then(res => res.json())
.then(data => {
if (!cancelled && data.isStalwartAdmin) {
if (cancelled || !data.stalwartAdmin) return;
setIsStalwartAdmin(true);
if (!data.authenticated) {
// Pre-create admin session so /admin works even after full page navigation
apiFetch('/api/admin/auth', {
method: 'POST',
+208 -128
View File
@@ -1,12 +1,14 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import QRCode from 'qrcode';
import * as OTPAuth from 'otpauth';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { useAccountSecurityStore } from '@/stores/account-security-store';
import { useAccountSecurityStore, type AppPasswordInfo } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
@@ -172,37 +174,95 @@ function DisplayNameSection() {
);
}
function generateTotp(accountLabel: string): { totp: OTPAuth.TOTP; url: string } {
const totp = new OTPAuth.TOTP({
issuer: 'Stalwart',
label: accountLabel || 'account',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: new OTPAuth.Secret({ size: 20 }),
});
return { totp, url: totp.toString() };
}
function TotpSection() {
const t = useTranslations('settings.security');
const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore();
const [totpUrl, setTotpUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { client } = useAuthStore();
const handleToggle = async (enable: boolean) => {
try {
if (enable) {
const url = await enableTotp();
setTotpUrl(url);
toast.success(t('totp.enabled'));
} else {
await disableTotp();
setTotpUrl(null);
toast.success(t('totp.disabled'));
const [setupUrl, setSetupUrl] = useState<string | null>(null);
const [setupTotp, setSetupTotp] = useState<OTPAuth.TOTP | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [password, setPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
const [setupError, setSetupError] = useState<string | null>(null);
const [disableOpen, setDisableOpen] = useState(false);
useEffect(() => {
if (!setupUrl) { setQrDataUrl(null); return; }
let cancelled = false;
QRCode.toDataURL(setupUrl, { width: 220, margin: 1 })
.then((url) => { if (!cancelled) setQrDataUrl(url); })
.catch(() => { /* ignore */ });
return () => { cancelled = true; };
}, [setupUrl]);
const startSetup = () => {
const { totp, url } = generateTotp(client?.getUsername() ?? 'account');
setSetupTotp(totp);
setSetupUrl(url);
setPassword('');
setOtpCode('');
setSetupError(null);
};
const cancelSetup = () => {
setSetupTotp(null);
setSetupUrl(null);
setPassword('');
setOtpCode('');
setSetupError(null);
};
const confirmSetup = async () => {
if (!setupTotp || !setupUrl) return;
if (!password) { setSetupError(t('totp.password_required')); return; }
if (!otpCode.trim()) { setSetupError(t('totp.code_required')); return; }
if (setupTotp.validate({ token: otpCode.trim(), window: 1 }) === null) {
setSetupError(t('totp.code_invalid'));
return;
}
try {
await enableTotp(password, setupUrl, otpCode.trim());
cancelSetup();
toast.success(t('totp.enabled'));
} catch (err) {
toast.error(
enable ? t('totp.enable_error') : t('totp.disable_error'),
err instanceof Error ? err.message : undefined
);
setSetupError(err instanceof Error ? err.message : t('totp.enable_error'));
}
};
const handleCopyUrl = () => {
if (totpUrl) {
navigator.clipboard.writeText(totpUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
const handleDisable = async () => {
if (!password) { setSetupError(t('totp.password_required')); return; }
try {
await disableTotp(password);
setDisableOpen(false);
setPassword('');
setSetupError(null);
toast.success(t('totp.disabled'));
} catch (err) {
setSetupError(err instanceof Error ? err.message : t('totp.disable_error'));
}
};
const handleToggle = (enable: boolean) => {
setSetupError(null);
if (enable) {
startSetup();
} else {
setDisableOpen(true);
setPassword('');
}
};
@@ -218,30 +278,66 @@ function TotpSection() {
<div className="space-y-3">
<SettingItem label={t('totp.label')} description={t('totp.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={otpEnabled}
checked={otpEnabled || !!setupUrl}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<span className={cn('text-xs font-medium', otpEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{otpEnabled ? t('totp.active') : t('totp.inactive')}
</span>
</div>
</SettingItem>
{totpUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
{setupUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-3">
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
{qrDataUrl && (
<div className="flex justify-center">
{ /* eslint-disable-next-line @next/next/no-img-element */ }
<img src={qrDataUrl} alt="TOTP QR code" className="rounded bg-white p-2" />
</div>
)}
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">
{totpUrl}
</code>
<Button variant="outline" size="sm" onClick={handleCopyUrl}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">{setupUrl}</code>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('password.current')}</label>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('totp.verification_code')}</label>
<Input value={otpCode} onChange={(e) => setOtpCode(e.target.value)} inputMode="numeric" maxLength={6} />
</div>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={confirmSetup} disabled={isSaving || !password || !otpCode}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('totp.confirm')}
</Button>
<Button size="sm" variant="ghost" onClick={cancelSetup}>{t('app_passwords.cancel')}</Button>
</div>
</div>
)}
{disableOpen && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{t('totp.disable_confirm_prompt')}</p>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('password.current')}
autoComplete="current-password"
/>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={handleDisable} disabled={isSaving || !password}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('totp.disable')}
</Button>
<Button size="sm" variant="ghost" onClick={() => { setDisableOpen(false); setPassword(''); setSetupError(null); }}>
{t('app_passwords.cancel')}
</Button>
</div>
</div>
@@ -250,36 +346,52 @@ function TotpSection() {
);
}
function AppPasswordRow({ password, onRemove, isSaving }: { password: AppPasswordInfo; onRemove: (id: string) => void; isSaving: boolean }) {
return (
<div className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
<div className="flex flex-col">
<span className="text-sm text-foreground">{password.description || password.id}</span>
{password.createdAt && (
<span className="text-xs text-muted-foreground">
{new Date(password.createdAt).toLocaleDateString()}
{password.expiresAt ? ` · expires ${new Date(password.expiresAt).toLocaleDateString()}` : ''}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onRemove(password.id)}
disabled={isSaving}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
);
}
function AppPasswordsSection() {
const t = useTranslations('settings.security');
const { appPasswords, addAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore();
const { appPasswords, createAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore();
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState('');
const [newPassword, setNewPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const generatePassword = useCallback(() => {
const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let result = '';
const array = new Uint8Array(24);
crypto.getRandomValues(array);
for (const byte of array) {
result += chars[byte % chars.length];
}
// Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx
return result.match(/.{1,4}/g)?.join('-') ?? result;
}, []);
const [newDescription, setNewDescription] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
const password = newPassword || generatePassword();
if (!newDescription.trim()) return;
try {
await addAppPassword(newName.trim(), password);
setNewName('');
setNewPassword('');
const result = await createAppPassword(
newDescription.trim(),
expiresAt ? new Date(expiresAt).toISOString() : null,
);
setCreatedSecret(result.secret);
setNewDescription('');
setExpiresAt('');
setShowAdd(false);
toast.success(t('app_passwords.added'));
} catch (err) {
@@ -287,15 +399,23 @@ function AppPasswordsSection() {
}
};
const handleRemove = async (name: string) => {
const handleRemove = async (id: string) => {
try {
await removeAppPassword(name);
await removeAppPassword(id);
toast.success(t('app_passwords.removed'));
} catch (err) {
toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined);
}
};
const handleCopySecret = () => {
if (!createdSecret) return;
navigator.clipboard.writeText(createdSecret).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
if (isLoadingAuth) {
return (
<div className="space-y-2">
@@ -322,43 +442,40 @@ function AppPasswordsSection() {
</div>
<p className="text-xs text-muted-foreground">{t('app_passwords.description')}</p>
{createdSecret && (
<div className="p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{t('app_passwords.copy_now_warning')}</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 font-mono">
{createdSecret}
</code>
<Button variant="outline" size="sm" onClick={handleCopySecret}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => setCreatedSecret(null)}>
{t('app_passwords.done')}
</Button>
</div>
)}
{showAdd && (
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.name_label')}</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder={t('app_passwords.name_placeholder')}
required
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.password_label')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('app_passwords.password_placeholder')}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setNewPassword(generatePassword())}>
{t('app_passwords.generate')}
</Button>
</div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.expires_label')}</label>
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSaving || !newName.trim()}>
<Button type="submit" size="sm" disabled={isSaving || !newDescription.trim()}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('app_passwords.create')}
</Button>
@@ -371,19 +488,8 @@ function AppPasswordsSection() {
{appPasswords.length > 0 ? (
<div className="space-y-1">
{appPasswords.map((name) => (
<div key={name} className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
<span className="text-sm text-foreground">{name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(name)}
disabled={isSaving}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
{appPasswords.map((p) => (
<AppPasswordRow key={p.id} password={p} onRemove={handleRemove} isSaving={isSaving} />
))}
</div>
) : (
@@ -395,21 +501,7 @@ function AppPasswordsSection() {
function EncryptionSection() {
const t = useTranslations('settings.security');
const { encryptionType, updateEncryption, isSaving, isLoadingCrypto } = useAccountSecurityStore();
const handleToggle = async (enabled: boolean) => {
try {
if (enabled) {
await updateEncryption({ type: 'pgp', algo: 'Aes256' });
toast.success(t('encryption.enabled'));
} else {
await updateEncryption({ type: 'disabled' });
toast.success(t('encryption.disabled_success'));
}
} catch (err) {
toast.error(t('encryption.error'), err instanceof Error ? err.message : undefined);
}
};
const { encryptionType, isLoadingCrypto } = useAccountSecurityStore();
if (isLoadingCrypto) {
return (
@@ -419,24 +511,12 @@ function EncryptionSection() {
);
}
const isEnabled = encryptionType !== 'disabled';
const isEnabled = encryptionType !== 'Disabled';
return (
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={isEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')}
{isEnabled ? t('encryption.active', { type: encryptionType }) : t('encryption.inactive')}
</span>
</div>
</SettingItem>
);
}
@@ -446,7 +526,7 @@ function EmailClientSection() {
const { client } = useAuthStore();
const [copied, setCopied] = useState(false);
const jmapUsername = client?.getUsername() || '';
const jmapUsername = useMemo(() => client?.getUsername() || '', [client]);
const handleCopy = () => {
navigator.clipboard.writeText(jmapUsername).then(() => {
-14
View File
@@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { useVacationStore } from '@/stores/vacation-store';
import { useFilterStore } from '@/stores/filter-store';
import { useAuthStore } from '@/stores/auth-store';
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { toast } from '@/stores/toast-store';
@@ -104,19 +103,6 @@ export function VacationSettings() {
textBody: localTextBody,
});
// Re-save the filter script to preserve metadata and include vacation block.
// This prevents the server from injecting vacation Sieve code that destroys
// the metadata comment the visual filter builder relies on.
try {
await useFilterStore.getState().syncVacationToScript(client, {
isEnabled: localEnabled,
subject: localSubject,
textBody: localTextBody,
});
} catch {
// Non-critical: vacation was saved via JMAP, script sync is best-effort
}
toast.success(tNotifications('vacation_saved'));
} catch (error) {
console.error('Failed to save vacation response:', error);
+126
View File
@@ -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/);
});
});
-246
View File
@@ -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');
});
});
});
+4
View File
@@ -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 },
+1
View File
@@ -27,6 +27,7 @@ export interface IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
getCapabilities(): Record<string, unknown>;
hasAccountCapability(capability: string, accountId?: string): boolean;
getMaxSizeUpload(): number;
getMaxCallsInRequest(): number;
getMaxObjectsInGet(): number;
+7
View File
@@ -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;
-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;
}
+279 -8
View File
@@ -28,9 +28,11 @@
"lucide-react": "^0.575.0",
"next": "^16.1.5",
"next-intl": "^4.5.8",
"otpauth": "^9.5.0",
"pkijs": "^3.3.3",
"postal-mime": "^2.7.4",
"pvtsutils": "^1.3.6",
"qrcode": "^1.5.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sonner": "^2.0.7",
@@ -46,6 +48,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^25.2.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0",
@@ -2061,10 +2064,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
@@ -4063,6 +4063,16 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -4535,7 +4545,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4545,7 +4554,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -4934,6 +4942,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001772",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001772.tgz",
@@ -4987,6 +5004,17 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -5000,7 +5028,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -5013,7 +5040,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
@@ -5217,6 +5243,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -5295,6 +5330,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -5361,6 +5402,12 @@
"minimalistic-crypto-utils": "^1.0.1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
@@ -6171,6 +6218,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -6741,6 +6797,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -8024,6 +8089,18 @@
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/otpauth": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz",
"integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"funding": {
"url": "https://github.com/hectorm/otpauth?sponsor=1"
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -8074,6 +8151,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -8110,7 +8196,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8219,6 +8304,15 @@
"node": ">=18"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/po-parser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
@@ -8565,6 +8659,23 @@
"node": ">=16.0.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -8682,6 +8793,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -8692,6 +8812,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -8846,6 +8972,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -9123,6 +9255,20 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -9221,6 +9367,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -10050,6 +10208,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@@ -10099,6 +10263,20 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
@@ -10116,6 +10294,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -10123,6 +10307,93 @@
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+3
View File
@@ -51,9 +51,11 @@
"lucide-react": "^0.575.0",
"next": "^16.1.5",
"next-intl": "^4.5.8",
"otpauth": "^9.5.0",
"pkijs": "^3.3.3",
"postal-mime": "^2.7.4",
"pvtsutils": "^1.3.6",
"qrcode": "^1.5.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sonner": "^2.0.7",
@@ -69,6 +71,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^25.2.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0",
+206 -293
View File
@@ -1,423 +1,336 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useAccountSecurityStore } from '../account-security-store';
import { describe, it, expect, vi, beforeEach } from 'vitest';
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: { 'Content-Type': 'application/json' },
});
vi.mock('@/lib/stalwart/jmap-passthrough', () => ({
stalwartJmap: vi.fn(),
requireResult: <T,>(responses: Array<[string, unknown, string]>, method: string): T => {
const match = responses.find(r => r[0] === method);
if (!match) throw new Error(`Missing ${method}`);
return match[1] as T;
},
}));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: {
getState: () => ({
client: {
getAccountId: () => 'acc-primary',
hasAccountCapability: (cap: string) => cap === 'urn:stalwart:jmap',
},
}),
},
}));
import { useAccountSecurityStore } from '../account-security-store';
import { stalwartJmap } from '@/lib/stalwart/jmap-passthrough';
const mockedJmap = stalwartJmap as unknown as ReturnType<typeof vi.fn>;
function resetStore() {
useAccountSecurityStore.getState().clearState();
}
const defaultState = {
isStalwart: null,
isProbing: false,
otpEnabled: false,
appPasswords: [],
isLoadingAuth: false,
encryptionType: 'disabled',
isLoadingCrypto: false,
displayName: '',
emails: [],
quota: 0,
roles: [],
isLoadingPrincipal: false,
isSaving: false,
error: null,
};
describe('AccountSecurityStore', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
describe('account-security-store', () => {
beforeEach(() => {
useAccountSecurityStore.setState(defaultState);
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
mockedJmap.mockReset();
resetStore();
});
describe('probe', () => {
it('sets isStalwart to true when probe succeeds', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: true }));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(true);
it('sets isStalwart=true when the account has the urn:stalwart:jmap capability', async () => {
const ok = await useAccountSecurityStore.getState().probe();
expect(ok).toBe(true);
expect(useAccountSecurityStore.getState().isStalwart).toBe(true);
expect(useAccountSecurityStore.getState().isProbing).toBe(false);
});
it('sets isStalwart to false when probe returns false', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: false }));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(false);
expect(useAccountSecurityStore.getState().isStalwart).toBe(false);
});
it('sets isStalwart to false on network error', async () => {
fetchSpy.mockRejectedValueOnce(new TypeError('Network error'));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(false);
expect(useAccountSecurityStore.getState().isStalwart).toBe(false);
expect(useAccountSecurityStore.getState().isProbing).toBe(false);
});
});
describe('fetchAuthInfo', () => {
it('populates auth info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: true, appPasswords: ['app1', 'app2'] } })
);
it('reports TOTP enabled when AccountPassword singleton has otpUrl', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ id: 'singleton', otpAuth: { otpUrl: 'otpauth://totp/x' } }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
]);
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.otpEnabled).toBe(true);
expect(state.appPasswords).toEqual(['app1', 'app2']);
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBeNull();
expect(useAccountSecurityStore.getState().otpEnabled).toBe(true);
expect(useAccountSecurityStore.getState().appPasswords).toEqual([]);
});
it('sets defaults when data fields are missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
it('reports TOTP disabled when otpAuth is empty', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ id: 'singleton', otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
]);
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.otpEnabled).toBe(false);
expect(state.appPasswords).toEqual([]);
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
});
it('sets error on HTTP failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500));
it('resolves app password rows via a follow-up Get when query returns ids', async () => {
mockedJmap
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: ['p1'] }, '1'],
])
.mockResolvedValueOnce([
['x:AppPassword/get', {
list: [{
id: 'p1',
description: 'Thunderbird',
createdAt: '2026-01-01T00:00:00Z',
expiresAt: null,
allowedIps: { '10.0.0.1': true },
}],
}, '0'],
]);
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBe('HTTP 500');
const pw = useAccountSecurityStore.getState().appPasswords[0];
expect(pw).toMatchObject({
id: 'p1',
description: 'Thunderbird',
createdAt: '2026-01-01T00:00:00Z',
expiresAt: null,
allowedIps: ['10.0.0.1'],
});
expect(mockedJmap).toHaveBeenCalledTimes(2);
});
it('sets error on network failure', async () => {
fetchSpy.mockRejectedValueOnce(new Error('Connection refused'));
it('records error on failure and clears loading flag', async () => {
mockedJmap.mockRejectedValueOnce(new Error('boom'));
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBe('Connection refused');
expect(useAccountSecurityStore.getState().isLoadingAuth).toBe(false);
expect(useAccountSecurityStore.getState().error).toBe('boom');
});
});
describe('fetchCryptoInfo', () => {
it('populates crypto info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { type: 'pgp' } })
);
it('reads encryption type from encryptionAtRest.@type', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountSettings/get', { list: [{ encryptionAtRest: { '@type': 'Aes256' } }] }, '0'],
]);
await useAccountSecurityStore.getState().fetchCryptoInfo();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('pgp');
expect(state.isLoadingCrypto).toBe(false);
expect(useAccountSecurityStore.getState().encryptionType).toBe('Aes256');
});
it('defaults to disabled when type is missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
it('defaults to Disabled when @type is missing or unknown', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountSettings/get', { list: [{ encryptionAtRest: null }] }, '0'],
]);
await useAccountSecurityStore.getState().fetchCryptoInfo();
expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled');
});
it('sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403));
await useAccountSecurityStore.getState().fetchCryptoInfo();
expect(useAccountSecurityStore.getState().error).toBe('HTTP 403');
expect(useAccountSecurityStore.getState().encryptionType).toBe('Disabled');
});
});
describe('fetchPrincipal', () => {
it('populates principal info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, {
data: {
description: 'John Doe',
emails: ['john@example.com', 'doe@example.com'],
quota: 5000000,
roles: ['user', 'admin'],
it('combines primary name with enabled aliases and exposes quota/roles', async () => {
mockedJmap.mockResolvedValueOnce([
['x:Account/get', {
list: [{
name: 'user@example.com',
description: 'Display User',
aliases: {
a1: { name: 'alias1@example.com', enabled: true },
a2: { name: 'alias2@example.com', enabled: false },
a3: { name: 'alias3@example.com', enabled: true },
},
})
);
quotas: { maxDiskQuota: 5_000_000 },
roles: { '@type': 'User' },
}],
}, '0'],
]);
await useAccountSecurityStore.getState().fetchPrincipal();
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('John Doe');
expect(state.emails).toEqual(['john@example.com', 'doe@example.com']);
expect(state.quota).toBe(5000000);
expect(state.roles).toEqual(['user', 'admin']);
expect(state.isLoadingPrincipal).toBe(false);
expect(state.displayName).toBe('Display User');
expect(state.emails).toEqual(['user@example.com', 'alias1@example.com', 'alias3@example.com']);
expect(state.quota).toBe(5_000_000);
expect(state.roles).toEqual(['User']);
});
it('handles single email string as array', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, {
data: { description: 'User', emails: 'single@example.com', quota: 0, roles: [] },
})
);
it('swallows forbidden errors (non-admins cannot read their own Account) without setting error', async () => {
mockedJmap.mockRejectedValueOnce(new Error('Forbidden: missing sysAccountGet permission'));
await useAccountSecurityStore.getState().fetchPrincipal();
expect(useAccountSecurityStore.getState().emails).toEqual(['single@example.com']);
expect(useAccountSecurityStore.getState().isLoadingPrincipal).toBe(false);
expect(useAccountSecurityStore.getState().error).toBeNull();
});
it('handles missing emails gracefully', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { description: 'User' } })
);
it('records non-forbidden errors', async () => {
mockedJmap.mockRejectedValueOnce(new Error('network down'));
await useAccountSecurityStore.getState().fetchPrincipal();
expect(useAccountSecurityStore.getState().emails).toEqual([]);
});
it('sets defaults when fields are missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await useAccountSecurityStore.getState().fetchPrincipal();
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('');
expect(state.emails).toEqual([]);
expect(state.quota).toBe(0);
expect(state.roles).toEqual([]);
});
});
describe('fetchAll', () => {
it('calls all three fetch methods in parallel', async () => {
fetchSpy
.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'smime' } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'Test', emails: [], quota: 0, roles: [] } }));
await useAccountSecurityStore.getState().fetchAll();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('smime');
expect(state.displayName).toBe('Test');
expect(state.isLoadingAuth).toBe(false);
expect(state.isLoadingCrypto).toBe(false);
expect(state.isLoadingPrincipal).toBe(false);
});
it('continues even if one fetch fails', async () => {
fetchSpy
.mockResolvedValueOnce(mockFetchResponse(500)) // auth fails
.mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'pgp' } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'OK', emails: [], quota: 0, roles: [] } }));
await useAccountSecurityStore.getState().fetchAll();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('pgp');
expect(state.displayName).toBe('OK');
expect(useAccountSecurityStore.getState().error).toBe('network down');
});
});
describe('changePassword', () => {
it('sends POST with currentPassword and newPassword', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { ok: true }));
it('calls x:AccountPassword/set with currentSecret and secret', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/set', { updated: { singleton: null } }, '0'],
]);
await useAccountSecurityStore.getState().changePassword('oldpass', 'newpass123');
await useAccountSecurityStore.getState().changePassword('old', 'new');
expect(fetchSpy).toHaveBeenCalledWith('/api/account/stalwart/password', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ currentPassword: 'oldpass', newPassword: 'newpass123' }),
}));
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
const calls = mockedJmap.mock.calls[0][0];
expect(calls).toEqual([[
'x:AccountPassword/set',
{
accountId: 'acc-primary',
update: { singleton: { currentSecret: 'old', secret: 'new' } },
},
'0',
]]);
});
it('throws and sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { error: 'Current password is incorrect' }));
await expect(
useAccountSecurityStore.getState().changePassword('wrong', 'newpass123')
).rejects.toThrow('Current password is incorrect');
it('propagates errors and records state', async () => {
mockedJmap.mockRejectedValueOnce(new Error('forbidden'));
await expect(useAccountSecurityStore.getState().changePassword('x', 'y')).rejects.toThrow('forbidden');
expect(useAccountSecurityStore.getState().error).toBe('forbidden');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
expect(useAccountSecurityStore.getState().error).toBe('Current password is incorrect');
});
});
describe('updateDisplayName', () => {
it('sends PATCH and updates local state on success', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
it('patches AccountSettings.description and updates local state', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountSettings/set', { updated: { singleton: null } }, '0'],
]);
await useAccountSecurityStore.getState().updateDisplayName('New Name');
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('New Name');
expect(state.isSaving).toBe(false);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]);
});
it('throws and sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server error' }));
await expect(
useAccountSecurityStore.getState().updateDisplayName('Name')
).rejects.toThrow('Server error');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
expect(useAccountSecurityStore.getState().displayName).toBe('New Name');
const args = mockedJmap.mock.calls[0][0][0][1];
expect(args).toEqual({ accountId: 'acc-primary', update: { singleton: { description: 'New Name' } } });
});
});
describe('enableTotp', () => {
it('sends enableOtpAuth and returns TOTP URL', async () => {
const totpUrl = 'otpauth://totp/user@example.com?secret=ABC';
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl }));
describe('enableTotp / disableTotp', () => {
it('enableTotp sends currentSecret + otpAuth.otpUrl + otpCode', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/set', { updated: { singleton: null } }, '0'],
]);
const result = await useAccountSecurityStore.getState().enableTotp();
await useAccountSecurityStore.getState().enableTotp('pw', 'otpauth://totp/x?secret=S', '123456');
expect(result).toBe(totpUrl);
expect(useAccountSecurityStore.getState().otpEnabled).toBe(true);
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'enableOtpAuth' }]);
});
it('throws and preserves otpEnabled=false on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'TOTP error' }));
await expect(
useAccountSecurityStore.getState().enableTotp()
).rejects.toThrow('TOTP error');
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
const args = mockedJmap.mock.calls[0][0][0][1];
expect(args.update.singleton).toEqual({
currentSecret: 'pw',
otpAuth: { otpUrl: 'otpauth://totp/x?secret=S', otpCode: '123456' },
});
});
describe('disableTotp', () => {
it('sends disableOtpAuth and sets otpEnabled to false', async () => {
it('disableTotp clears otpUrl', async () => {
useAccountSecurityStore.setState({ otpEnabled: true });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/set', { updated: { singleton: null } }, '0'],
]);
await useAccountSecurityStore.getState().disableTotp();
await useAccountSecurityStore.getState().disableTotp('pw');
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
const args = mockedJmap.mock.calls[0][0][0][1];
expect(args.update.singleton).toEqual({ currentSecret: 'pw', otpAuth: { otpUrl: null } });
});
});
describe('addAppPassword', () => {
it('sends addAppPassword and refreshes auth info', async () => {
// First call: POST addAppPassword
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
// Second call: fetchAuthInfo refresh
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['Thunderbird'] } })
);
describe('createAppPassword', () => {
it('returns the server-generated id and secret then refreshes auth info', async () => {
mockedJmap
.mockResolvedValueOnce([
['x:AppPassword/set', { created: { new: { id: 'p-new', secret: 'S3CR3T' } } }, '0'],
])
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
]);
await useAccountSecurityStore.getState().addAppPassword('Thunderbird', 'secret');
const result = await useAccountSecurityStore.getState().createAppPassword('CLI', '2026-12-01T00:00:00Z');
const state = useAccountSecurityStore.getState();
expect(state.appPasswords).toEqual(['Thunderbird']);
expect(state.isSaving).toBe(false);
expect(result).toEqual({ id: 'p-new', secret: 'S3CR3T' });
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret' }]);
const createArgs = mockedJmap.mock.calls[0][0][0][1];
expect(createArgs.create.new).toEqual({ description: 'CLI', expiresAt: '2026-12-01T00:00:00Z' });
expect(mockedJmap).toHaveBeenCalledTimes(2);
});
it('throws on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server down' }));
it('throws with server-provided description when notCreated is returned', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AppPassword/set', { notCreated: { new: { type: 'invalidProperties', description: 'description too short' } } }, '0'],
]);
await expect(
useAccountSecurityStore.getState().addAppPassword('App', 'pass')
).rejects.toThrow('Server down');
useAccountSecurityStore.getState().createAppPassword('x')
).rejects.toThrow('description too short');
});
it('throws when the server does not return a secret', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AppPassword/set', { created: { new: { id: 'p' } } }, '0'],
]);
await expect(
useAccountSecurityStore.getState().createAppPassword('x')
).rejects.toThrow(/did not return/i);
});
});
describe('removeAppPassword', () => {
it('sends removeAppPassword and refreshes auth info', async () => {
useAccountSecurityStore.setState({ appPasswords: ['Thunderbird', 'iPhone'] });
it('calls AppPassword/set with destroy and refreshes auth info', async () => {
mockedJmap
.mockResolvedValueOnce([['x:AppPassword/set', { destroyed: ['p1'] }, '0']])
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
]);
// First call: POST removeAppPassword
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
// Second call: fetchAuthInfo refresh
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['iPhone'] } })
);
await useAccountSecurityStore.getState().removeAppPassword('p1');
await useAccountSecurityStore.getState().removeAppPassword('Thunderbird');
expect(useAccountSecurityStore.getState().appPasswords).toEqual(['iPhone']);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]);
});
});
describe('updateEncryption', () => {
it('sends crypto settings and updates local encryptionType', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' });
expect(useAccountSecurityStore.getState().encryptionType).toBe('pgp');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
});
it('throws on failure without changing encryptionType', async () => {
useAccountSecurityStore.setState({ encryptionType: 'disabled' });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Encryption error' }));
await expect(
useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' })
).rejects.toThrow('Encryption error');
expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled');
const args = mockedJmap.mock.calls[0][0][0][1];
expect(args).toEqual({ accountId: 'acc-primary', destroy: ['p1'] });
expect(mockedJmap).toHaveBeenCalledTimes(2);
});
});
describe('clearState', () => {
it('resets all state to defaults', () => {
it('resets all derived fields back to defaults', () => {
useAccountSecurityStore.setState({
isStalwart: true,
otpEnabled: true,
appPasswords: ['app1'],
encryptionType: 'pgp',
displayName: 'Test User',
emails: ['test@example.com'],
quota: 5000000,
roles: ['admin'],
error: 'some error',
appPasswords: [{ id: 'p', description: 'd', createdAt: null, expiresAt: null, allowedIps: [] }],
encryptionType: 'Aes256',
displayName: 'user',
emails: ['a@b'],
quota: 10,
roles: ['User'],
error: 'x',
});
useAccountSecurityStore.getState().clearState();
const state = useAccountSecurityStore.getState();
expect(state.isStalwart).toBeNull();
expect(state.isProbing).toBe(false);
expect(state.otpEnabled).toBe(false);
expect(state.appPasswords).toEqual([]);
expect(state.encryptionType).toBe('disabled');
expect(state.encryptionType).toBe('Disabled');
expect(state.displayName).toBe('');
expect(state.emails).toEqual([]);
expect(state.quota).toBe(0);
expect(state.roles).toEqual([]);
expect(state.isSaving).toBe(false);
expect(state.error).toBeNull();
});
});
+206 -165
View File
@@ -1,51 +1,83 @@
import { create } from 'zustand';
import { debug } from '@/lib/debug';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { apiFetch } from '@/lib/browser-navigation';
import { useAuthStore } from '@/stores/auth-store';
import { stalwartJmap, requireResult } from '@/lib/stalwart/jmap-passthrough';
export type EncryptionType = 'Disabled' | 'Aes128' | 'Aes256';
export interface AppPasswordInfo {
id: string;
description: string;
createdAt: string | null;
expiresAt: string | null;
allowedIps: string[];
}
interface AccountSecurityState {
// Detection
isStalwart: boolean | null; // null = not yet probed
isStalwart: boolean | null;
isProbing: boolean;
// Auth info
otpEnabled: boolean;
appPasswords: string[];
appPasswords: AppPasswordInfo[];
isLoadingAuth: boolean;
// Crypto info
encryptionType: string;
// Encryption-at-rest
encryptionType: EncryptionType;
isLoadingCrypto: boolean;
// Principal info
// Profile
displayName: string;
emails: string[];
quota: number;
roles: string[];
isLoadingPrincipal: boolean;
// Operation states
isSaving: boolean;
error: string | null;
// Actions
probe: () => Promise<boolean>;
fetchAuthInfo: () => Promise<void>;
fetchCryptoInfo: () => Promise<void>;
fetchPrincipal: () => Promise<void>;
fetchAll: () => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
updateDisplayName: (displayName: string) => Promise<void>;
enableTotp: () => Promise<string>;
disableTotp: () => Promise<void>;
addAppPassword: (name: string, password: string) => Promise<void>;
removeAppPassword: (name: string) => Promise<void>;
updateEncryption: (settings: { type: string; algo?: string; certs?: string }) => Promise<void>;
enableTotp: (currentPassword: string, otpUrl: string, otpCode: string) => Promise<void>;
disableTotp: (currentPassword: string) => Promise<void>;
createAppPassword: (description: string, expiresAt?: string | null) => Promise<{ id: string; secret: string }>;
removeAppPassword: (id: string) => Promise<void>;
clearState: () => void;
}
function getApiHeaders(): Record<string, string> {
return getActiveAccountSlotHeaders();
function getPrimaryAccountId(): string {
const client = useAuthStore.getState().client;
if (!client) throw new Error('Not authenticated');
return client.getAccountId();
}
function appPasswordFromResult(raw: Record<string, unknown>): AppPasswordInfo {
const allowedIps = raw.allowedIps && typeof raw.allowedIps === 'object'
? Object.keys(raw.allowedIps as Record<string, unknown>)
: [];
return {
id: String(raw.id ?? ''),
description: typeof raw.description === 'string' ? raw.description : '',
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
allowedIps,
};
}
function extractEncryptionType(raw: unknown): EncryptionType {
if (!raw || typeof raw !== 'object') return 'Disabled';
const type = (raw as { ['@type']?: string })['@type'];
if (type === 'Aes128' || type === 'Aes256') return type;
return 'Disabled';
}
export const useAccountSecurityStore = create<AccountSecurityState>()((set, get) => ({
@@ -54,7 +86,7 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
otpEnabled: false,
appPasswords: [],
isLoadingAuth: false,
encryptionType: 'disabled',
encryptionType: 'Disabled',
isLoadingCrypto: false,
displayName: '',
emails: [],
@@ -67,11 +99,8 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
probe: async () => {
set({ isProbing: true });
try {
const response = await apiFetch('/api/account/stalwart/probe', {
headers: getApiHeaders(),
});
const data = await response.json();
const isStalwart = data.isStalwart === true;
const client = useAuthStore.getState().client;
const isStalwart = !!client?.hasAccountCapability?.('urn:stalwart:jmap');
set({ isStalwart, isProbing: false });
return isStalwart;
} catch (error) {
@@ -84,16 +113,31 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
fetchAuthInfo: async () => {
set({ isLoadingAuth: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/auth', {
headers: getApiHeaders(),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
set({
otpEnabled: data.data?.otpEnabled ?? false,
appPasswords: data.data?.appPasswords ?? [],
isLoadingAuth: false,
});
const accountId = getPrimaryAccountId();
const responses = await stalwartJmap([
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, '0'],
['x:AppPassword/query', { accountId }, '1'],
]);
const passwordResult = requireResult<{ list: Array<{ otpAuth?: { otpUrl?: string | null } }> }>(
responses,
'x:AccountPassword/get',
);
const queryResult = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query');
const otpAuth = passwordResult.list?.[0]?.otpAuth;
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl);
let appPasswords: AppPasswordInfo[] = [];
if (queryResult.ids?.length) {
const getResponses = await stalwartJmap([
['x:AppPassword/get', { accountId, ids: queryResult.ids }, '0'],
]);
const getResult = requireResult<{ list: Array<Record<string, unknown>> }>(getResponses, 'x:AppPassword/get');
appPasswords = (getResult.list ?? []).map(appPasswordFromResult);
}
set({ otpEnabled, appPasswords, isLoadingAuth: false });
} catch (error) {
debug.error('Failed to fetch auth info:', error);
set({
@@ -106,15 +150,16 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
fetchCryptoInfo: async () => {
set({ isLoadingCrypto: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/crypto', {
headers: getApiHeaders(),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
set({
encryptionType: data.data?.type ?? 'disabled',
isLoadingCrypto: false,
});
const accountId = getPrimaryAccountId();
const responses = await stalwartJmap([
['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0'],
]);
const result = requireResult<{ list: Array<{ encryptionAtRest?: unknown }> }>(
responses,
'x:AccountSettings/get',
);
const encryptionType = extractEncryptionType(result.list?.[0]?.encryptionAtRest);
set({ encryptionType, isLoadingCrypto: false });
} catch (error) {
debug.error('Failed to fetch crypto info:', error);
set({
@@ -127,37 +172,42 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
fetchPrincipal: async () => {
set({ isLoadingPrincipal: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/principal', {
headers: getApiHeaders(),
});
if (!response.ok) {
if (response.status === 403) {
// User lacks permission to read principal (e.g. non-admin); treat as empty
const accountId = getPrimaryAccountId();
const responses = await stalwartJmap([
['x:Account/get', { accountId, ids: [accountId] }, '0'],
]);
const result = requireResult<{
list: Array<{
description?: string | null;
aliases?: Record<string, { name?: string; domainId?: string; enabled?: boolean }>;
quotas?: { maxDiskQuota?: number };
roles?: { ['@type']?: string };
name?: string;
domainId?: string;
}>;
}>(responses, 'x:Account/get');
const acc = result.list?.[0];
const aliasAddresses = acc?.aliases
? Object.values(acc.aliases)
.filter((a) => a?.enabled !== false && a?.name)
.map((a) => a?.name!)
: [];
const primaryEmail = acc?.name ? [acc.name] : [];
set({
displayName: '',
emails: [],
quota: 0,
roles: [],
isLoadingPrincipal: false,
});
return;
}
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const principal = data.data;
set({
displayName: principal?.description ?? '',
emails: Array.isArray(principal?.emails) ? principal.emails : principal?.emails ? [principal.emails] : [],
quota: principal?.quota ?? 0,
roles: principal?.roles ?? [],
displayName: acc?.description ?? '',
emails: [...primaryEmail, ...aliasAddresses],
quota: acc?.quotas?.maxDiskQuota ?? 0,
roles: acc?.roles?.['@type'] ? [acc.roles['@type']] : [],
isLoadingPrincipal: false,
});
} catch (error) {
debug.error('Failed to fetch principal:', error);
const msg = error instanceof Error ? error.message : 'Failed to fetch principal';
const isForbidden = msg.toLowerCase().includes('forbidden');
set({
isLoadingPrincipal: false,
error: error instanceof Error ? error.message : 'Failed to fetch principal',
error: isForbidden ? null : msg,
});
}
},
@@ -170,17 +220,17 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
changePassword: async (currentPassword, newPassword) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/password', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `HTTP ${response.status}`);
}
const accountId = getPrimaryAccountId();
await stalwartJmap([
[
'x:AccountPassword/set',
{
accountId,
update: { singleton: { currentSecret: currentPassword, secret: newPassword } },
},
'0',
],
]);
set({ isSaving: false });
} catch (error) {
set({
@@ -194,19 +244,14 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
updateDisplayName: async (displayName) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/principal', {
method: 'PATCH',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([
{ action: 'set', field: 'description', value: displayName },
]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `HTTP ${response.status}`);
}
const accountId = getPrimaryAccountId();
await stalwartJmap([
[
'x:AccountSettings/set',
{ accountId, update: { singleton: { description: displayName } } },
'0',
],
]);
set({ displayName, isSaving: false });
} catch (error) {
set({
@@ -217,23 +262,26 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
}
},
enableTotp: async () => {
enableTotp: async (currentPassword, otpUrl, otpCode) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'enableOtpAuth' }]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
const data = await response.json();
const accountId = getPrimaryAccountId();
await stalwartJmap([
[
'x:AccountPassword/set',
{
accountId,
update: {
singleton: {
currentSecret: currentPassword,
otpAuth: { otpUrl, otpCode },
},
},
},
'0',
],
]);
set({ otpEnabled: true, isSaving: false });
return data.data;
} catch (error) {
set({
isSaving: false,
@@ -243,20 +291,25 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
}
},
disableTotp: async () => {
disableTotp: async (currentPassword) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'disableOtpAuth' }]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
const accountId = getPrimaryAccountId();
await stalwartJmap([
[
'x:AccountPassword/set',
{
accountId,
update: {
singleton: {
currentSecret: currentPassword,
otpAuth: { otpUrl: null },
},
},
},
'0',
],
]);
set({ otpEnabled: false, isSaving: false });
} catch (error) {
set({
@@ -267,47 +320,59 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
}
},
addAppPassword: async (name, password) => {
createAppPassword: async (description, expiresAt) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
});
const accountId = getPrimaryAccountId();
const tmpId = 'new';
const responses = await stalwartJmap([
[
'x:AppPassword/set',
{
accountId,
create: {
[tmpId]: {
description,
...(expiresAt ? { expiresAt } : {}),
},
},
},
'0',
],
]);
const result = requireResult<{
created?: Record<string, { id: string; secret: string; createdAt?: string }>;
notCreated?: Record<string, { type: string; description?: string }>;
}>(responses, 'x:AppPassword/set');
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
const notCreated = result.notCreated?.[tmpId];
if (notCreated) {
throw new Error(notCreated.description || notCreated.type || 'Failed to create app password');
}
const created = result.created?.[tmpId];
if (!created?.id || !created.secret) {
throw new Error('Server did not return created app password');
}
// Refresh auth info to get updated app passwords list
await get().fetchAuthInfo();
set({ isSaving: false });
return { id: created.id, secret: created.secret };
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to add app password',
error: error instanceof Error ? error.message : 'Failed to create app password',
});
throw error;
}
},
removeAppPassword: async (name) => {
removeAppPassword: async (id) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'removeAppPassword', name }]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
// Refresh auth info to get updated app passwords list
const accountId = getPrimaryAccountId();
await stalwartJmap([
['x:AppPassword/set', { accountId, destroy: [id] }, '0'],
]);
await get().fetchAuthInfo();
set({ isSaving: false });
} catch (error) {
@@ -319,37 +384,13 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
}
},
updateEncryption: async (settings) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/crypto', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
set({ encryptionType: settings.type, isSaving: false });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to update encryption',
});
throw error;
}
},
clearState: () => set({
isStalwart: null,
isProbing: false,
otpEnabled: false,
appPasswords: [],
isLoadingAuth: false,
encryptionType: 'disabled',
encryptionType: 'Disabled',
isLoadingCrypto: false,
displayName: '',
emails: [],
-64
View File
@@ -29,7 +29,6 @@ interface FilterStore {
toggleRule: (ruleId: string) => void;
setRawScript: (content: string) => void;
resetToVisualBuilder: () => void;
syncVacationToScript: (client: IJMAPClient, vacation: VacationSieveConfig) => Promise<void>;
clearState: () => void;
}
@@ -193,69 +192,6 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [], externalRequires: [] }),
syncVacationToScript: async (client, vacation) => {
try {
// Preserve current rules before re-fetching, since the server
// may have overwritten our script with a vacation-only one.
const { rules: previousRules } = get();
// Always re-fetch scripts from the server to get the current state
// after Stalwart may have rewritten the active script.
const allScripts = await client.getSieveScripts();
// Skip the server-managed 'vacation' script (RFC 9661 §4)
const scripts = allScripts.filter(s => s.name !== 'vacation');
const activeScript = scripts.find(s => s.isActive) || scripts[0];
let rules = previousRules;
let externalRequires = get().externalRequires;
// If there's an active script, try to parse our metadata from it.
// If the server overwrote it (no metadata), fall back to stored rules.
if (activeScript) {
const content = await client.getSieveScriptContent(activeScript.blobId);
const parsed = parseScript(content);
if (!parsed.isOpaque) {
rules = parsed.rules;
externalRequires = parsed.externalRequires;
}
}
// Generate a combined script with our metadata, rules, and vacation
const content = generateScript(rules, vacation.isEnabled ? vacation : undefined, { externalRequires });
if (activeScript) {
// Preserve the script's current activation state - don't pass activate: true
// unconditionally, as that would deactivate the server-managed 'vacation'
// script and cause VacationResponse/get to return isEnabled: false.
await client.updateSieveScript(activeScript.id, content, activeScript.isActive);
set({
activeScriptId: activeScript.id,
rawScript: content,
rules,
vacationSettings: vacation,
isOpaque: false,
externalRequires,
});
} else {
// Don't activate; there may be a server-managed 'vacation' script active.
// The filters script will be activated when the user saves filters normally.
const script = await client.createSieveScript('filters', content, false);
set({
activeScriptId: script.id,
rawScript: content,
rules,
vacationSettings: vacation,
isOpaque: false,
externalRequires,
});
}
debug.log('filters', 'Vacation synced to sieve script');
} catch (error) {
debug.error('Failed to sync vacation to sieve script:', error);
}
},
clearState: () => set({
rules: [],
isLoading: false,