diff --git a/.env.dev.example b/.env.dev.example index d26b9f14..2c876a01 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -15,9 +15,15 @@ DEV_MOCK_JMAP=true # Point the app at its own mock endpoint. -# IMPORTANT: This must match the origin the app runs on (default: port 3000). -# Using a different port (e.g. 3001) will cause CORS errors. -JMAP_SERVER_URL=/api/dev-jmap +# IMPORTANT: must be an ABSOLUTE URL matching the origin the app runs on +# (default: port 3000) - NOT a relative path. A relative path here makes +# /api/auth/stalwart-context 400 on every request (resolveTrustedJmapUrl +# rejects it), which silently breaks the real server-side session-cookie +# flow that S/MIME enrollment, offline sync, and the AI server/retrieval +# routes all depend on. The client-side mock fetch works either way, which +# is why this is easy to miss - it only bites features needing a real +# server-side session identity. +JMAP_SERVER_URL=http://localhost:3000/api/dev-jmap # ============================================================================= # App diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 7f94dc6b..3735c161 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -34,6 +34,7 @@ import { Bug, SwatchBook, Download, + Sparkles, X, type LucideIcon, } from 'lucide-react'; @@ -66,6 +67,7 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings import { NotificationSettings } from '@/components/settings/notification-settings'; import { ThemesSettings } from '@/components/settings/themes-settings'; import { PluginsSettings } from '@/components/settings/plugins-settings'; +import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings'; import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings'; @@ -111,6 +113,7 @@ type Tab = | 'about_data' | 'themes' | 'plugins' + | 'ai_assistant' | 'debug'; type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced'; @@ -153,6 +156,7 @@ const tabIcons: Record = { about_data: Info, themes: SwatchBook, plugins: Puzzle, + ai_assistant: Sparkles, debug: Bug, }; @@ -234,6 +238,7 @@ const tabSearchPaths: Record = { about_data: ['settings.advanced'], themes: [], plugins: [], + ai_assistant: [], debug: ['settings.advanced'], }; @@ -264,6 +269,7 @@ const tabKeywords: Record = { about_data: 'export import storage quota privacy backup', themes: 'custom theme css skin appearance', plugins: 'extensions addons', + ai_assistant: 'assistant ask model llm ollama chatbot', debug: 'logs developer console diagnostic', }; @@ -652,6 +658,7 @@ export default function SettingsPage() { // Advanced { id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' }, ...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []), + ...(isFeatureEnabled('aiAssistantEnabled') ? [{ id: 'ai_assistant' as Tab, label: 'AI Assistant', icon: tabIcons.ai_assistant, group: 'advanced' as TabGroup }] : []), ...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []), ]; @@ -777,6 +784,7 @@ export default function SettingsPage() { {effectiveActiveTab === 'about_data' && } {effectiveActiveTab === 'themes' && } {effectiveActiveTab === 'plugins' && } + {effectiveActiveTab === 'ai_assistant' && } {effectiveActiveTab === 'debug' && } {effectiveActiveTab.startsWith('plugin:') && ( MAX_QUERY_CHARS) { + return NextResponse.json({ error: 'query too long' }, { status: 400 }); + } + const limit = typeof body.limit === 'number' ? Math.min(Math.max(Math.trunc(body.limit), 1), 20) : DEFAULT_LIMIT; + + try { + const scored = await serverSearchMail(auth.serverUrl, auth.authHeader, query, limit); + const chunks = await hydrateMailRefs(auth.serverUrl, auth.authHeader, scored.map((s) => s.ref)); + + const contextBlock = chunks + .map((c, i) => `[${i + 1}] Subject: ${c.title}\n${c.text}`) + .join('\n\n'); + + return NextResponse.json({ + ok: true, + hits: chunks.map((c, i) => ({ ref: c.ref, title: c.title, snippet: c.text.slice(0, 200), rank: i + 1 })), + contextBlock, + }, { headers: { 'Cache-Control': 'no-store' } }); + } catch (cause) { + logger.error('ai retrieve failed', { error: cause instanceof Error ? cause.message : String(cause) }); + return NextResponse.json({ error: 'retrieval unavailable' }, { status: 502 }); + } +} diff --git a/app/api/ai/server/chat/route.ts b/app/api/ai/server/chat/route.ts new file mode 100644 index 00000000..614bd5a6 --- /dev/null +++ b/app/api/ai/server/chat/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement'; +import { logger } from '@/lib/logger'; + +export const runtime = 'nodejs'; + +const MAX_BODY_BYTES = 200 * 1024; + +interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +interface OllamaChatResponse { + message?: { content?: string }; + prompt_eval_count?: number; + eval_count?: number; +} + +/** + * POST /api/ai/server/chat — the one real enforcement chokepoint for the + * `server` AI class (docs/AI-ASSISTANT-CONCEPT.md §10 point 2: "re-validates + * ... entitlement against live state; rejects on mismatch ... never trusts + * the client"). Every call re-checks the seat; nothing here is cosmetic. + * + * Retrieval already happened client-side (the same /api/offline/search leg + * `local`/`public` use) — this route receives the already-built prompt + * messages and only proxies the model call + records the metering entry + * that IS the billing record (lib/ai/entitlement.ts). + */ +export async function POST(request: NextRequest) { + const auth = await getStalwartCredentials(request); + if (!auth) { + return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); + } + + const seat = await checkAndAssignSeat(auth.username); + if (!seat.allowed) { + return NextResponse.json({ error: seat.reason ?? 'not entitled' }, { status: 402 }); + } + + const rawBody = await request.text(); + if (rawBody.length > MAX_BODY_BYTES) { + return NextResponse.json({ error: 'request too large' }, { status: 413 }); + } + + let body: { model?: unknown; messages?: unknown }; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }); + } + + const model = typeof body.model === 'string' ? body.model : ''; + const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null; + if (!model || !messages || messages.length === 0) { + return NextResponse.json({ error: 'model and messages are required' }, { status: 400 }); + } + + const baseUrl = process.env.AI_SERVER_BASE_URL; + if (!baseUrl) { + return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 }); + } + + const startedAt = Date.now(); + try { + const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages, stream: false }), + }); + if (!res.ok) { + return NextResponse.json({ error: `AI server returned ${res.status}` }, { status: 502 }); + } + const data = (await res.json()) as OllamaChatResponse; + const content = data.message?.content; + if (!content) { + return NextResponse.json({ error: 'AI server returned no message content' }, { status: 502 }); + } + + await recordUsage({ + timestamp: new Date().toISOString(), + username: auth.username, + model, + promptTokens: data.prompt_eval_count ?? 0, + completionTokens: data.eval_count ?? 0, + latencyMs: Date.now() - startedAt, + }); + + return NextResponse.json({ answer: content, seatJustAssigned: seat.seatJustAssigned === true }); + } catch (cause) { + logger.error('ai server chat failed', { error: cause instanceof Error ? cause.message : String(cause) }); + return NextResponse.json({ error: 'AI server unreachable' }, { status: 502 }); + } +} diff --git a/app/api/ai/server/models/route.ts b/app/api/ai/server/models/route.ts new file mode 100644 index 00000000..4e4ee897 --- /dev/null +++ b/app/api/ai/server/models/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; + +export const runtime = 'nodejs'; + +/** + * GET /api/ai/server/models — list models on the centrally-hosted `server` + * class runtime (docs/AI-ASSISTANT-CONCEPT.md §2.1: "the same self-hosted + * open-weight model stack as `local`... running on VNC's own infrastructure + * instead of the user's laptop"). Tonight, `AI_SERVER_BASE_URL` stands in for + * that infra with the Ollama already running on this developer's Mac — see + * the module comment in lib/ai/entitlement.ts. Swapping to the real + * EU/CH-hosted instance tomorrow is a config change, not a rewrite. + * + * Listing models is not a billable action (doc §10 point 1 — cosmetic), so + * this only requires a valid session, not a seat. + */ +export async function GET(request: NextRequest) { + const auth = await getStalwartCredentials(request); + if (!auth) { + return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); + } + + const baseUrl = process.env.AI_SERVER_BASE_URL; + if (!baseUrl) { + return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 }); + } + + try { + const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`); + if (!res.ok) { + return NextResponse.json({ error: `upstream returned ${res.status}` }, { status: 502 }); + } + const body = (await res.json()) as { models?: Array<{ name: string; capabilities?: string[] }> }; + // Excludes embedding-only models (e.g. nomic-embed-text, used by + // lib/ai/retrieval/mail-embeddings.ts) from the *chat* picker — Ollama + // lists them in the same /api/tags response, but calling /api/chat with + // one fails outright. `capabilities` absent (older Ollama) fails open + // rather than hiding every model on an upgrade. + const chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion')); + return NextResponse.json({ models: chatModels.map((m) => m.name).filter(Boolean) }); + } catch (cause) { + return NextResponse.json( + { error: cause instanceof Error ? cause.message : 'AI server unreachable' }, + { status: 502 }, + ); + } +} diff --git a/app/manifest.ts b/app/manifest.ts index 4e878bdd..5acd577f 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -46,7 +46,7 @@ export default async function manifest(): Promise { const appName = branded("appName", "") || process.env.NEXT_PUBLIC_APP_NAME || - "Bulwark Webmail"; + "VNCmail+"; const shortName = branded("appShortName", "") || appName; const description = diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index bbdc0a76..61e8105a 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -9,6 +9,7 @@ import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourc import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { withBasePath } from "@/lib/browser-navigation"; +import { resolveThemeLogo } from "@/lib/theme-logo"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; @@ -766,6 +767,8 @@ export function EmailViewer({ return new Date(time).toISOString(); }, [client, t, tComposer]); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); + const activeThemeId = useThemeStore((state) => state.activeThemeId); + const installedThemes = useThemeStore((state) => state.installedThemes); const { startTour } = useTour(); const isEmbedded = useIsEmbedded(); const [showFullHeaders, setShowFullHeaders] = useState(false); @@ -2729,15 +2732,20 @@ export function EmailViewer({ if (!email) { if (isDemoMode) { - const logoSrc = withBasePath(resolvedTheme === 'dark' - ? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg' - : '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg'); + // Same resolution as navigation-rail.tsx/login: active theme's own + // brand logo (SRC mark / VNClagoon wordmark), falling back to the SRC + // mark rather than a hardcoded brand image - this demo empty state has + // no admin-override concept of its own, so there's no global override + // to check here. + const logoSrc = withBasePath( + resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', '/branding/SRC_Symbol.png', '/branding/SRC_Symbol.png'), + ); return (
Bulwark Mail

{tDemoWelcome('title')}

diff --git a/components/settings/ai-assistant-settings.tsx b/components/settings/ai-assistant-settings.tsx new file mode 100644 index 00000000..fcff1322 --- /dev/null +++ b/components/settings/ai-assistant-settings.tsx @@ -0,0 +1,456 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { RefreshCw, CheckCircle, AlertTriangle, Loader2, Plus, Trash2 } from 'lucide-react'; +import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section'; +import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/lib/browser-navigation'; +import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types'; +import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities'; +import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store'; +import { loadAiSettings, saveAiSettings, createProfile, type AiLocalSettings } from '@/lib/ai/local-settings'; +import { + askMail, + listLocalModels, + listServerModels, + testLocalConnection, + type AskResult, +} from '@/lib/ai/local-client'; + +const inputClass = + 'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 flex-1 min-w-[220px]'; + +/** + * Decisions recorded 2026-08-05 (see lib/ai/types.ts, lib/ai/entitlement.ts): + * `local` (loopback Ollama) ships free, no entitlement check. `server` + * (centrally-hosted, proxied through this app's own backend) is real and + * entitlement-enforced — every call re-checks a licensed seat server-side. + * `public` (BYOK) supports several named provider profiles, picked case by + * case per question, and is explicitly unmonitored for now. + */ +export function AiAssistantSettings() { + const [policy, setPolicy] = useState(DEFAULT_AI_POLICY); + const [policyLoading, setPolicyLoading] = useState(true); + const [settings, setSettings] = useState(() => loadAiSettings()); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await apiFetch('/api/ai/policy'); + if (res.ok && !cancelled) setPolicy(await res.json()); + } finally { + if (!cancelled) setPolicyLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const update = useCallback((key: K, value: AiLocalSettings[K]) => { + setSettings((prev) => { + const next = { ...prev, [key]: value }; + saveAiSettings(next); + return next; + }); + }, []); + + const canUseLocal = supportsLocalLlm() && policy.entitlement.classes.includes('local'); + const canUseServer = policy.entitlement.classes.includes('server'); + const canUsePublic = policy.entitlement.classes.includes('public'); + + // ── Local provider ── + const [localModels, setLocalModels] = useState([]); + const [refreshingLocal, setRefreshingLocal] = useState(false); + const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle'); + const [testError, setTestError] = useState(null); + + const refreshLocalModels = useCallback(async () => { + setRefreshingLocal(true); + try { + const models = await listLocalModels(settings.localBaseUrl); + setLocalModels(models); + if (!settings.localModel && models[0]) update('localModel', models[0]); + } catch { + setLocalModels([]); + } finally { + setRefreshingLocal(false); + } + }, [settings.localBaseUrl, settings.localModel, update]); + + const runTestConnection = useCallback(async () => { + setTestStatus('testing'); + setTestError(null); + const result = await testLocalConnection(settings.localBaseUrl); + if (result.ok) { + setTestStatus('ok'); + } else { + setTestStatus('error'); + setTestError(result.error ?? 'Connection failed'); + } + }, [settings.localBaseUrl]); + + // ── Server provider ── + const [serverModels, setServerModels] = useState([]); + const [refreshingServer, setRefreshingServer] = useState(false); + const [serverError, setServerError] = useState(null); + const [seatNotice, setSeatNotice] = useState(null); + + const refreshServerModels = useCallback(async () => { + setRefreshingServer(true); + setServerError(null); + try { + const models = await listServerModels(); + setServerModels(models); + if (!settings.serverModel && models[0]) update('serverModel', models[0]); + } catch (err) { + setServerModels([]); + setServerError(err instanceof Error ? err.message : String(err)); + } finally { + setRefreshingServer(false); + } + }, [settings.serverModel, update]); + + // ── Public provider — several named profiles, one picked per question ── + const [newProfileName, setNewProfileName] = useState(''); + const [newProfileBaseUrl, setNewProfileBaseUrl] = useState('https://openrouter.ai/api/v1'); + const [newProfileModel, setNewProfileModel] = useState(''); + const [newProfileKey, setNewProfileKey] = useState(''); + + const addProfile = useCallback(() => { + if (!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey) return; + const profile = createProfile(newProfileName, newProfileBaseUrl, newProfileModel); + setAiApiKey(profile.id, newProfileKey); + update('publicProfiles', [...settings.publicProfiles, profile]); + if (!settings.activeProfileId) update('activeProfileId', profile.id); + setNewProfileName(''); + setNewProfileBaseUrl('https://openrouter.ai/api/v1'); + setNewProfileModel(''); + setNewProfileKey(''); + }, [newProfileName, newProfileBaseUrl, newProfileModel, newProfileKey, settings.publicProfiles, settings.activeProfileId, update]); + + const removeProfile = useCallback( + (id: string) => { + clearAiApiKey(id); + const remaining = settings.publicProfiles.filter((p) => p.id !== id); + update('publicProfiles', remaining); + if (settings.activeProfileId === id) update('activeProfileId', remaining[0]?.id ?? null); + }, + [settings.publicProfiles, settings.activeProfileId, update], + ); + + // ── Ask ── + const [question, setQuestion] = useState(''); + const [asking, setAsking] = useState(false); + const [askResult, setAskResult] = useState(null); + const [askError, setAskError] = useState(null); + + const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; + + const canAsk = + question.trim().length > 0 && + (settings.provider === 'local' + ? canUseLocal && !!settings.localModel + : settings.provider === 'server' + ? canUseServer && !!settings.serverModel + : settings.provider === 'public' + ? canUsePublic && !!activeProfile && settings.publicConsentAccepted + : false); + + const runAsk = useCallback(async () => { + setAsking(true); + setAskError(null); + setAskResult(null); + setSeatNotice(null); + try { + const key = activeProfile ? getAiApiKey(activeProfile.id) : null; + const result = await askMail(question.trim(), { + provider: settings.provider as 'local' | 'server' | 'public', + localBaseUrl: settings.localBaseUrl, + localModel: settings.localModel, + serverModel: settings.serverModel, + publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null, + }); + setAskResult(result); + if (result.seatJustAssigned) { + setSeatNotice('A licensed seat on the server-hosted class was just assigned to your account.'); + } + } catch (err) { + setAskError(err instanceof Error ? err.message : String(err)); + } finally { + setAsking(false); + } + }, [question, settings, activeProfile]); + + const providerOptions = useMemo( + () => [ + ...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []), + ...(canUseServer ? [{ value: 'server', label: 'Server (VNC-hosted)' }] : []), + ...(canUsePublic ? [{ value: 'public', label: 'Public (your API keys)' }] : []), + ], + [canUseLocal, canUseServer, canUsePublic], + ); + + if (policyLoading) { + return ( +
+ Loading… +
+ ); + } + + return ( +
+ + + {providerOptions.length > 0 ? ( + update('provider', v as 'local' | 'server' | 'public')} + options={providerOptions} + /> + ) : ( + No provider class available. + )} + + + + {settings.provider === 'local' && canUseLocal && ( + + + update('localBaseUrl', e.target.value)} + spellCheck={false} + className={inputClass} + /> + + +
+ {localModels.length > 0 ? ( + update('serverModel', v)} + options={serverModels.map((m) => ({ value: m, label: m }))} + /> + ) : ( + {settings.serverModel || 'None selected'} + )} + +
+
+ {serverError && ( + + + {serverError} + + + )} +
+ )} + + {settings.provider === 'public' && canUsePublic && ( + + {settings.publicProfiles.length > 0 && ( + +
+ {settings.publicProfiles.map((p) => ( +
+
+

{p.name}

+

{p.model} · {p.baseUrl}

+
+ +
+ ))} +
+
+ )} + +
+
+ setNewProfileName(e.target.value)} + placeholder="Name, e.g. Claude via OpenRouter" + spellCheck={false} + className={inputClass} + /> + setNewProfileModel(e.target.value)} + placeholder="Model, e.g. anthropic/claude-sonnet-4.5" + spellCheck={false} + className={inputClass} + /> +
+
+ setNewProfileBaseUrl(e.target.value)} + placeholder="Base URL" + spellCheck={false} + className={inputClass} + /> + setNewProfileKey(e.target.value)} + placeholder="sk-..." + spellCheck={false} + className={inputClass} + /> + +
+
+
+ + update('publicConsentAccepted', v)} + /> + +
+ )} + + {settings.provider && ( + +
+ {settings.provider === 'public' && settings.publicProfiles.length > 0 && ( + +