diff --git a/app/api/account/stalwart/auth/route.ts b/app/api/account/stalwart/auth/route.ts deleted file mode 100644 index 4fa0f4d0..00000000 --- a/app/api/account/stalwart/auth/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/account/stalwart/crypto/route.ts b/app/api/account/stalwart/crypto/route.ts deleted file mode 100644 index 07681115..00000000 --- a/app/api/account/stalwart/crypto/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/account/stalwart/jmap/route.ts b/app/api/account/stalwart/jmap/route.ts new file mode 100644 index 00000000..7f292c17 --- /dev/null +++ b/app/api/account/stalwart/jmap/route.ts @@ -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 }); + } +} diff --git a/app/api/account/stalwart/password/route.ts b/app/api/account/stalwart/password/route.ts deleted file mode 100644 index e229e63f..00000000 --- a/app/api/account/stalwart/password/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/account/stalwart/principal/route.ts b/app/api/account/stalwart/principal/route.ts deleted file mode 100644 index 8801c2b6..00000000 --- a/app/api/account/stalwart/principal/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/account/stalwart/probe/route.ts b/app/api/account/stalwart/probe/route.ts deleted file mode 100644 index e7dbe525..00000000 --- a/app/api/account/stalwart/probe/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 803edd08..3ba03af6 100644 --- a/app/api/admin/auth/route.ts +++ b/app/api/admin/auth/route.ts @@ -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 { 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) { diff --git a/app/api/admin/stalwart-check/route.ts b/app/api/admin/stalwart-check/route.ts deleted file mode 100644 index 4c2c3d6a..00000000 --- a/app/api/admin/stalwart-check/route.ts +++ /dev/null @@ -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' }, - }); - } -} diff --git a/app/api/webdav/route.ts b/app/api/webdav/route.ts index 85cbbac7..acc19542 100644 --- a/app/api/webdav/route.ts +++ b/app/api/webdav/route.ts @@ -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 diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 20062155..eeeccbba 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -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) { - setIsStalwartAdmin(true); + 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', diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index 5f1624d4..0f29068c 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -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(null); - const [copied, setCopied] = useState(false); + const { client } = useAuthStore(); + + const [setupUrl, setSetupUrl] = useState(null); + const [setupTotp, setSetupTotp] = useState(null); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [password, setPassword] = useState(''); + const [otpCode, setOtpCode] = useState(''); + const [setupError, setSetupError] = useState(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; + } - 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')); - } + 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() {
- {isSaving ? ( - - ) : ( - - )} + {otpEnabled ? t('totp.active') : t('totp.inactive')}
- {totpUrl && ( -
+ {setupUrl && ( +

{t('totp.setup_instructions')}

+ {qrDataUrl && ( +
+ { /* eslint-disable-next-line @next/next/no-img-element */ } + TOTP QR code +
+ )}
- - {totpUrl} - -
+
+ + setPassword(e.target.value)} autoComplete="current-password" /> +
+
+ + setOtpCode(e.target.value)} inputMode="numeric" maxLength={6} /> +
+ {setupError &&

{setupError}

} +
+ + +
+
+ )} + + {disableOpen && ( +
+

{t('totp.disable_confirm_prompt')}

+ setPassword(e.target.value)} + placeholder={t('password.current')} + autoComplete="current-password" + /> + {setupError &&

{setupError}

} +
+ +
@@ -250,36 +346,52 @@ function TotpSection() { ); } +function AppPasswordRow({ password, onRemove, isSaving }: { password: AppPasswordInfo; onRemove: (id: string) => void; isSaving: boolean }) { + return ( +
+
+ {password.description || password.id} + {password.createdAt && ( + + {new Date(password.createdAt).toLocaleDateString()} + {password.expiresAt ? ` · expires ${new Date(password.expiresAt).toLocaleDateString()}` : ''} + + )} +
+ +
+ ); +} + 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(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 (
@@ -322,43 +442,40 @@ function AppPasswordsSection() {

{t('app_passwords.description')}

+ {createdSecret && ( +
+

{t('app_passwords.copy_now_warning')}

+
+ + {createdSecret} + + +
+ +
+ )} + {showAdd && (
setNewName(e.target.value)} + value={newDescription} + onChange={(e) => setNewDescription(e.target.value)} placeholder={t('app_passwords.name_placeholder')} required />
- -
-
- setNewPassword(e.target.value)} - placeholder={t('app_passwords.password_placeholder')} - className="pr-10" - /> - -
- -
+ + setExpiresAt(e.target.value)} />
- @@ -371,19 +488,8 @@ function AppPasswordsSection() { {appPasswords.length > 0 ? (
- {appPasswords.map((name) => ( -
- {name} - -
+ {appPasswords.map((p) => ( + ))}
) : ( @@ -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 ( -
- {isSaving ? ( - - ) : ( - - )} - - {isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')} - -
+ + {isEnabled ? t('encryption.active', { type: encryptionType }) : t('encryption.inactive')} +
); } @@ -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(() => { diff --git a/components/settings/vacation-settings.tsx b/components/settings/vacation-settings.tsx index d5df9913..19dd248b 100644 --- a/components/settings/vacation-settings.tsx +++ b/components/settings/vacation-settings.tsx @@ -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); diff --git a/lib/__tests__/jmap-passthrough.test.ts b/lib/__tests__/jmap-passthrough.test.ts new file mode 100644 index 00000000..5f89fd46 --- /dev/null +++ b/lib/__tests__/jmap-passthrough.test.ts @@ -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; + +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]> = [ + ['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]> = [ + ['x:Account/get', {}, '0'], + ]; + + expect(() => requireResult(responses, 'x:AppPassword/query')).toThrow(/x:AppPassword\/query/); + }); +}); diff --git a/lib/__tests__/stalwart-client.test.ts b/lib/__tests__/stalwart-client.test.ts deleted file mode 100644 index 2cf9d62d..00000000 --- a/lib/__tests__/stalwart-client.test.ts +++ /dev/null @@ -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; - 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'); - }); - }); -}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 6a09f19e..ad25b307 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -49,6 +49,10 @@ export class DemoJMAPClient implements IJMAPClient { // ── Capabilities ────────────────────────────────────────────── + hasAccountCapability(_capability: string, _accountId?: string): boolean { + return false; + } + getCapabilities(): Record { return { 'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 }, diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 34dc54d7..916b7d8b 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -27,6 +27,7 @@ export interface IJMAPClient { // ── Capabilities ────────────────────────────────────────────── getCapabilities(): Record; + hasAccountCapability(capability: string, accountId?: string): boolean; getMaxSizeUpload(): number; getMaxCallsInRequest(): number; getMaxObjectsInGet(): number; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index dc1f57ca..ac90a057 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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; diff --git a/lib/stalwart/client.ts b/lib/stalwart/client.ts deleted file mode 100644 index 9762b539..00000000 --- a/lib/stalwart/client.ts +++ /dev/null @@ -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(path: string, init?: RequestInit): Promise { - 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 { - 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 { - 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 { - 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 { - const result = await this.request<{ data: string }>('/account/auth', { - method: 'POST', - body: JSON.stringify([{ type: 'enableOtpAuth' }]), - }); - return result.data; - } - - /** Disable TOTP */ - async disableTotp(): Promise { - 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 { - 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 { - 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 { - 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 { - await this.request<{ data: unknown }>('/account/crypto', { - method: 'POST', - body: JSON.stringify(settings), - }); - } - - /** GET /principal/{name} - Fetch principal details */ - async getPrincipal(name: string): Promise { - 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 { - 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 { - await this.updatePrincipal(name, [ - { action: 'set', field: 'secrets', value: newPassword }, - ]); - } - - /** Update display name via PATCH /principal/{name} */ - async updateDisplayName(name: string, displayName: string): Promise { - await this.updatePrincipal(name, [ - { action: 'set', field: 'description', value: displayName }, - ]); - } -} diff --git a/lib/stalwart/credentials.ts b/lib/stalwart/credentials.ts index a4ffc0b4..c84cd2c0 100644 --- a/lib/stalwart/credentials.ts +++ b/lib/stalwart/credentials.ts @@ -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, string]; +export type JmapMethodResponse = [string, Record, 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 { + 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>( + 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; +} diff --git a/package-lock.json b/package-lock.json index 891b0ff2..06bc5841 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c54bcf00..8c2fb66d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/stores/__tests__/account-security-store.test.ts b/stores/__tests__/account-security-store.test.ts index e1718730..c7915c5c 100644 --- a/stores/__tests__/account-security-store.test.ts +++ b/stores/__tests__/account-security-store.test.ts @@ -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: (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; + +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; - +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' }]); + 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' }, + }); }); - 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); - }); - }); - - 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(); }); }); diff --git a/stores/account-security-store.ts b/stores/account-security-store.ts index b811cfe9..14cd096f 100644 --- a/stores/account-security-store.ts +++ b/stores/account-security-store.ts @@ -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; fetchAuthInfo: () => Promise; fetchCryptoInfo: () => Promise; fetchPrincipal: () => Promise; fetchAll: () => Promise; + changePassword: (currentPassword: string, newPassword: string) => Promise; updateDisplayName: (displayName: string) => Promise; - enableTotp: () => Promise; - disableTotp: () => Promise; - addAppPassword: (name: string, password: string) => Promise; - removeAppPassword: (name: string) => Promise; - updateEncryption: (settings: { type: string; algo?: string; certs?: string }) => Promise; + + enableTotp: (currentPassword: string, otpUrl: string, otpCode: string) => Promise; + disableTotp: (currentPassword: string) => Promise; + + createAppPassword: (description: string, expiresAt?: string | null) => Promise<{ id: string; secret: string }>; + removeAppPassword: (id: string) => Promise; + clearState: () => void; } -function getApiHeaders(): Record { - return getActiveAccountSlotHeaders(); +function getPrimaryAccountId(): string { + const client = useAuthStore.getState().client; + if (!client) throw new Error('Not authenticated'); + return client.getAccountId(); +} + +function appPasswordFromResult(raw: Record): AppPasswordInfo { + const allowedIps = raw.allowedIps && typeof raw.allowedIps === 'object' + ? Object.keys(raw.allowedIps as Record) + : []; + 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()((set, get) => ({ @@ -54,7 +86,7 @@ export const useAccountSecurityStore = create()((set, get) otpEnabled: false, appPasswords: [], isLoadingAuth: false, - encryptionType: 'disabled', + encryptionType: 'Disabled', isLoadingCrypto: false, displayName: '', emails: [], @@ -67,11 +99,8 @@ export const useAccountSecurityStore = create()((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()((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> }>(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()((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()((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 - set({ - displayName: '', - emails: [], - quota: 0, - roles: [], - isLoadingPrincipal: false, - }); - return; - } - throw new Error(`HTTP ${response.status}`); - } - const data = await response.json(); - const principal = data.data; + const accountId = getPrimaryAccountId(); + const responses = await stalwartJmap([ + ['x:Account/get', { accountId, ids: [accountId] }, '0'], + ]); + const result = requireResult<{ + list: Array<{ + description?: string | null; + aliases?: Record; + 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: 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()((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()((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()((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()((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()((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; + notCreated?: Record; + }>(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()((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: [], diff --git a/stores/filter-store.ts b/stores/filter-store.ts index 50c312fc..ec3eec32 100644 --- a/stores/filter-store.ts +++ b/stores/filter-store.ts @@ -29,7 +29,6 @@ interface FilterStore { toggleRule: (ruleId: string) => void; setRawScript: (content: string) => void; resetToVisualBuilder: () => void; - syncVacationToScript: (client: IJMAPClient, vacation: VacationSieveConfig) => Promise; clearState: () => void; } @@ -193,69 +192,6 @@ export const useFilterStore = create()((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,