promote: AI (BYOK/server/entitlement/retrieval), S/MIME (web CA + mobile full stack), 2-theme rebrand (dev→main)

This commit is contained in:
Bernd Rodler
2026-08-06 08:27:31 +02:00
63 changed files with 3309 additions and 451 deletions
+9 -3
View File
@@ -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
+8
View File
@@ -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<Tab, LucideIcon> = {
about_data: Info,
themes: SwatchBook,
plugins: Puzzle,
ai_assistant: Sparkles,
debug: Bug,
};
@@ -234,6 +238,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
about_data: ['settings.advanced'],
themes: [],
plugins: [],
ai_assistant: [],
debug: ['settings.advanced'],
};
@@ -264,6 +269,7 @@ const tabKeywords: Record<Tab, string> = {
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' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'ai_assistant' && <AiAssistantSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
{effectiveActiveTab.startsWith('plugin:') && (
<PluginIframeSlot
+1
View File
@@ -28,6 +28,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
aiAssistantEnabled: { label: 'AI Assistant (preview)', description: 'Show the AI Assistant settings tab. Local (Ollama on the user\'s own machine or this desktop app) is free and unmetered; public (bring-your-own-key) is available too but not yet monitored or metered — see docs/AI-ASSISTANT-CONCEPT.md.' },
};
const RESTRICTABLE_SETTINGS = [
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { getEntitlementState, setSeatTotal, revokeSeat, readMeteringLedger } from '@/lib/ai/entitlement';
export const runtime = 'nodejs';
/**
* Admin-only data endpoints for the `server` AI class's real entitlement
* enforcement (lib/ai/entitlement.ts). This is the data plumbing only — the
* visual admin console (docs/AI-ASSISTANT-CONCEPT.md §6) is a separate,
* not-yet-built UI on top of these same endpoints.
*/
export async function GET(request: NextRequest) {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
try {
const [state, ledger] = await Promise.all([getEntitlementState(), readMeteringLedger()]);
return NextResponse.json({ ...state, recentUsage: ledger }, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
logger.error('ai entitlement read error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
let body: { seatsTotal?: unknown; revokeUsername?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
try {
if (typeof body.seatsTotal === 'number') {
const state = await setSeatTotal(body.seatsTotal);
await auditLog('ai.entitlement.seats_total', { seatsTotal: state.seatsTotal }, ip);
return NextResponse.json(state);
}
if (typeof body.revokeUsername === 'string' && body.revokeUsername) {
const state = await revokeSeat(body.revokeUsername);
await auditLog('ai.entitlement.revoke_seat', { username: body.revokeUsername }, ip);
return NextResponse.json(state);
}
return NextResponse.json({ error: 'seatsTotal or revokeUsername is required' }, { status: 400 });
} catch (error) {
logger.error('ai entitlement update error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
import { DEFAULT_AI_ENTITLEMENT, type AiPolicy } from '@/lib/ai/types';
/**
* GET /api/ai/policy - AI Assistant policy (NOT admin-protected - users read this)
*
* `enabled` mirrors the admin FeatureGates toggle. `entitlement.classes`
* reflects real configuration, not a hardcoded guess: `server` only appears
* when AI_SERVER_BASE_URL is actually set (app/api/ai/server/* would 503
* otherwise) - this is enforcement point 1 (docs §10), cosmetic-only, the
* client hiding what it can't use; the real gate is checkAndAssignSeat() on
* every /api/ai/server/chat call, not this list.
*/
export async function GET() {
try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const classes = [...DEFAULT_AI_ENTITLEMENT.classes];
if (process.env.AI_SERVER_BASE_URL) classes.push('server');
const aiPolicy: AiPolicy = {
enabled: policy.features.aiAssistantEnabled,
entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
publicConsentVersion: null,
};
return NextResponse.json(aiPolicy, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('AI policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+67
View File
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { serverSearchMail, hydrateMailRefs } from '@/lib/ai/retrieval/mail-embeddings';
import { logger } from '@/lib/logger';
export const runtime = 'nodejs';
const MAX_QUERY_CHARS = 512;
const DEFAULT_LIMIT = 6;
/**
* POST /api/ai/retrieve — the server embedding leg (docs/AI-ASSISTANT-CONCEPT.md
* §7 step 2). Real JMAP fetch + real Ollama embeddings + real cosine ranking
* (lib/ai/retrieval/mail-embeddings.ts), not a mock.
*
* ACL note (§7 step 2b): this only ever embeds/searches the *authenticated
* session's own* JMAP account — there is no shared-mailbox fan-out to
* pre-filter yet, since group accounts are still deferred entirely (matches
* the doc's own "shared-mailbox retrieval ships server-only" decision, which
* itself hasn't been reached because there's no group account to retrieve
* from). Nothing here can leak across accounts because nothing crosses the
* account boundary in the first place.
*/
export async function POST(request: NextRequest) {
const auth = await getStalwartCredentials(request);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
if (!process.env.AI_SERVER_BASE_URL) {
return new NextResponse(null, { status: 404 });
}
let body: { query?: unknown; limit?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
const query = typeof body.query === 'string' ? body.query.trim() : '';
if (!query) {
return NextResponse.json({ error: 'query is required' }, { status: 400 });
}
if (query.length > 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 });
}
}
+96
View File
@@ -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 });
}
}
+48
View File
@@ -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 },
);
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ export default async function manifest(): Promise<ExtendedManifest> {
const appName =
branded<string>("appName", "") ||
process.env.NEXT_PUBLIC_APP_NAME ||
"Bulwark Webmail";
"VNCmail+";
const shortName = branded<string>("appShortName", "") || appName;
const description =
+12 -4
View File
@@ -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 (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
<div className="text-center p-8 max-w-md">
<img
src={logoSrc}
alt="Bulwark Mail"
alt="VNCmail+"
className="h-12 mx-auto mb-6"
/>
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
@@ -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<AiPolicy>(DEFAULT_AI_POLICY);
const [policyLoading, setPolicyLoading] = useState(true);
const [settings, setSettings] = useState<AiLocalSettings>(() => 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(<K extends keyof AiLocalSettings>(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<string[]>([]);
const [refreshingLocal, setRefreshingLocal] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
const [testError, setTestError] = useState<string | null>(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<string[]>([]);
const [refreshingServer, setRefreshingServer] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [seatNotice, setSeatNotice] = useState<string | null>(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<AskResult | null>(null);
const [askError, setAskError] = useState<string | null>(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 (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Loading
</div>
);
}
return (
<div className="space-y-6">
<SettingsSection
title="AI Assistant"
description="Ask questions about your synced mail. Local runs entirely on this machine's own model runtime; server is centrally hosted and licensed per seat; public sends your question to a provider you choose, using your own API key."
>
<SettingItem label="Provider">
{providerOptions.length > 0 ? (
<RadioGroup
value={settings.provider ?? ''}
onChange={(v) => update('provider', v as 'local' | 'server' | 'public')}
options={providerOptions}
/>
) : (
<span className="text-sm text-muted-foreground">No provider class available.</span>
)}
</SettingItem>
</SettingsSection>
{settings.provider === 'local' && canUseLocal && (
<SettingsSection
title="Local runtime"
description={
localLlmNeedsCorsSetup()
? "Reaches Ollama on this machine directly from the browser. If the test below fails, Ollama's OLLAMA_ORIGINS setting likely doesn't allow this page's origin yet."
: 'Reaches Ollama on this machine directly — no extra setup needed in the desktop app.'
}
>
<SettingItem label="Base URL">
<input
type="text"
value={settings.localBaseUrl}
onChange={(e) => update('localBaseUrl', e.target.value)}
spellCheck={false}
className={inputClass}
/>
</SettingItem>
<SettingItem label="Model" description={localModels.length === 0 ? 'Refresh to list installed models.' : undefined}>
<div className="flex items-center gap-2 flex-wrap">
{localModels.length > 0 ? (
<Select
value={settings.localModel ?? ''}
onChange={(v) => update('localModel', v)}
options={localModels.map((m) => ({ value: m, label: m }))}
/>
) : (
<span className="text-sm text-muted-foreground">{settings.localModel || 'None selected'}</span>
)}
<Button variant="outline" size="sm" onClick={refreshLocalModels} disabled={refreshingLocal}>
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingLocal ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</SettingItem>
<SettingItem label="Connection">
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={runTestConnection} disabled={testStatus === 'testing'}>
{testStatus === 'testing' && <Loader2 className="w-3.5 h-3.5 me-1.5 animate-spin" />}
Test connection
</Button>
{testStatus === 'ok' && (
<span className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-500">
<CheckCircle className="w-3.5 h-3.5" /> Reachable
</span>
)}
{testStatus === 'error' && (
<span className="flex items-center gap-1.5 text-sm text-destructive">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" /> {testError}
</span>
)}
</div>
</SettingItem>
</SettingsSection>
)}
{settings.provider === 'server' && canUseServer && (
<SettingsSection
title="Server (VNC-hosted)"
description="Centrally hosted — no setup needed on your side. Licensed per seat; using this for the first time consumes one automatically if seats remain."
>
<SettingItem label="Model" description={serverModels.length === 0 ? 'Refresh to list available models.' : undefined}>
<div className="flex items-center gap-2 flex-wrap">
{serverModels.length > 0 ? (
<Select
value={settings.serverModel ?? ''}
onChange={(v) => update('serverModel', v)}
options={serverModels.map((m) => ({ value: m, label: m }))}
/>
) : (
<span className="text-sm text-muted-foreground">{settings.serverModel || 'None selected'}</span>
)}
<Button variant="outline" size="sm" onClick={refreshServerModels} disabled={refreshingServer}>
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingServer ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</SettingItem>
{serverError && (
<SettingItem label="Status">
<span className="flex items-center gap-1.5 text-sm text-destructive">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" /> {serverError}
</span>
</SettingItem>
)}
</SettingsSection>
)}
{settings.provider === 'public' && canUsePublic && (
<SettingsSection
title="Public providers"
description="Save several — different models for different questions. Any OpenAI-compatible endpoint works. Keys are stored only in this browser and, for now, use of this class is not monitored or metered by VNC."
>
{settings.publicProfiles.length > 0 && (
<SettingItem label="Saved profiles">
<div className="flex flex-col gap-2 w-full">
{settings.publicProfiles.map((p) => (
<div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
<p className="text-xs text-muted-foreground truncate">{p.model} · {p.baseUrl}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => removeProfile(p.id)} aria-label={`Remove ${p.name}`}>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
</Button>
</div>
))}
</div>
</SettingItem>
)}
<SettingItem label="Add a provider">
<div className="flex flex-col gap-2 w-full">
<div className="flex gap-2 flex-wrap">
<input
type="text"
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
placeholder="Name, e.g. Claude via OpenRouter"
spellCheck={false}
className={inputClass}
/>
<input
type="text"
value={newProfileModel}
onChange={(e) => setNewProfileModel(e.target.value)}
placeholder="Model, e.g. anthropic/claude-sonnet-4.5"
spellCheck={false}
className={inputClass}
/>
</div>
<div className="flex gap-2 flex-wrap">
<input
type="text"
value={newProfileBaseUrl}
onChange={(e) => setNewProfileBaseUrl(e.target.value)}
placeholder="Base URL"
spellCheck={false}
className={inputClass}
/>
<input
type="password"
value={newProfileKey}
onChange={(e) => setNewProfileKey(e.target.value)}
placeholder="sk-..."
spellCheck={false}
className={inputClass}
/>
<Button
variant="outline"
size="sm"
onClick={addProfile}
disabled={!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey}
>
<Plus className="w-3.5 h-3.5 me-1.5" />
Add
</Button>
</div>
</div>
</SettingItem>
<SettingItem
label="I understand this leaves the organisation"
description="Your question and any retrieved mail excerpts are sent to the provider you pick below, outside this organisation."
>
<ToggleSwitch
checked={settings.publicConsentAccepted}
onChange={(v) => update('publicConsentAccepted', v)}
/>
</SettingItem>
</SettingsSection>
)}
{settings.provider && (
<SettingsSection title="Try it" description="Ask a question against your synced mail.">
<div className="flex flex-col gap-3">
{settings.provider === 'public' && settings.publicProfiles.length > 0 && (
<SettingItem label="Answer with">
<Select
value={settings.activeProfileId ?? ''}
onChange={(v) => update('activeProfileId', v)}
options={settings.publicProfiles.map((p) => ({ value: p.id, label: p.name }))}
/>
</SettingItem>
)}
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="What did legal say about the Meier contract deadline?"
rows={3}
className="px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 resize-y"
/>
<Button onClick={runAsk} disabled={!canAsk || asking} className="self-start">
{asking && <Loader2 className="w-3.5 h-3.5 me-1.5 animate-spin" />}
Ask
</Button>
{seatNotice && (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/40 p-3">
<CheckCircle className="w-4 h-4 mt-0.5 text-green-600 dark:text-green-500 shrink-0" />
<p className="text-sm text-muted-foreground">{seatNotice}</p>
</div>
)}
{askError && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 text-destructive shrink-0" />
<p className="text-sm text-destructive">{askError}</p>
</div>
)}
{askResult && (
<div className="flex flex-col gap-2 rounded-lg border border-border p-4">
{askResult.unaugmented && (
<p className="text-xs text-muted-foreground italic">
No local mail index available in this session answered without retrieval context.
</p>
)}
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
{askResult.sources.length > 0 && (
<div className="flex flex-col gap-0.5 border-t border-border pt-2 mt-1">
<span className="text-xs font-medium text-muted-foreground">Sources</span>
{askResult.sources.map((s, i) => (
<span key={s.id} className="text-xs text-muted-foreground truncate">
[{i + 1}] {s.subject}
</span>
))}
</div>
)}
</div>
)}
</div>
</SettingsSection>
)}
</div>
);
}
+5 -15
View File
@@ -72,21 +72,11 @@ export function ThemesSettings() {
{/* Theme Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{/* Default theme card */}
<ThemeCard
name="Default"
author="Bulwark"
isDefaultTheme
variants={['light', 'dark']}
isDark={isDark}
isActive={activeThemeId === null}
isBuiltIn
isDefault={!themePolicy.defaultThemeId}
disabled={Boolean(forcedThemeId)}
onActivate={() => handleActivate(null)}
/>
{/* Installed themes */}
{/* No "Default/Bulwark" card: product decision 2026-08-05 ships exactly
two themes (SRC default, VNClagoon) - see DEFAULT_THEME_POLICY in
lib/admin/types.ts. The underlying activateTheme(null) capability
stays reachable programmatically (e.g. an admin clearing
defaultThemeId), just not offered as a selectable card here. */}
{visibleThemes.map(theme => {
const isForceEnabled = theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id);
return (
@@ -8,4 +8,4 @@ kind: Component
images:
- name: ghcr.io/brvncde-dotcom/vncmail-plus-dev
newName: ghcr.io/brvncde-dotcom/vncmail-plus-dev
newTag: sha-d0a1cee6
newTag: sha-147660a
+165
View File
@@ -0,0 +1,165 @@
# Admin AI Policy Console — Spec (docs/AI-ASSISTANT-CONCEPT.md §6)
**Status: SPEC + MOCKUP ONLY — not implemented.** Per explicit instruction, this
is presented for approval before any of it is coded. Nothing in this document
has a corresponding UI yet; the referenced *existing* files are the real,
already-shipped backend this console would sit on top of.
## 1. Why this exists
Every AI policy lever that exists today is either hardcoded, env-only, or has
a data endpoint with no UI:
| Lever | Today | Gap |
|---|---|---|
| AI Assistant on/off | `FeatureGates.aiAssistantEnabled`, toggle in the generic Policy tab ([policy.tsx](../app/(main)/admin/_tabs/policy.tsx)) | None — this one's real and stays where it is. |
| Which classes (`local`/`server`/`public`) are reachable | [`lib/ai/types.ts`](../lib/ai/types.ts): `local`+`public` hardcoded on, `server` auto-added only if `AI_SERVER_BASE_URL` is set ([`app/api/ai/policy/route.ts`](../app/api/ai/policy/route.ts)) | No admin override. An admin cannot disable `public` (BYOK) org-wide, or disable `server` while keeping the env var set for staging. |
| Server-class model list | Every completion-capable model Ollama reports, unfiltered ([`app/api/ai/server/models/route.ts`](../app/api/ai/server/models/route.ts)) | No allow-list. If the shared Ollama host has a large/expensive model loaded, every user can select it. |
| BYOK (public) provider endpoints | Fully open — a user can point `baseUrl` at anything ([`lib/ai/local-settings.ts`](../lib/ai/local-settings.ts)) | No admin allow-list of approved providers/base URLs. Pure client trust today. |
| Entitlement / seats (`server` class) | Real enforcement + data endpoints exist ([`lib/ai/entitlement.ts`](../lib/ai/entitlement.ts), [`app/api/admin/ai/entitlement/route.ts`](../app/api/admin/ai/entitlement/route.ts)) | **No UI.** An admin today can only set `seatsTotal` via `curl` against the admin API. |
| Usage / metering ledger | Real, append-only, already recorded on every `server`-class call | **No UI.** Same — `curl` only. |
| Retrieval (mail content → embeddings) | Always on when the `server` leg is reachable ([`lib/ai/retrieval/mail-embeddings.ts`](../lib/ai/retrieval/mail-embeddings.ts)) | No org-level off switch. A privacy-conscious admin cannot disable server-side mail-content augmentation independent of disabling the whole `server` class. |
| BYOK consent | Schema has `publicConsentVersion: string \| null`, permanently `null` ([`lib/ai/types.ts`](../lib/ai/types.ts)) | No admin-authored consent text or version bump flow. |
This console is the single screen that closes all seven gaps.
## 2. Scope boundary
**In scope:** a new admin tab that reads/writes the levers above.
**Not in scope** (explicitly deferred, flag if wrong): per-user overrides
(everything here is tenant-wide), model *pricing*, any billing/invoice
integration beyond the existing metering ledger, S/MIME/theme consoles
(separate features).
## 3. Data model additions
New persisted config, `AiConsoleConfig`, stored via the existing
config-manager convention (CONFIG dir, operator-authored — see
[`lib/admin/paths.ts`](../lib/admin/paths.ts)'s `getConfigDir()` vs
`getStatePath()` distinction; this is config, seats/ledger stay in STATE
where `entitlement.ts` already puts them):
```ts
export interface AiConsoleConfig {
/** Per-class admin override. A class must be BOTH infra-available
* (server: AI_SERVER_BASE_URL set) AND enabled here to reach users.
* Missing entries default to true (local/public) / false (server) —
* matches today's DEFAULT_AI_ENTITLEMENT.classes behavior exactly, so
* turning this feature on changes nothing until an admin touches it. */
classesEnabled: Partial<Record<AiClass, boolean>>;
/** null = every completion-capable model Ollama reports (today's
* behavior, unchanged). Non-null = only these model names selectable
* for the `server` class. */
serverModelAllowlist: string[] | null;
/** null = unrestricted BYOK base URLs (today's behavior, unchanged).
* Non-null = base URL must start with one of these prefixes, checked
* client-side (advisory — see §6 open question on server-side
* enforcement) at profile-save time. */
publicProviderAllowlist: string[] | null;
/** Master switch for the retrieval leg (mail-content → embeddings).
* Independent of classesEnabled.server: an admin can allow chat but
* disable content augmentation. Defaults true (today's behavior). */
retrievalEnabled: boolean;
/** Admin-authored consent shown once per user before first BYOK/public
* use. Replaces the permanently-null publicConsentVersion. Bumping
* `version` re-prompts every user (their locally-stored acceptance is
* keyed by version — client-side change, not in this doc's scope). */
consent: { version: string; text: string } | null;
}
export const DEFAULT_AI_CONSOLE_CONFIG: AiConsoleConfig = {
classesEnabled: {},
serverModelAllowlist: null,
publicProviderAllowlist: null,
retrievalEnabled: true,
consent: null,
};
```
Entitlement (`tier`, `seatsTotal`, seat list, ledger) needs **no new schema**
— [`lib/ai/entitlement.ts`](../lib/ai/entitlement.ts) already has everything
the console needs to read and write.
## 4. New/changed API endpoints
- **`GET /api/admin/ai/policy`** (new, admin-protected) — returns
`AiConsoleConfig`.
- **`PUT /api/admin/ai/policy`** (new, admin-protected) — validates and
persists partial updates, audit-logs each change
(`ai.console.classes_updated`, `ai.console.consent_updated`, etc.,
following the existing `auditLog()` convention in
[`app/api/admin/policy/route.ts`](../app/api/admin/policy/route.ts)).
- **`GET /api/ai/policy`** (existing, extend) — folds `classesEnabled` into
the `classes` computation (a class only appears if infra-available AND
admin-enabled), adds `retrievalEnabled` and the current `consent` block to
the response so the client can gate/prompt correctly.
- **`GET/PUT /api/admin/ai/entitlement`** (existing, unchanged) — the console
UI simply gets a front-end for what already exists.
## 5. UI — new admin tab "AI"
New file `app/(main)/admin/_tabs/ai-policy.tsx`, registered alongside the
existing tabs (Policy, Themes, Plugins, …) in whatever wires up the sidebar
today. Follows the exact visual conventions already in
[`policy.tsx`](../app/(main)/admin/_tabs/policy.tsx): bordered
`rounded-lg` sections with a `bg-muted/30` header strip, the same toggle
switch markup, save button that only appears when dirty.
Six sections, top to bottom (see the companion mockup for the visual):
1. **Provider Classes** — three cards (Local / Server / Public), each a
toggle + one line of status. Server's card shows "Not configured
(AI_SERVER_BASE_URL unset)" and disables its own toggle when infra isn't
there, rather than letting an admin flip on something that 503s.
2. **Server: Model Allow-list** — only visible/enabled when Server is on.
Multi-select pulled live from `/api/ai/server/models`, defaulting to "all
models" (today's behavior) with an explicit switch to "restrict to
selected".
3. **Public (BYOK): Provider Allow-list** — same pattern, but base-URL
prefixes instead of model names (e.g. `https://api.openai.com`,
`https://api.anthropic.com`), free-text add/remove list, defaulting to
unrestricted.
4. **Entitlement & Seats** — tier picker (base/standard/pro — cosmetic today,
no different enforcement per tier, flagged as such), seat total number
input, and a live table of assigned seats each with a "Revoke" button —
direct front-end for the existing `PUT .../entitlement` with
`revokeUsername`.
5. **Usage** — read-only table, last 200 rows of the metering ledger
(timestamp, user, model, tokens, latency), plus a one-line rollup (calls
today, total tokens this week) computed client-side from the same rows —
no new aggregation endpoint needed for a first cut.
6. **Retrieval & Consent** — one toggle (retrieval on/off) + a textarea for
consent text with a version string input and a "Bump version (re-prompt
all users)" button.
## 6. Open questions for approval
1. **Provider allow-list enforcement point.** Spec above checks the BYOK
base-URL allow-list client-side only (at profile-save time in Settings).
True enforcement would require routing BYOK calls through this app's own
backend (losing the "no CORS problem, no server cost" property that made
`public` attractive as client-direct in the first place — see
[[vncmail-ai-assistant-rollout]] decision #3). Recommend: ship client-side
only for now, document it as advisory, revisit if it needs to be a real
boundary.
2. **Tier semantics.** `tier` (base/standard/pro) exists in
`AiEntitlementState` today but nothing reads it to change behavior (seat
count is the only real gate). This console would let an admin set it
without it doing anything yet. Recommend: keep the picker (cheap, matches
the schema, avoids a future migration) but label it "cosmetic — no
tier-differentiated behavior yet" in the UI itself, not just this doc.
3. **Where the master `aiAssistantEnabled` toggle lives.** Recommend leaving
it in the generic Policy tab (single source of truth, already shipped,
already tested) and just linking to it from the top of this new AI tab
("AI Assistant is currently **ON** — change in Policy →") rather than
duplicating the toggle in two places.
## 7. Explicitly not building yet
Per-user/per-group overrides, billing/invoice integration, server-side
enforcement of the BYOK allow-list (see 6.1), any change to how `local`
works (stays free/unmonitored/client-direct, per standing decision).
+232
View File
@@ -0,0 +1,232 @@
# Test basis — 2026-08-06 morning
Covers everything shipped overnight: AI Assistant (web), S/MIME (web + mobile),
and the 2-theme rebrand. For each area: how to get into a testable state,
concrete steps with expected results, and — importantly — what's still a
known gap so nothing here gets mistaken for more finished than it is.
All web steps assume `vncmail-plus` running locally via the built-in mock
JMAP server (fastest path, no real Stalwart needed):
```bash
cp .env.dev.example .env.local
# then set, in .env.local:
# AI_SERVER_BASE_URL=http://127.0.0.1:11434 (real Ollama on this Mac)
# SMIME_CA_DEV_LOCAL=true
npm run dev
```
Open `http://localhost:3000` and **log in via the "Anmelden" (dev-mode
login) button, not "Demo starten"**. Demo mode is a pure client-side
in-memory session with no server-side cookie — AI's `server` class, S/MIME
enrollment, and offline routes all need the real cookie the login button
sets, and will look broken (401s) under Demo mode for reasons that have
nothing to do with the features themselves.
---
## 1. AI Assistant (web)
Settings → AI Assistant.
### 1.1 Local (Ollama on this Mac)
1. Select provider **Local**. Model list should populate from the running
`ollama serve` (confirm with `ollama list` in a terminal first).
2. Ask a question in "Try it" with no special mail content, e.g. "reply with
exactly the words LOCAL AI WORKS".
3. **Expect:** exact echo back, no seat/entitlement banner (local is free,
unmetered, never reaches this app's own backend).
### 1.2 Server (this app's backend → your Ollama)
1. Select provider **Server**. Model list comes from `/api/ai/server/models`
(same models as Local, proxied — confirms the same-origin proxy path
works, not just direct-to-loopback).
2. Ask a first question. **Expect:** a small "seat assigned" notice on the
*first* successful call for this user, none on subsequent calls.
3. Check `data/admin-state/ai-metering.jsonl` on disk — a new line should
appear with real `promptTokens`/`completionTokens`/`latencyMs` from the
actual Ollama call, not placeholders.
4. To test the entitlement *ceiling*: `curl -X PUT localhost:3000/api/admin/ai/entitlement -H 'Content-Type: application/json' -d '{"seatsTotal":0}'`
(needs an admin session cookie), then retry a Server-class question as a
*different* username. **Expect:** HTTP 402, "no licensed seats configured".
Set `seatsTotal` back up afterwards.
### 1.3 Public (BYOK)
1. Add a profile: name, model, base URL, API key (any real provider you
have a key for, or a fake key just to test the UI — the request itself
will fail at the provider, not in this app).
2. Add a *second* profile with a different name/model.
3. In "Try it", use the **"Answer with"** selector to choose between the two
profiles for the same question. **Expect:** each answers using its own
configured model, no cross-talk.
4. Remove a profile from the list. **Expect:** it disappears immediately,
and its key is gone from `localStorage` (`DevTools → Application →
Local Storage`, key prefix `vncmail.ai.key.`).
### 1.4 Retrieval (grounded answers over your own mail)
The demo/mock inbox includes a "Villa sul Lago" booking confirmation email.
1. On **Server** or **Local**, ask: *"When is check-in for the Villa sul
Lago booking?"*
2. **Expect:** a specific date/time, with a cited source referencing the
actual email (not a generic non-answer). This exercises the full
pipeline: real JMAP `Email/query`+`Email/get`, real Ollama embeddings
(`nomic-embed-text`), cosine similarity, RRF fusion with the local
text-match leg.
### 1.5 Known gaps — don't be surprised by these
- **No admin UI yet for any of the above.** Seat totals, model allow-lists,
per-class on/off, usage — all real on the backend (§1.2, §1.3) but only
reachable via `curl`/direct API calls today. A full spec + visual mockup
for the admin console exists at
[`docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md`](ADMIN-AI-POLICY-CONSOLE-SPEC.md)
— presented for approval, **intentionally not built** pending sign-off.
- **BYOK provider allow-listing does not exist at all yet** — a user can
point a Public profile at literally any URL. Not a regression, just never
built; see the spec's §6.1 for the proposed (client-side-only) approach.
- Tier (base/standard/pro) is stored but doesn't change behavior yet — only
`seatsTotal` gates anything.
---
## 2. S/MIME — web
### 2.1 What to test today
With `SMIME_CA_DEV_LOCAL=true` set, the CA-issuance backend is real and
independently verified (4 passing tests: CSR signature verification, full
chain verification via `leaf.verify(caCert)`, correct SAN/rfc822Name
addresses, `emailProtection` EKU present — see
`lib/smime-ca/__tests__/local-dev-provider.test.ts`).
There is **no user-facing "Enroll" button wired up yet** — that was a
deliberate scope decision made overnight (see the commit message on
`c2c07293`), not an oversight: this plugin has known, still-open
security-audit findings, and generating brand-new CSR/key-import code
against it at 1am risked introducing a new one rather than closing an old
one. So today, test the backend directly:
```bash
# Generate a throwaway CSR (any tool - openssl shown here):
openssl req -new -newkey rsa:2048 -nodes -keyout /tmp/test.key \
-out /tmp/test.csr -subj "/CN=Test User"
# Call the enrollment route directly (needs an authenticated session cookie
# from the "Anmelden" login above - copy it from DevTools → Application →
# Cookies, or use a logged-in curl session):
curl -X POST localhost:3000/api/smime/enroll \
-H 'Content-Type: application/json' \
--cookie "<your session cookie>" \
-d "{\"csrPem\": $(python3 -c 'import json,sys; print(json.dumps(open("/tmp/test.csr").read()))'), \"addresses\": [\"you@example.com\"]}"
```
**Expect:** a JSON response with `certificatePem`, `chainPem`, `serialNumber`,
`notAfter`. Verify it's a real cert: `openssl x509 -in <(echo "$certificatePem") -noout -text`
should show issuer `VNCmail+ LOCAL DEV S/MIME CA`, your `you@example.com` in
Subject Alternative Name, and Extended Key Usage including `E-mail
Protection`.
### 2.2 Known gap — the important one
**Nobody can actually enroll from the UI today.** The plugin's Settings
section still has no "Get a certificate" button, no client-side CSR
generation, and no wiring to import an issued cert into its existing
encrypted-at-rest key storage (`vnc/plugins/smime/src/pkcs12.js`'s
AES-GCM+PBKDF2 convention). The plugin itself is confirmed *mounted*
(privileged iframe, same-origin fetch works without a bridge) with 0 certs
imported — that's expected, not a bug. This is real, well-scoped follow-up
work, not blocked on anything — just deliberately not rushed overnight.
---
## 3. S/MIME — mobile (vncmail-native)
Unlike web, mobile got the **full stack**, merged to `main` this session
(`7b89839`, combining the S/MIME work with the AI/offline-sync work that
landed on `main` in parallel): real CMS sign/verify/encrypt/decrypt on
`node-forge` primitives (wire-compatible with the audited webmail plugin,
verified byte-for-byte against OpenSSL), PKCS#12 import, keys wrapped
AES-256-GCM/PBKDF2 in `expo-secure-store` (Keychain/Keystore — never
AsyncStorage in the clear).
### 3.1 Test steps
1. Build and run on a simulator/device (`npm run ios` / `npm run android`
from `vncmail-native`).
2. Settings → S/MIME → **Import certificate**. Use a real `.p12` file (an
OpenSSL-generated one works fine for testing) and its passphrase.
3. **Expect:** cert imported, persists across an app restart, a wrong
passphrase is rejected, the right one unlocks it.
4. Compose a new email to yourself → enable **Sign** (and/or **Encrypt**) →
send.
5. Open the received message. **Expect:** a signature banner showing
verified/signed-by, and if encrypted, the body decrypts and displays
normally.
6. Try receiving a deliberately malformed/unauthenticated-cipher message
(or just trust the 259-line hardening test suite —
`src/lib/__tests__/smime-hardening.test.ts` — covers this without
needing to hand-craft one). **Expect:** unauthenticated content renders
as inert plaintext, never live HTML.
### 3.2 Known-good, already verified
479+ unit tests (834 after the merge with the AI/sync work) pass, including
a full round-trip test. `typecheck` is clean. Also verified live on an
Android emulator during that session: real OpenSSL-3-generated `.p12`
imported through the actual device file picker.
### 3.3 Known gap
No cross-checking was done *this session* between the mobile
implementation and the CA now issuing certs on web (§2) — they were built
independently and haven't been tested importing a *web-CA-issued*
certificate into the mobile app. Worth doing once web's Enroll UI (§2.2)
exists, not before.
---
## 4. Theme
Settings → Themes. Exactly 2 built-in themes now: **SRC** (default, Swiss
red `#D52B1E`) and **VNClagoon** (navy/cyan). The other 6 generic built-ins
(Nord, Catppuccin, Solarized, Roundcube Elastic, Aurora Glass, plus the old
default) are hidden via admin theme policy, not deleted — an admin could
re-enable them, a normal user can't see them.
### 4.1 Test steps
1. Confirm SRC is active on first load (no theme ever selected before).
2. Switch to VNClagoon. **Expect:** a "Theme activated" toast, and the
chrome genuinely re-skins (navy/cyan replaces red throughout — sidebar,
buttons, unread markers, not just an accent color here and there).
3. Switch back to SRC. **Expect:** same, in reverse.
4. Search the UI (empty-state illustrations, manifest/PWA name, login page,
locale strings in a couple of languages) for the string "Bulwark" — all
24 locale files were rebranded to "VNCmail+" this session, plus
`app/manifest.ts`, demo fixtures, and the inbox empty-state logo, which
now resolves through the same theme-logo system every other themed
surface uses instead of a hardcoded path.
### 4.2 Known gap
Not exhaustively re-checked for every locale/every screen — the 24-file
find-and-replace was mechanical (script-driven) and spot-checked, not
manually walked screen-by-screen in each language. If a stray "Bulwark"
turns up somewhere, it's most likely a screen that wasn't in scope of the
locale-string sweep (e.g. a hardcoded string in a component rather than a
translation key) — same category as the logo fix above, worth a quick grep
(`grep -ril bulwark`) if one surfaces.
---
## 5. Offline / mail-index
Already QA'd and fixed this session (not re-listed as a to-do): a leaked
SQLite handle on `PRAGMA` failure, and contacts/files that weren't being
removed from the local index on delete. Both fixed and covered by tests —
no separate manual test needed tomorrow unless something else turns up.
+7 -3
View File
@@ -2,8 +2,10 @@ import { describe, it, expect } from 'vitest';
import { BUILTIN_THEMES } from '../builtin-themes';
describe('BUILTIN_THEMES', () => {
it('contains exactly 6 themes', () => {
expect(BUILTIN_THEMES).toHaveLength(6);
it('contains exactly 8 themes', () => {
// 6 generic built-ins (author: 'Built-in') + 2 VNC brand themes
// (VNClagoon, SRC — author: 'VNC'), added this week.
expect(BUILTIN_THEMES).toHaveLength(8);
});
it('all themes have required fields', () => {
@@ -11,7 +13,7 @@ describe('BUILTIN_THEMES', () => {
expect(theme.id).toBeTruthy();
expect(theme.name).toBeTruthy();
expect(theme.version).toBeTruthy();
expect(theme.author).toBe('Built-in');
expect(['Built-in', 'VNC']).toContain(theme.author);
expect(theme.css).toBeTruthy();
expect(theme.variants).toEqual(['light', 'dark']);
expect(theme.enabled).toBe(true);
@@ -45,6 +47,8 @@ describe('BUILTIN_THEMES', () => {
expect(names).toContain('Solarized');
expect(names).toContain('Roundcube Elastic');
expect(names).toContain('Aurora Glass');
expect(names).toContain('VNClagoon');
expect(names).toContain('SRC');
});
it('theme IDs are unique', () => {
+24 -1
View File
@@ -66,6 +66,14 @@ export interface FeatureGates {
crossStarredViewEnabled: boolean;
crossAllViewEnabled: boolean;
unifiedCrossAccountEnabled: boolean;
/**
* Master admin switch for the AI Assistant tab (docs/AI-ASSISTANT-CONCEPT.md).
* Defaults true as of the 2026-08-05 evening decision to make local AI
* visible by default (see lib/ai/types.ts) — local ships free with no
* entitlement gate, so there's a real, working feature behind this tab
* now, not an empty preview. An admin can still turn it off per-tenant.
*/
aiAssistantEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -92,6 +100,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
crossStarredViewEnabled: false,
crossAllViewEnabled: false,
unifiedCrossAccountEnabled: false,
aiAssistantEnabled: true,
};
export interface ThemePolicy {
@@ -103,8 +112,22 @@ export interface ThemePolicy {
defaultThemeId: string | null;
}
// Product decision 2026-08-05: exactly two themes ship for VNCmail+ — SRC
// (default) and VNClagoon. Every other built-in preset (Qui, Nord,
// Catppuccin, Solarized, Roundcube Elastic, Aurora Glass) stays in
// lib/builtin-themes.ts (cheap to re-enable later) but is hidden by default
// via this policy rather than deleted.
const NON_SHIPPING_BUILTIN_THEMES = [
'builtin-qui',
'builtin-nord',
'builtin-catppuccin',
'builtin-solarized',
'builtin-roundcube-elastic',
'builtin-aurora-glass',
];
export const DEFAULT_THEME_POLICY: ThemePolicy = {
disabledBuiltinThemes: [],
disabledBuiltinThemes: NON_SHIPPING_BUILTIN_THEMES,
disabledThemes: [],
defaultThemeId: 'builtin-src',
};
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
// Real end-to-end seat assignment against the real Ollama was verified live
// (see the commit this test ships with); this covers the rejection branch,
// which is deterministic and cheaper to prove with a unit test than another
// live round trip.
describe('lib/ai/entitlement', () => {
let stateDir: string;
beforeEach(async () => {
vi.resetModules();
stateDir = await mkdtemp(path.join(tmpdir(), 'ai-entitlement-test-'));
process.env.ADMIN_STATE_DIR = stateDir;
delete process.env.AI_SERVER_SEAT_TOTAL;
// Each test needs a fresh globalThis singleton, not just a fresh module -
// the module stashes cached state on globalThis specifically to survive
// HMR, so resetModules() alone doesn't clear it.
delete (globalThis as Record<symbol, unknown>)[Symbol.for('vncmail.ai.entitlement')];
});
afterEach(async () => {
delete process.env.ADMIN_STATE_DIR;
await rm(stateDir, { recursive: true, force: true });
});
it('assigns a seat on first use and allows the same user again', async () => {
const { checkAndAssignSeat, setSeatTotal } = await import('../entitlement');
await setSeatTotal(1);
const first = await checkAndAssignSeat('alice@example.com');
expect(first).toEqual({ allowed: true, seatJustAssigned: true });
const second = await checkAndAssignSeat('alice@example.com');
expect(second).toEqual({ allowed: true });
});
it('rejects a new user once all seats are assigned', async () => {
const { checkAndAssignSeat, setSeatTotal } = await import('../entitlement');
await setSeatTotal(1);
await checkAndAssignSeat('alice@example.com');
const rejected = await checkAndAssignSeat('bob@example.com');
expect(rejected.allowed).toBe(false);
expect(rejected.reason).toMatch(/already assigned/i);
});
it('rejects everyone when no seats are configured', async () => {
const { checkAndAssignSeat } = await import('../entitlement');
const result = await checkAndAssignSeat('anyone@example.com');
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/no licensed seats/i);
});
it('revoking a seat frees it for someone else', async () => {
const { checkAndAssignSeat, setSeatTotal, revokeSeat } = await import('../entitlement');
await setSeatTotal(1);
await checkAndAssignSeat('alice@example.com');
await revokeSeat('alice@example.com');
const result = await checkAndAssignSeat('bob@example.com');
expect(result).toEqual({ allowed: true, seatJustAssigned: true });
});
it('persists usage to the metering ledger, append-only', async () => {
const { recordUsage, readMeteringLedger } = await import('../entitlement');
await recordUsage({
timestamp: new Date(0).toISOString(),
username: 'alice@example.com',
model: 'qwen2.5:32b',
promptTokens: 10,
completionTokens: 5,
latencyMs: 123,
});
await recordUsage({
timestamp: new Date(0).toISOString(),
username: 'alice@example.com',
model: 'qwen2.5:32b',
promptTokens: 8,
completionTokens: 3,
latencyMs: 90,
});
const ledger = await readMeteringLedger();
expect(ledger).toHaveLength(2);
expect(ledger[0].promptTokens).toBe(10);
expect(ledger[1].promptTokens).toBe(8);
});
});
+162
View File
@@ -0,0 +1,162 @@
// Real entitlement + metering enforcement for the AI Assistant's `server`
// class (docs/AI-ASSISTANT-CONCEPT.md §9/§10 — per-seat licensing, a
// metering ledger that doubles as the billing record).
//
// Deliberately scoped to `server` only, not `local`/`public`, per the
// 2026-08-05 decisions: `local` ships free (never reaches a server this app
// controls, so it can't be metered — see the doc's own §9 reasoning) and
// `public` is explicitly unmonitored for now. `server` is the one class that
// (a) proxies through this app's own backend (see app/api/ai/server/*) and
// (b) has a real marginal cost (shared GPU time) worth gating — so it is the
// one place enforcement is both possible and worth building tonight.
//
// Persistence follows the existing admin state-dir convention
// (lib/admin/paths.ts): STATE, not CONFIG, because this is runtime-mutated
// data (seat assignments, usage), not operator-authored config.
import { readFile, writeFile, rename, appendFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { getStatePath, ensureStateDir } from '@/lib/admin/paths';
import { logger } from '@/lib/logger';
export interface AiEntitlementState {
subject: 'tenant' | 'user';
tier: 'base' | 'standard' | 'pro';
/** Total seats licensed. 0 = server class entirely unlicensed (default). */
seatsTotal: number;
/** Usernames who have consumed a seat (first successful use assigns one,
* matching real per-seat licensing — not deallocated by idling). */
assignedTo: string[];
}
export interface EntitlementCheck {
allowed: boolean;
reason?: string;
/** True the moment this call consumed a previously-unassigned seat. */
seatJustAssigned?: boolean;
}
export interface MeteringEntry {
timestamp: string;
username: string;
model: string;
/** Ollama reports these as prompt_eval_count / eval_count. */
promptTokens: number;
completionTokens: number;
latencyMs: number;
}
const STATE_FILE = 'ai-entitlement.json';
const LEDGER_FILE = 'ai-metering.jsonl';
const DEFAULT_STATE: AiEntitlementState = {
subject: 'tenant',
tier: 'base',
seatsTotal: Number.parseInt(process.env.AI_SERVER_SEAT_TOTAL ?? '0', 10) || 0,
assignedTo: [],
};
// Stash on globalThis like config-manager.ts — HMR/dev re-evaluates this
// module, and in-memory seat state must survive that or every hot reload
// would silently re-grant seats.
const SINGLETON_KEY = Symbol.for('vncmail.ai.entitlement');
type GlobalWithState = typeof globalThis & { [SINGLETON_KEY]?: Promise<AiEntitlementState> | undefined };
async function readState(): Promise<AiEntitlementState> {
try {
const raw = await readFile(getStatePath(STATE_FILE), 'utf-8');
return { ...DEFAULT_STATE, ...JSON.parse(raw) };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn('ai-entitlement: failed to read state, using defaults', {
error: error instanceof Error ? error.message : String(error),
});
}
return { ...DEFAULT_STATE };
}
}
async function writeState(state: AiEntitlementState): Promise<void> {
await ensureStateDir();
const target = getStatePath(STATE_FILE);
const tmp = target + '.tmp';
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8');
await rename(tmp, target);
}
let cached: AiEntitlementState | null = null;
async function loadCached(): Promise<AiEntitlementState> {
if (cached) return cached;
const g = globalThis as GlobalWithState;
if (!g[SINGLETON_KEY]) g[SINGLETON_KEY] = readState();
cached = await g[SINGLETON_KEY];
return cached;
}
/**
* The real enforcement point (doc §10 point 2): re-validated on every call,
* never trusts anything the client sent. Auto-assigns a seat on first use
* when seats remain — that's what "per-seat" means for a subject that
* hasn't been explicitly provisioned by an admin yet.
*/
export async function checkAndAssignSeat(username: string): Promise<EntitlementCheck> {
const state = await loadCached();
if (state.assignedTo.includes(username)) {
return { allowed: true };
}
if (state.assignedTo.length >= state.seatsTotal) {
return {
allowed: false,
reason: state.seatsTotal === 0
? 'The server-hosted AI class has no licensed seats configured.'
: `All ${state.seatsTotal} licensed seat(s) are already assigned to other users.`,
};
}
const next: AiEntitlementState = { ...state, assignedTo: [...state.assignedTo, username] };
await writeState(next);
cached = next;
const g = globalThis as GlobalWithState;
g[SINGLETON_KEY] = Promise.resolve(next);
return { allowed: true, seatJustAssigned: true };
}
/** The metering write IS the billing record — see module header. Append-only,
* never rewritten, so it stays valid as an audit trail even if this process
* crashes mid-write (worst case: one truncated trailing line). */
export async function recordUsage(entry: MeteringEntry): Promise<void> {
await ensureStateDir();
await appendFile(getStatePath(LEDGER_FILE), JSON.stringify(entry) + '\n', 'utf-8');
}
export async function getEntitlementState(): Promise<AiEntitlementState> {
return loadCached();
}
export async function setSeatTotal(total: number): Promise<AiEntitlementState> {
const state = await loadCached();
const next: AiEntitlementState = { ...state, seatsTotal: Math.max(0, Math.trunc(total)) };
await writeState(next);
cached = next;
(globalThis as GlobalWithState)[SINGLETON_KEY] = Promise.resolve(next);
return next;
}
export async function revokeSeat(username: string): Promise<AiEntitlementState> {
const state = await loadCached();
const next: AiEntitlementState = { ...state, assignedTo: state.assignedTo.filter((u) => u !== username) };
await writeState(next);
cached = next;
(globalThis as GlobalWithState)[SINGLETON_KEY] = Promise.resolve(next);
return next;
}
/** Read-only summary, no PII beyond usernames already visible to any admin. */
export async function readMeteringLedger(limit = 200): Promise<MeteringEntry[]> {
const path = getStatePath(LEDGER_FILE);
if (!existsSync(path)) return [];
const raw = await readFile(path, 'utf-8');
const lines = raw.trim().split('\n').filter(Boolean);
return lines.slice(-limit).map((line) => JSON.parse(line) as MeteringEntry);
}
+32
View File
@@ -0,0 +1,32 @@
// Client-held storage for the user's own public-provider API keys (BYOK).
//
// Decision 2026-08-05 (reverses docs/AI-ASSISTANT-CONCEPT.md decision #1's
// server-side-custody design): the user brings and holds their own keys,
// client-side, not VNC. This is the same custody model as
// vncmail-native's lib/ai-key-store.ts (expo-secure-store there; this repo
// has no OS keychain access from a browser tab, so localStorage is the
// honest equivalent here — plain, not hidden behind a false sense of
// "secure storage").
//
// Decision 2026-08-05 (later same night): several keys, not one — a user may
// hold multiple named provider profiles (different models, different
// providers) and pick which one answers a given question. Keys are stored
// separately from `lib/ai/local-settings.ts`'s profile metadata (name, base
// URL, model) so a profile can be exported/shared without its secret, and so
// clearing one key can't accidentally corrupt the profile list.
const KEY_PREFIX = 'vncmail:ai:key:';
export function getAiApiKey(profileId: string): string | null {
if (typeof window === 'undefined') return null;
return window.localStorage.getItem(KEY_PREFIX + profileId);
}
export function setAiApiKey(profileId: string, key: string): void {
if (typeof window === 'undefined') return;
window.localStorage.setItem(KEY_PREFIX + profileId, key);
}
export function clearAiApiKey(profileId: string): void {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(KEY_PREFIX + profileId);
}
+338
View File
@@ -0,0 +1,338 @@
// The AI assistant's wire client. `local`/`public` mirror vncmail-native's
// src/api/ai.ts (direct loopback/provider fetch, no streaming) so the two
// clients stay in lockstep — matching docs/AI-ASSISTANT-CONCEPT.md §2's
// "local"/"public" rows, not proxied through this app's own Next.js server.
// That distinction matters once this app is hosted remotely: a server-side
// proxy would reach the *server's* loopback, not the user's own laptop
// running Ollama.
//
// `server` (added 2026-08-05 night) is the opposite by design: it DOES
// proxy through this app's own backend (app/api/ai/server/*), because it's
// centrally-hosted infra (VNC's EU/CH stack — standing in tonight for a real
// Ollama on this Mac, see lib/ai/entitlement.ts), not a user's own machine.
// That server-side hop is also the one real entitlement enforcement point
// (§10 point 2) — `local`/`public` never reach it, by design, and so cannot
// be metered or billed the same way.
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
// ── Local: Ollama's native API, not the OpenAI-compat shim — one fewer path
// assumption (no "/v1" prefix to guess at) for a runtime this code talks to directly. ──
interface OllamaTagsResponse {
models?: Array<{ name: string; capabilities?: string[] }>;
}
interface OllamaChatResponse {
message?: { content?: string };
}
export async function listLocalModels(baseUrl: string): Promise<string[]> {
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`);
if (!res.ok) throw new Error(`Ollama returned ${res.status}`);
const body = (await res.json()) as OllamaTagsResponse;
// Excludes embedding-only models (e.g. nomic-embed-text) from the chat
// picker — same reasoning as app/api/ai/server/models/route.ts.
return (body.models ?? [])
.filter((m) => !m.capabilities || m.capabilities.includes('completion'))
.map((m) => m.name)
.filter(Boolean);
}
/**
* Diagnoses the specific failure rather than a generic "connection failed" —
* docs/AI-ASSISTANT-CONCEPT.md §3 calls this out explicitly for the browser
* row: a CORS rejection (the runtime is up but refused this page's origin)
* looks identical to "nothing is listening" unless told apart. `fetch`
* itself can't distinguish them (a CORS failure and a connection refusal
* both surface as `TypeError: Failed to fetch`), so this only upgrades the
* message when the caller can tell us there's a live page origin to name.
*/
export async function testLocalConnection(
baseUrl: string,
): Promise<{ ok: boolean; error?: string }> {
try {
await listLocalModels(baseUrl);
return { ok: true };
} catch (err) {
const origin = typeof window !== 'undefined' ? window.location.origin : null;
const hint = origin
? ` Reachable in principle, but if Ollama is actually running, it likely refused this page's origin (${origin}) — start it with OLLAMA_ORIGINS=${origin}.`
: '';
return {
ok: false,
error: (err instanceof Error ? err.message : String(err)) + hint,
};
}
}
export async function chatLocal(
baseUrl: string,
model: string,
messages: ChatMessage[],
): Promise<string> {
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) throw new Error(`Ollama returned ${res.status}`);
const body = (await res.json()) as OllamaChatResponse;
const content = body.message?.content;
if (!content) throw new Error('Ollama returned no message content');
return content;
}
// ── Server: centrally-hosted, proxied through this app's own backend
// (app/api/ai/server/*). Unlike `local`, this is same-origin from the
// browser's perspective — no CORS/OLLAMA_ORIGINS story at all — and unlike
// both `local` and `public`, every call is entitlement-checked server-side. ──
export async function listServerModels(): Promise<string[]> {
const res = await fetch('/api/ai/server/models');
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? `AI server returned ${res.status}`);
}
const body = (await res.json()) as { models?: string[] };
return body.models ?? [];
}
export interface ServerChatResult {
answer: string;
/** True the moment this call consumed a previously-unassigned licensed seat
* (lib/ai/entitlement.ts) — surfaced so the UI can say so once, not left
* to happen silently the first time someone uses this class. */
seatJustAssigned: boolean;
}
export async function chatServer(model: string, messages: ChatMessage[]): Promise<ServerChatResult> {
const res = await fetch('/api/ai/server/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages }),
});
const body = (await res.json().catch(() => null)) as { answer?: string; error?: string; seatJustAssigned?: boolean } | null;
if (!res.ok || !body?.answer) {
throw new Error(body?.error ?? `AI server returned ${res.status}`);
}
return { answer: body.answer, seatJustAssigned: body.seatJustAssigned === true };
}
// ── Public: OpenAI-compatible chat-completions. OpenRouter by default, but any
// endpoint speaking this shape works unmodified (self-hosted vLLM, LiteLLM, etc). ──
interface OpenAiChatResponse {
choices?: Array<{ message?: { content?: string } }>;
}
export async function chatPublic(
baseUrl: string,
apiKey: string,
model: string,
messages: ChatMessage[],
): Promise<string> {
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ model, messages }),
});
if (!res.ok) throw new Error(`Provider returned ${res.status}`);
const body = (await res.json()) as OpenAiChatResponse;
const content = body.choices?.[0]?.message?.content;
if (!content) throw new Error('Provider returned no message content');
return content;
}
// ── Retrieval: two legs run in parallel and get Reciprocal-Rank-Fused
// (docs/AI-ASSISTANT-CONCEPT.md §7 steps 2-3), exactly like the doc
// describes — this is real, not a single degraded leg wearing SourceRef's
// clothes:
// - local FTS: this app's own already-built offline search surface
// (app/api/offline/search/route.ts). The encrypted SQLite/FTS5 store it
// reads only exists in Electron's main process — a 404/503 there means
// "no local index in this session", not an error.
// - server embedding: app/api/ai/retrieve (lib/ai/retrieval/mail-embeddings.ts) —
// real JMAP fetch, real Ollama embeddings, real cosine ranking. A 404
// there means AI_SERVER_BASE_URL isn't configured; anything else is a
// real failure, logged but not fatal to the question.
// Either leg being absent degrades to the other with no special-casing
// (reciprocalRankFusion handles an empty array leg for free); both absent
// degrades to an unaugmented question, same as before tonight.
import { reciprocalRankFusion } from './retrieval/fusion';
import type { Scored, SourceRef } from './retrieval/types';
export interface AskSource {
id: string;
subject: string;
}
export interface AskResult {
answer: string;
sources: AskSource[];
/** True when the question was answered without any retrieved context. */
unaugmented: boolean;
/** True the moment this call consumed a previously-unassigned licensed
* seat on the `server` class (lib/ai/entitlement.ts). Always false for
* `local`/`public`, which aren't entitlement-gated. */
seatJustAssigned: boolean;
}
interface OfflineSearchHit {
id: string;
jmapAccountId: string;
title: string;
snippet?: string;
}
interface OfflineSearchResponse {
ok: true;
hits: OfflineSearchHit[];
}
interface ServerRetrieveHit {
ref: SourceRef;
title: string;
snippet: string;
}
interface ServerRetrieveResponse {
ok: true;
hits: ServerRetrieveHit[];
}
interface RetrievedContext {
contextBlock: string;
hits: Array<{ id: string; title: string }>;
}
async function fetchLocalLeg(question: string): Promise<{ scored: Scored<SourceRef>[]; text: Map<string, { title: string; snippet: string }> }> {
const empty = { scored: [] as Scored<SourceRef>[], text: new Map<string, { title: string; snippet: string }>() };
try {
const res = await fetch(`/api/offline/search?q=${encodeURIComponent(question)}&limit=6`);
if (!res.ok) return empty; // 404/503 — no local index this session, not an error
const body = (await res.json()) as OfflineSearchResponse;
if (!body.ok) return empty;
const text = new Map(body.hits.map((h) => [h.id, { title: h.title, snippet: h.snippet ?? '' }]));
const scored = body.hits.map((h, i) => ({
ref: { product: 'mail' as const, accountId: h.jmapAccountId, collectionId: '', itemId: h.id, chunkIx: 0 },
score: 1 / (i + 1), // rank position is all reciprocalRankFusion reads
}));
return { scored, text };
} catch {
return empty;
}
}
async function fetchServerLeg(question: string): Promise<{ scored: Scored<SourceRef>[]; text: Map<string, { title: string; snippet: string }> }> {
const empty = { scored: [] as Scored<SourceRef>[], text: new Map<string, { title: string; snippet: string }>() };
try {
const res = await fetch('/api/ai/retrieve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: question, limit: 6 }),
});
if (!res.ok) return empty; // 404 (server class not configured) or any other failure — degrade, don't fail the question
const body = (await res.json()) as ServerRetrieveResponse;
if (!body.ok) return empty;
const text = new Map(body.hits.map((h) => [h.ref.itemId, { title: h.title, snippet: h.snippet }]));
const scored = body.hits.map((h, i) => ({ ref: h.ref, score: 1 / (i + 1) }));
return { scored, text };
} catch {
return empty;
}
}
async function retrieveContext(question: string): Promise<RetrievedContext | null> {
const [local, server] = await Promise.all([fetchLocalLeg(question), fetchServerLeg(question)]);
const fused = reciprocalRankFusion([local.scored, server.scored], 6);
if (fused.length === 0) return null;
const combinedText = new Map([...server.text, ...local.text]); // local wins on overlap: it's the more precise leg (BM25 on exact terms)
const withText = fused
.map((f) => ({ ref: f.ref, info: combinedText.get(f.ref.itemId) }))
.filter((f): f is { ref: SourceRef; info: { title: string; snippet: string } } => !!f.info);
if (withText.length === 0) return null;
return {
contextBlock: withText.map((h, i) => `[${i + 1}] Subject: ${h.info.title}\n${h.info.snippet}`).join('\n\n'),
hits: withText.map((h) => ({ id: h.ref.itemId, title: h.info.title })),
};
}
export function buildPrompt(question: string, contextBlock: string): ChatMessage[] {
return [
{
role: 'system',
content:
"You answer questions about the user's email using only the numbered excerpts " +
'below as context. Cite sources by their number in brackets, e.g. [1]. If the ' +
"excerpts don't contain the answer, say so plainly rather than guessing.",
},
{ role: 'user', content: `${contextBlock}\n\nQuestion: ${question}` },
];
}
/**
* One saved BYOK profile, resolved to an actual key — the caller picks which
* profile answers *this* question (docs decision 2026-08-05: several keys,
* selected case by case, not one fixed "the" public provider).
*/
export interface ResolvedPublicProfile {
baseUrl: string;
model: string;
apiKey: string;
}
export interface AskConfig {
provider: 'local' | 'server' | 'public';
localBaseUrl: string;
localModel: string | null;
serverModel: string | null;
publicProfile: ResolvedPublicProfile | null;
}
export async function askMail(question: string, config: AskConfig): Promise<AskResult> {
if (config.provider === 'local' && !config.localModel) {
throw new Error('No local model selected');
}
if (config.provider === 'server' && !config.serverModel) {
throw new Error('No server model selected');
}
if (config.provider === 'public' && !config.publicProfile) {
throw new Error('No provider profile selected');
}
const retrieved = await retrieveContext(question);
const messages = retrieved
? buildPrompt(question, retrieved.contextBlock)
: [{ role: 'user' as const, content: question }];
let answer: string;
let seatJustAssigned = false;
if (config.provider === 'public') {
const profile = config.publicProfile as ResolvedPublicProfile;
answer = await chatPublic(profile.baseUrl, profile.apiKey, profile.model, messages);
} else if (config.provider === 'server') {
const result = await chatServer(config.serverModel as string, messages);
answer = result.answer;
seatJustAssigned = result.seatJustAssigned;
} else {
answer = await chatLocal(config.localBaseUrl, config.localModel as string, messages);
}
return {
answer,
sources: (retrieved?.hits ?? []).map((h) => ({ id: h.id, subject: h.title })),
unaugmented: !retrieved,
seatJustAssigned,
};
}
+85
View File
@@ -0,0 +1,85 @@
// Small, isolated persistence for AI Assistant settings — deliberately NOT
// folded into stores/settings-store.ts tonight. That store's export/import
// feature enumerates every field by hand; this is prototype-scope UI state
// (docs/AI-ASSISTANT-CONCEPT.md §12's P0/P5), and migrating it into the
// shared store belongs with whichever phase makes these settings real
// product config rather than a local-AI test harness.
export type AiProvider = 'local' | 'server' | 'public';
/**
* A named public-provider configuration (BYOK). Decision 2026-08-05: several
* of these, not one — different models/providers for different questions,
* picked case by case at Ask time (see `activeProfileId`). The API key
* itself lives in `lib/ai/key-store.ts`, keyed by `id`, not here — so a
* profile's metadata can be listed/edited without ever handling the secret.
*/
export interface AiProviderProfile {
id: string;
name: string;
baseUrl: string;
model: string;
}
export interface AiLocalSettings {
provider: AiProvider | null;
localBaseUrl: string;
localModel: string | null;
serverModel: string | null;
publicProfiles: AiProviderProfile[];
/** Which saved profile answers the next question. Not a permanent default —
* the "Try it" UI lets this be changed per question. */
activeProfileId: string | null;
publicConsentAccepted: boolean;
}
const STORAGE_KEY = 'vncmail:ai:settings';
export const DEFAULT_AI_SETTINGS: AiLocalSettings = {
provider: null,
localBaseUrl: 'http://127.0.0.1:11434',
localModel: null,
serverModel: null,
publicProfiles: [],
activeProfileId: null,
publicConsentAccepted: false,
};
function newProfileId(): string {
return `profile-${Math.random().toString(36).slice(2, 10)}-${Math.random().toString(36).slice(2, 10)}`;
}
/** One-time upgrade from the earlier single-profile shape (a bare
* publicBaseUrl/publicModel pair) into the profile list, so a browser that
* already saved settings before profiles existed doesn't just lose them. */
function migrate(raw: Record<string, unknown>): Partial<AiLocalSettings> {
if (Array.isArray(raw.publicProfiles)) return raw as Partial<AiLocalSettings>;
if (typeof raw.publicBaseUrl === 'string' && typeof raw.publicModel === 'string' && raw.publicModel) {
const id = newProfileId();
return {
...raw,
publicProfiles: [{ id, name: 'Default', baseUrl: raw.publicBaseUrl, model: raw.publicModel }],
activeProfileId: id,
};
}
return raw as Partial<AiLocalSettings>;
}
export function loadAiSettings(): AiLocalSettings {
if (typeof window === 'undefined') return { ...DEFAULT_AI_SETTINGS };
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_AI_SETTINGS };
return { ...DEFAULT_AI_SETTINGS, ...migrate(JSON.parse(raw)) };
} catch {
return { ...DEFAULT_AI_SETTINGS };
}
}
export function saveAiSettings(settings: AiLocalSettings): void {
if (typeof window === 'undefined') return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
export function createProfile(name: string, baseUrl: string, model: string): AiProviderProfile {
return { id: newProfileId(), name, baseUrl, model };
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { reciprocalRankFusion } from '../fusion';
import type { SourceRef } from '../types';
function ref(itemId: string): SourceRef {
return { product: 'mail', accountId: 'acct-1', collectionId: 'inbox', itemId, chunkIx: 0 };
}
describe('reciprocalRankFusion', () => {
it('ranks an item found by both legs above one found by only one', () => {
const local = [{ ref: ref('a'), score: 1 }, { ref: ref('b'), score: 0.9 }];
const server = [{ ref: ref('a'), score: 0.8 }, { ref: ref('c'), score: 0.7 }];
const fused = reciprocalRankFusion([local, server], 10);
expect(fused[0].ref.itemId).toBe('a'); // rank 1 in both legs
expect(fused.map((f) => f.ref.itemId)).toEqual(['a', 'b', 'c']);
});
it('degrades to a single retriever when one leg is empty, no special-casing needed', () => {
const local = [{ ref: ref('a'), score: 1 }, { ref: ref('b'), score: 0.5 }];
const fused = reciprocalRankFusion([local, []], 10);
expect(fused.map((f) => f.ref.itemId)).toEqual(['a', 'b']);
});
it('returns nothing when both legs are empty', () => {
expect(reciprocalRankFusion([[], []], 10)).toEqual([]);
});
it('respects the limit', () => {
const local = [ref('a'), ref('b'), ref('c')].map((r, i) => ({ ref: r, score: 1 - i * 0.1 }));
const fused = reciprocalRankFusion([local, []], 2);
expect(fused).toHaveLength(2);
});
it('does not double-count the same item across legs when collectionId differs', () => {
// Same email, but the two legs report a different mailbox for it - see
// fusion.ts's refKey comment for why collectionId is deliberately not
// part of the fusion identity.
const local = [{ ref: { ...ref('a'), collectionId: 'inbox' }, score: 1 }];
const server = [{ ref: { ...ref('a'), collectionId: 'archive' }, score: 1 }];
const fused = reciprocalRankFusion([local, server], 10);
expect(fused).toHaveLength(1);
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { Scored, SourceRef } from './types';
/**
* Reciprocal Rank Fusion (docs/AI-ASSISTANT-CONCEPT.md §7 step 3):
* score(d) = Σ 1/(k + rank_i(d)), k = 60.
*
* RRF only reads rank, not the underlying score — so it needs no calibration
* between BM25 (FTS) and cosine (embedding) scores, and degrades to a single
* retriever with no code branch when one leg is absent (just pass an empty
* array for that leg).
*/
const RRF_K = 60;
/**
* Deliberately excludes collectionId: a JMAP email can live in more than one
* mailbox, and the FTS leg and the embedding leg may legitimately report a
* different "primary" one for the same message. itemId is already the real
* identity within an account - including collectionId here would let the
* same email be counted twice instead of properly fused.
*/
function refKey(ref: SourceRef): string {
return `${ref.product}:${ref.accountId}:${ref.itemId}:${ref.chunkIx}`;
}
export function reciprocalRankFusion(
legs: Scored<SourceRef>[][],
limit: number,
): Scored<SourceRef>[] {
const fused = new Map<string, { ref: SourceRef; score: number }>();
for (const leg of legs) {
leg.forEach((hit, index) => {
const key = refKey(hit.ref);
const rank = index + 1;
const contribution = 1 / (RRF_K + rank);
const existing = fused.get(key);
if (existing) {
existing.score += contribution;
} else {
fused.set(key, { ref: hit.ref, score: contribution });
}
});
}
return [...fused.values()]
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
+189
View File
@@ -0,0 +1,189 @@
// The `server` embedding leg of retrieval (docs/AI-ASSISTANT-CONCEPT.md §7
// step 2, §8.1's mail RetrieverAdapter) — real JMAP fetch, real embeddings
// via Ollama's /api/embed, real cosine similarity. No mocked vectors
// anywhere in this file.
//
// Persistence: in-memory only, per server process, keyed by accountId, with
// a TTL that triggers a full re-embed. A real persistent vector store with
// incremental updates (the doc's own P4 phase) is real follow-up work, not
// a same-night stretch goal on top of everything else built tonight — this
// is honest about that rather than pretending otherwise.
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
import type { Chunk, Scored, SourceRef } from './types';
const EMBED_MODEL = process.env.AI_EMBED_MODEL || 'nomic-embed-text';
const MAX_EMAILS = 200;
const CACHE_TTL_MS = 5 * 60 * 1000;
const MAX_CHUNK_CHARS = 1000;
interface CachedEntry {
ref: SourceRef;
title: string;
text: string;
vector: number[];
}
interface CacheRecord {
builtAt: number;
entries: CachedEntry[];
}
// globalThis-stashed like entitlement.ts/config-manager.ts, so dev HMR
// doesn't silently start re-embedding on every hot reload.
const CACHE_KEY = Symbol.for('vncmail.ai.mail-embeddings-cache');
type GlobalWithCache = typeof globalThis & { [CACHE_KEY]?: Map<string, CacheRecord> };
function getCache(): Map<string, CacheRecord> {
const g = globalThis as GlobalWithCache;
if (!g[CACHE_KEY]) g[CACHE_KEY] = new Map();
return g[CACHE_KEY];
}
async function embed(texts: string[]): Promise<number[][]> {
const baseUrl = process.env.AI_SERVER_BASE_URL;
if (!baseUrl) throw new Error('AI_SERVER_BASE_URL is not configured');
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/embed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: EMBED_MODEL, input: texts }),
});
if (!res.ok) throw new Error(`embedding runtime returned ${res.status}`);
const body = (await res.json()) as { embeddings?: number[][] };
if (!body.embeddings || body.embeddings.length !== texts.length) {
throw new Error('embedding runtime returned an unexpected shape');
}
return body.embeddings;
}
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}
interface JmapEmail {
id: string;
mailboxIds?: Record<string, boolean>;
subject?: string;
preview?: string;
receivedAt?: string;
}
async function fetchRecentMail(serverUrl: string, authHeader: string): Promise<{ accountId: string; emails: JmapEmail[] }> {
const session = await fetchJmapSession(serverUrl, authHeader);
if (!session) throw new Error('no JMAP session');
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!accountId) throw new Error('no primary mail account');
const apiUrl = rebaseApiUrl(session, serverUrl);
if (!apiUrl) throw new Error('session advertises no usable apiUrl');
// Back-reference: Email/get's #ids resolves against the previous call's
// result within the same request, one round trip instead of two.
const res = await postJmap(apiUrl, authHeader, JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/query', {
accountId,
sort: [{ property: 'receivedAt', isAscending: false }],
limit: MAX_EMAILS,
}, '0'],
['Email/get', {
accountId,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: ['id', 'mailboxIds', 'subject', 'preview', 'receivedAt'],
}, '1'],
],
}));
if (!res.ok) throw new Error(`Email/get returned ${res.status}`);
const payload = await res.json() as {
methodResponses?: [string, { list?: JmapEmail[] }, string][];
};
const getResult = payload.methodResponses?.find((r) => r[2] === '1');
if (!getResult || getResult[0] !== 'Email/get') throw new Error('Email/get failed');
return { accountId, emails: getResult[1]?.list ?? [] };
}
/**
* Rebuild (or return the cached) embedding index for this account. Real
* work only happens on a cache miss/expiry — repeated questions in the same
* session don't re-embed everything.
*/
async function getOrBuildCache(accountId: string, serverUrl: string, authHeader: string): Promise<CacheRecord> {
const cache = getCache();
const existing = cache.get(accountId);
if (existing && Date.now() - existing.builtAt < CACHE_TTL_MS) return existing;
const { emails } = await fetchRecentMail(serverUrl, authHeader);
const candidates = emails
.map((email) => {
const text = `${email.subject ?? ''}\n${email.preview ?? ''}`.slice(0, MAX_CHUNK_CHARS).trim();
const collectionId = Object.keys(email.mailboxIds ?? {})[0] ?? 'unknown';
return { email, text, collectionId };
})
.filter((c) => c.text.length > 0);
if (candidates.length === 0) {
const empty: CacheRecord = { builtAt: Date.now(), entries: [] };
cache.set(accountId, empty);
return empty;
}
const vectors = await embed(candidates.map((c) => c.text));
const entries: CachedEntry[] = candidates.map((c, i) => ({
ref: { product: 'mail', accountId, collectionId: c.collectionId, itemId: c.email.id, chunkIx: 0 },
title: c.email.subject || '(no subject)',
text: c.text,
vector: vectors[i],
}));
const record: CacheRecord = { builtAt: Date.now(), entries };
cache.set(accountId, record);
return record;
}
export async function serverSearchMail(
serverUrl: string,
authHeader: string,
query: string,
limit: number,
): Promise<Scored<SourceRef>[]> {
const session = await fetchJmapSession(serverUrl, authHeader);
const accountId = session?.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!accountId) throw new Error('no primary mail account');
const record = await getOrBuildCache(accountId, serverUrl, authHeader);
if (record.entries.length === 0) return [];
const [queryVector] = await embed([query]);
return record.entries
.map((entry) => ({ ref: entry.ref, score: cosineSimilarity(queryVector, entry.vector) }))
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
export async function hydrateMailRefs(
serverUrl: string,
authHeader: string,
refs: SourceRef[],
): Promise<Chunk[]> {
const session = await fetchJmapSession(serverUrl, authHeader);
const accountId = session?.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!accountId) return [];
const record = getCache().get(accountId);
if (!record) return [];
const byItemId = new Map(record.entries.map((e) => [e.ref.itemId, e]));
return refs
.map((ref) => byItemId.get(ref.itemId))
.filter((e): e is CachedEntry => !!e)
.map((e) => ({ ref: e.ref, text: e.text, title: e.title }));
}
+45
View File
@@ -0,0 +1,45 @@
// Retrieval pipeline schema (docs/AI-ASSISTANT-CONCEPT.md §7, §8.1).
//
// The whole point of SourceRef is that nothing past this file needs to know
// what a mailbox is: fusion, budgeting, prompt assembly and citation
// rendering all operate on SourceRef. Adding another product (VNCtalk, per
// the doc's P7) means writing one more RetrieverAdapter, not touching any of
// that shared code.
export type Product = 'mail' | 'talk' | 'files' | 'calendar';
export interface SourceRef {
product: Product;
/** Owning account — personal or group. */
accountId: string;
/** Mailbox · room · drive · calendar. */
collectionId: string;
/** Email · message · file · event. */
itemId: string;
/** Which slice of a long item this chunk covers. */
chunkIx: number;
}
export interface Scored<T> {
ref: T;
score: number;
}
export interface Chunk {
ref: SourceRef;
/** Display-ready text for this chunk, already capped to a safe prompt size. */
text: string;
/** Human-readable label for citations, e.g. an email subject. */
title: string;
}
export interface RetrieverAdapter {
product: Product;
/** The local FTS leg, if this product has one (mail does, via the
* Electron-only encrypted index). Undefined where no local leg exists —
* fusion degrades to a single retriever with no code branch needed. */
localSearch?: (query: string, limit: number) => Promise<Scored<SourceRef>[]>;
/** The server embedding leg. */
serverSearch: (query: string, limit: number) => Promise<Scored<SourceRef>[]>;
hydrate: (refs: SourceRef[]) => Promise<Chunk[]>;
}
+55
View File
@@ -0,0 +1,55 @@
// Shared client/server contract for the AI Assistant feature.
// docs/AI-ASSISTANT-CONCEPT.md §9 (entitlement), §11 (client shape), §12 (P0).
//
// This file defines the schema so it never needs a breaking migration later
// (decision #4 — entitlement from day one, cheap now).
//
// Decisions 2026-08-05 evening simplify the doc's original P1/P2 sequencing
// for now — local-first, nothing metered yet:
// - `local` ships free, always available, no entitlement check at all.
// - `public` is available too, but explicitly UNMONITORED for the moment
// (no seats, no metering, no consent-record backend — §7.3/§9/§10 are
// not built yet). The client-side "this leaves the organisation"
// acknowledgement still shows (cheap, honest), it just isn't
// server-enforced yet.
// - `server` (VNC-hosted, EU/CH) is now wired up for real too (added later
// the same night, per "do it this night - no stop"): a real server-side
// proxy (app/api/ai/server/*) to AI_SERVER_BASE_URL, which stands in for
// the dev-k8s-hosted instance until that exists tomorrow. Unlike
// `local`/`public`, `server` IS entitlement-enforced for real —
// lib/ai/entitlement.ts — since it's the one class with a real,
// centrally-borne cost.
export type AiClass = 'local' | 'server' | 'public';
export interface AiEntitlement {
licensed: boolean;
subject: 'user' | 'tenant';
tier: 'base' | 'standard' | 'pro';
classes: AiClass[];
expiresAt: string | null;
graceUntil: string | null;
}
export interface AiPolicy {
/** Admin FeatureGates.aiAssistantEnabled — the tab is hidden entirely below this. */
enabled: boolean;
entitlement: AiEntitlement;
/** Public-model consent text version currently in force (§7.3). Unset until P2. */
publicConsentVersion: string | null;
}
export const DEFAULT_AI_ENTITLEMENT: AiEntitlement = {
licensed: true,
subject: 'tenant',
tier: 'base',
classes: ['local', 'public'],
expiresAt: null,
graceUntil: null,
};
export const DEFAULT_AI_POLICY: AiPolicy = {
enabled: false,
entitlement: { ...DEFAULT_AI_ENTITLEMENT },
publicConsentVersion: null,
};
+5 -5
View File
@@ -40,15 +40,15 @@ export function createDemoEmails(): Email[] {
keywords: {},
size: 4200,
receivedAt: demoDate(0, -2),
from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }],
from: [{ name: 'VNCmail+ Team', email: 'welcome@vncmail.example' }],
to: [USER],
subject: 'Welcome to Bulwark Mail!',
subject: 'Welcome to VNCmail+!',
sentAt: demoDate(0, -2),
preview: 'Thanks for trying out Bulwark Mail. This is a demo environment where you can explore all features...',
preview: 'Thanks for trying out VNCmail+. This is a demo environment where you can explore all features...',
hasAttachment: false,
...bodies(
'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!',
'<div><h2>Welcome to Bulwark Mail!</h2><p>Thanks for trying out Bulwark Mail!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>',
'Thanks for trying out VNCmail+!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!',
'<div><h2>Welcome to VNCmail+!</h2><p>Thanks for trying out VNCmail+!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>',
),
messageId: '<welcome@demo.bulwark.email>',
},
+2 -2
View File
@@ -6,8 +6,8 @@ export function createDemoIdentities(): Identity[] {
id: 'demo-identity-primary',
name: 'Demo User',
email: 'demo@example.com',
textSignature: 'Best regards,\nDemo User\nBulwark Mail Demo',
htmlSignature: '<p>Best regards,<br><b>Demo User</b><br>Bulwark Mail Demo</p>',
textSignature: 'Best regards,\nDemo User\nVNCmail+ Demo',
htmlSignature: '<p>Best regards,<br><b>Demo User</b><br>VNCmail+ Demo</p>',
mayDelete: false,
},
{
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { strayIdsAfterCatchUp } from '../reindex';
describe('strayIdsAfterCatchUp', () => {
it('removes locally-indexed ids absent from a complete (uncapped) fetch', () => {
// Contacts/files have no date filter, so a catch-up query that comes back
// under the cap IS the whole account - anything indexed but missing from
// it was deleted. This is the only place a JMAP `destroyed` ever reaches
// these two content types, since nothing in the renderer populates
// reindex's `removed` field.
const existing = new Set(['a', 'b', 'c']);
expect(strayIdsAfterCatchUp(existing, ['a', 'c'], 2, 2_000)).toEqual(['b']);
});
it('does nothing when the fetch is empty but so is the local index', () => {
expect(strayIdsAfterCatchUp(new Set(), [], 0, 2_000)).toEqual([]);
});
it('never removes anything when the query hit its cap - a truncated page is not the whole world', () => {
// Exactly the case that would otherwise delete objects that are still
// live: an account with >= cap contacts/files, where "missing from this
// page" only means "not on this page", not "gone".
const existing = new Set(['a', 'b', 'c']);
expect(strayIdsAfterCatchUp(existing, ['a'], 2_000, 2_000)).toEqual([]);
});
it('is a no-op when nothing is stray', () => {
const existing = new Set(['a', 'b']);
expect(strayIdsAfterCatchUp(existing, ['a', 'b', 'c'], 3, 2_000)).toEqual([]);
});
});
+47 -1
View File
@@ -4,8 +4,9 @@ import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { isSqlcipherAvailable } from '../binding';
import type { SqlcipherConstructor, SqlcipherDatabase, SqlcipherStatement } from '../binding';
import { accountFileToken, getStoreDir, indexDbPath, STORE_DIR_ENV } from '../paths';
import { MailIndex, toFtsMatchQuery, type IndexDoc } from '../store';
import { MailIndex, openKeyed, toFtsMatchQuery, type IndexDoc } from '../store';
describe('toFtsMatchQuery', () => {
it('quotes every token so FTS5 operators in user input cannot break the query', () => {
@@ -78,6 +79,51 @@ describe('paths', () => {
});
});
/** A fake `SqlcipherDatabase` whose `pragma()` is driven by the given handler. */
function fakeSqlcipherCtor(
pragmaHandler: (source: string) => unknown,
): { ctor: SqlcipherConstructor; instances: Array<{ closeCalls: number }> } {
const instances: Array<{ closeCalls: number }> = [];
function FakeDatabase(this: unknown, _path?: string): SqlcipherDatabase {
const state = { closeCalls: 0 };
instances.push(state);
const stmt: SqlcipherStatement = { run: () => ({ changes: 0, lastInsertRowid: 0 }), get: () => undefined, all: () => [] };
const db: SqlcipherDatabase = {
exec: () => {},
prepare: () => stmt,
pragma: pragmaHandler,
close: () => { state.closeCalls += 1; },
};
return db;
}
return { ctor: FakeDatabase as unknown as SqlcipherConstructor, instances };
}
describe('openKeyed', () => {
it('closes the connection before rethrowing when a pragma AFTER assertEncrypted fails', () => {
// The bug this guards: `journal_mode = WAL` (or any pragma after the key
// check) throwing must not leak the native handle - a `try` that only
// wrapped SOME of these calls previously let exactly this escape.
const { ctor, instances } = fakeSqlcipherCtor((source) => {
if (source === 'cipher_version') return [{ cipher_version: 'fake-4.5.0' }];
if (source === 'journal_mode = WAL') throw new Error('simulated pragma failure');
return undefined;
});
expect(() => openKeyed(ctor, '/fake/path.db', randomBytes(32))).toThrow(/simulated pragma failure/);
expect(instances).toHaveLength(1);
expect(instances[0].closeCalls).toBe(1);
});
it('tolerates the extra close when assertEncrypted itself is the failure (its own close is idempotent)', () => {
// assertEncrypted closes on its own failure before throwing; openKeyed's
// catch then calls close again. That second call must be harmless, not a
// new crash - hence >= 1 rather than a fixed count.
const { ctor, instances } = fakeSqlcipherCtor((source) => (source === 'cipher_version' ? [] : undefined));
expect(() => openKeyed(ctor, '/fake/path.db', randomBytes(32))).toThrow(/no SQLCipher support/);
expect(instances[0].closeCalls).toBeGreaterThanOrEqual(1);
});
});
function doc(overrides: Partial<IndexDoc> = {}): IndexDoc {
return {
jmapAccountId: 'acc1',
+59 -6
View File
@@ -106,6 +106,26 @@ function isoDaysFromNow(days: number): string {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
}
/**
* Which locally-indexed ids are stray after an uncapped (contact/file)
* catch-up fetch, and therefore safe to remove as deleted.
*
* Only safe when `queriedCount < cap`: a query that hit the cap was
* truncated - "the rest weren't asked for", not "the rest are gone" - and
* treating a truncated page as the whole world would delete objects that are
* still live. Exported for unit testing; the database-touching caller is not.
*/
export function strayIdsAfterCatchUp(
existingIds: ReadonlySet<string>,
fetchedIds: readonly string[],
queriedCount: number,
cap: number,
): string[] {
if (queriedCount >= cap) return [];
const fetched = new Set(fetchedIds);
return [...existingIds].filter((id) => !fetched.has(id));
}
/**
* Which types this session can actually index. Calendar/contacts are session
* capabilities; files is a PER-ACCOUNT capability (a server can advertise
@@ -151,8 +171,19 @@ interface FetchArgs {
ids: readonly string[] | null;
}
interface FetchResult {
docs: IndexDoc[];
/**
* How many ids the type's OWN query returned, before any `Foo/get`
* chunking. Only set when `ids === null` (a catch-up fetch); used to tell a
* complete uncapped fetch apart from one truncated at its cap - see
* `strayIdsAfterCatchUp`, the only consumer.
*/
queriedCount?: number;
}
/** Fetches and flattens one content type. `ids === null` means "the recent window". */
async function fetchDocs(contentType: ContentType, args: FetchArgs): Promise<IndexDoc[]> {
async function fetchDocs(contentType: ContentType, args: FetchArgs): Promise<FetchResult> {
const { session, authHeader, jmapAccountId, ids } = args;
switch (contentType) {
@@ -170,7 +201,7 @@ async function fetchDocs(contentType: ContentType, args: FetchArgs): Promise<Ind
);
for (const email of emails) docs.push(extractMail(jmapAccountId, email));
}
return docs;
return { docs, queriedCount: ids ? undefined : targetIds.length };
}
case 'calendar': {
const targetIds = ids ?? await queryCalendarEventIds(
@@ -185,7 +216,7 @@ async function fetchDocs(contentType: ContentType, args: FetchArgs): Promise<Ind
);
for (const event of events) docs.push(extractCalendarEvent(jmapAccountId, event));
}
return docs;
return { docs, queriedCount: ids ? undefined : targetIds.length };
}
case 'contact': {
const targetIds = ids ?? await queryContactIds(session, authHeader, jmapAccountId, CONTACTS_MAX);
@@ -196,7 +227,7 @@ async function fetchDocs(contentType: ContentType, args: FetchArgs): Promise<Ind
);
for (const card of cards) docs.push(extractContact(jmapAccountId, card));
}
return docs;
return { docs, queriedCount: ids ? undefined : targetIds.length };
}
case 'file': {
const targetIds = ids ?? await queryFileIds(session, authHeader, jmapAccountId, FILES_MAX);
@@ -208,10 +239,11 @@ async function fetchDocs(contentType: ContentType, args: FetchArgs): Promise<Ind
}
// Paths need the whole set in hand, so this one can't stream per chunk.
const paths = buildFilePaths(nodes);
return nodes
const docs = nodes
// Directories are indexed too: "what's in the Invoices folder" is a
// real query, and a folder row is a few bytes.
.map((node) => extractFile(jmapAccountId, node, { path: paths.get(node.id) }));
return { docs, queriedCount: ids ? undefined : targetIds.length };
}
}
}
@@ -292,7 +324,7 @@ export async function runIndex(
? requestedIds.slice(0, MAX_IDS_PER_CALL)
: null;
const docs = await fetchDocs(contentType, {
const { docs, queriedCount } = await fetchDocs(contentType, {
session, authHeader: indexSession.authHeader, jmapAccountId, ids,
});
written[contentType] = index.upsert(docs);
@@ -302,6 +334,27 @@ export async function runIndex(
// contacts have no date, and file rows are metadata-sized.
index.pruneOlderThan(jmapAccountId, 'mail', isoDaysFromNow(-INDEX_WINDOW_DAYS));
}
// Contact/file DELETES: a JMAP `destroyed` only ever reaches this
// route via `req.removed`, which nothing in the renderer populates
// today - so without this, a deleted contact or file stays
// searchable (and retrievable by the AI feature) forever. Mail and
// calendar can't use the same trick: their queries are windowed by
// date, so an id missing from one fetch may simply be outside the
// window, not gone. Contacts/files have no date filter at all - the
// query is "the first N, capped" - so when a catch-up fetch (ids
// === null) comes back under the cap, it IS the complete set, and
// anything indexed but absent from it is safely known to be deleted.
if (queriedCount !== undefined && (contentType === 'contact' || contentType === 'file')) {
const cap = contentType === 'contact' ? CONTACTS_MAX : FILES_MAX;
const stale = strayIdsAfterCatchUp(
index.existingIds(jmapAccountId, contentType),
docs.map((d) => d.id),
queriedCount,
cap,
);
if (stale.length > 0) index.remove(jmapAccountId, contentType, stale);
}
} catch (error) {
// One unsupported or misbehaving type must not fail the others.
const message = error instanceof Error ? error.message : String(error);
+54 -31
View File
@@ -15,7 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { loadSqlcipher, type SqlcipherDatabase } from './binding';
import { loadSqlcipher, type SqlcipherConstructor, type SqlcipherDatabase } from './binding';
import { dbSiblings, indexDbPath } from './paths';
export const SCHEMA_VERSION = 1;
@@ -137,6 +137,51 @@ export interface OpenOptions {
key: Buffer;
}
/**
* Opens the connection, sets the SQLCipher key, verifies real encryption, and
* applies the fixed pragmas - the sequence both the first attempt and the
* wrong-key retry in `open()` need identically.
*
* On ANY failure the just-opened connection is closed before the error
* propagates. This matters beyond `assertEncrypted`'s own failure (which
* already closes): a bare `db.pragma(...)` throwing - SQLITE_BUSY, a full disk
* on the first WAL write, anything - must not leak the native handle either,
* which a `try` wrapped around only some of these calls previously missed.
*
* Exported so the cleanup guarantee can be unit-tested against a fake
* `SqlcipherDatabase` - a real double failure (wrong key, THEN a pragma
* failure on the freshly rebuilt file) is not practically reproducible
* against the real binding.
*/
export function openKeyed(
Database: SqlcipherConstructor,
dbPath: string,
key: Buffer,
): { db: SqlcipherDatabase; version: number | null } {
const db = new Database(dbPath);
try {
// The key pragma must be the FIRST statement on the connection. Hex form
// means SQLCipher uses these 32 bytes as the raw key with no KDF, which is
// right for a random key (a passphrase would want the KDF).
db.pragma(`key = "x'${key.toString('hex')}'"`);
assertEncrypted(db, dbPath);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
// The offline replica (lib/offline-replica/**) is a SECOND connection to
// this same file, writing disjoint tables. WAL lets a writer and readers
// coexist, but two WRITERS get SQLITE_BUSY immediately without this - and
// both subsystems are driven by the same renderer push handler, so they
// genuinely do overlap.
db.pragma('busy_timeout = 8000');
return { db, version: readSchemaVersion(db) };
} catch (error) {
// Idempotent: assertEncrypted already closed on its own failure, so this
// is a harmless no-op in that case.
try { db.close(); } catch { /* already closed */ }
throw error;
}
}
export class MailIndex {
private constructor(
private readonly db: SqlcipherDatabase,
@@ -163,46 +208,24 @@ export class MailIndex {
const dbPath = indexDbPath(storeDir, accountId);
fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: 0o700 });
let db = new Database(dbPath);
// The key pragma must be the FIRST statement on the connection. Hex form
// means SQLCipher uses these 32 bytes as the raw key with no KDF, which is
// right for a random key (a passphrase would want the KDF).
db.pragma(`key = "x'${key.toString('hex')}'"`);
assertEncrypted(db, dbPath);
// A wrong key surfaces here rather than at open: SQLCipher only reads the
// header lazily. Treat it as "unreadable" and rebuild from scratch - the
// index is derived data, so there is nothing to recover and never anything
// to prompt the user for (the key was never a user secret).
let version: number | null;
let opened: { db: SqlcipherDatabase; version: number | null };
try {
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
// The offline replica (lib/offline-replica/**) is a SECOND connection to
// this same file, writing disjoint tables. WAL lets a writer and readers
// coexist, but two WRITERS get SQLITE_BUSY immediately without this - and
// both subsystems are driven by the same renderer push handler, so they
// genuinely do overlap.
db.pragma('busy_timeout = 8000');
version = readSchemaVersion(db);
opened = openKeyed(Database, dbPath, key);
} catch {
db.close();
// `openKeyed` guarantees the failed connection above is already closed,
// so there is nothing to clean up here before retrying on a fresh file.
for (const f of dbSiblings(dbPath)) {
try { fs.rmSync(f, { force: true }); } catch { /* best effort */ }
}
db = new Database(dbPath);
db.pragma(`key = "x'${key.toString('hex')}'"`);
assertEncrypted(db, dbPath);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
// The offline replica (lib/offline-replica/**) is a SECOND connection to
// this same file, writing disjoint tables. WAL lets a writer and readers
// coexist, but two WRITERS get SQLITE_BUSY immediately without this - and
// both subsystems are driven by the same renderer push handler, so they
// genuinely do overlap.
db.pragma('busy_timeout = 8000');
version = null;
opened = openKeyed(Database, dbPath, key);
opened.version = null; // fresh file - nothing to read
}
const db = opened.db;
let version = opened.version;
if (version !== null && version !== SCHEMA_VERSION) {
// Rebuildable derived data: drop, don't migrate.
+29
View File
@@ -0,0 +1,29 @@
// Single source of truth for "this feature only exists on some platforms" —
// mirrors the same-named module in vncmail-native (mobile), so the AI
// Assistant capability contract (docs/AI-ASSISTANT-CONCEPT.md §3, §11)
// reads identically across both clients.
import { isElectronShell } from '@/lib/electron-bridge';
/**
* The `local` provider class (a loopback Ollama-compatible runtime) needs a
* host process reachable on 127.0.0.1. Electron's main process can fetch
* loopback directly, no CORS constraint. A browser page can too — localhost
* is a trustworthy origin so mixed-content doesn't block it — but only if
* the runtime's own CORS allowlist permits this origin (see
* localLlmNeedsCorsSetup below). Mobile has neither the runtime nor the RAM
* and is a separate codebase (vncmail-native), not reachable from here.
*/
export function supportsLocalLlm(): boolean {
return true; // web or Electron — this codebase is never mobile
}
/**
* True only for the plain-browser case: Electron reaches loopback from its
* main process with no CORS involved at all, so this is specifically the
* "advise the user to set OLLAMA_ORIGINS" case (docs/AI-ASSISTANT-CONCEPT.md
* §3's note under the platform matrix), not a general capability check.
*/
export function localLlmNeedsCorsSetup(): boolean {
return !isElectronShell();
}
@@ -0,0 +1,130 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { webcrypto } from 'node:crypto';
import * as asn1js from 'asn1js';
import * as pkijs from 'pkijs';
import { LocalDevCaProvider } from '../local-dev-provider';
function pemToBer(pem: string): ArrayBuffer {
const b64 = pem.replace(/-----BEGIN[^-]+-----/, '').replace(/-----END[^-]+-----/, '').replace(/\s+/g, '');
const bin = Buffer.from(b64, 'base64');
return bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength);
}
function berToPem(der: ArrayBuffer, label: string): string {
const b64 = Buffer.from(der).toString('base64');
const lines = b64.match(/.{1,64}/g) ?? [];
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`;
}
/** Builds a real, validly-self-signed CSR — the same shape a browser's
* WebCrypto-based plugin code would produce, just done here in Node so the
* test needs no browser. */
async function buildRealCsr(commonName: string): Promise<string> {
const keys = await webcrypto.subtle.generateKey(
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
true,
['sign', 'verify'],
) as CryptoKeyPair;
const csr = new pkijs.CertificationRequest();
csr.version = 0;
csr.subject.typesAndValues = [
new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', value: new asn1js.Utf8String({ value: commonName }) }),
];
await csr.subjectPublicKeyInfo.importKey(keys.publicKey);
await csr.sign(keys.privateKey, 'SHA-256');
return berToPem(csr.toSchema().toBER(false), 'CERTIFICATE REQUEST');
}
describe('LocalDevCaProvider', () => {
let stateDir: string;
beforeEach(async () => {
stateDir = await mkdtemp(path.join(tmpdir(), 'smime-dev-ca-test-'));
process.env.ADMIN_STATE_DIR = stateDir;
});
afterEach(async () => {
delete process.env.ADMIN_STATE_DIR;
await rm(stateDir, { recursive: true, force: true });
});
it('issues a certificate signed by its own CA, honouring server-chosen addresses only', async () => {
const provider = new LocalDevCaProvider();
const csrPem = await buildRealCsr('CN the CSR asked for, should be ignored for SAN purposes');
const issued = await provider.enroll({
csrPem,
addresses: ['alice@example.com', 'alice.alt@example.com'],
commonName: 'Alice Example',
});
expect(issued.certificatePem).toContain('BEGIN CERTIFICATE');
expect(issued.chainPem).toHaveLength(1);
expect(issued.serialNumber).toMatch(/^[0-9a-f]+$/i);
const leaf = pkijs.Certificate.fromBER(pemToBer(issued.certificatePem));
const caCert = pkijs.Certificate.fromBER(pemToBer(issued.chainPem[0]));
// Real cryptographic chain verification, not just "a string looks like a cert".
const chainVerified = await leaf.verify(caCert);
expect(chainVerified).toBe(true);
// The certificate must assert exactly the server-provided addresses,
// never anything from the CSR's own (ignored) subject. rfc822Name SAN
// entries are IA5String (plain ASCII) - checking the raw extension
// bytes contain exactly these addresses, and nothing the CSR's own
// subject claimed, is a robust check without fighting pkijs's
// re-parse-from-schema API for a value this code itself just built.
const sanExt = leaf.extensions?.find((e) => e.extnID === '2.5.29.17');
expect(sanExt).toBeDefined();
const sanRaw = Buffer.from(sanExt!.extnValue.valueBlock.valueHexView).toString('latin1');
expect(sanRaw).toContain('alice@example.com');
expect(sanRaw).toContain('alice.alt@example.com');
expect(sanRaw).not.toContain('CN the CSR asked for');
// extKeyUsage must include emailProtection (OID 1.3.6.1.5.5.7.3.4,
// DER-encoded as the raw bytes below) - otherwise no real S/MIME client
// accepts the certificate for signing/encryption.
const ekuExt = leaf.extensions?.find((e) => e.extnID === '2.5.29.37');
expect(ekuExt).toBeDefined();
const emailProtectionOidDer = Buffer.from([0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x04]);
expect(Buffer.from(ekuExt!.extnValue.valueBlock.valueHexView).includes(emailProtectionOidDer)).toBe(true);
});
it('rejects a CSR with a forged/mismatched signature', async () => {
const provider = new LocalDevCaProvider();
const validPem = await buildRealCsr('Whatever');
// Corrupt one byte in the middle of the base64 body to break the signature
// without breaking PEM framing.
const lines = validPem.split('\n');
const bodyIdx = Math.floor(lines.length / 2);
lines[bodyIdx] = lines[bodyIdx].slice(0, -4) + (lines[bodyIdx].slice(-4) === 'AAAA' ? 'BBBB' : 'AAAA');
const tamperedPem = lines.join('\n');
await expect(
provider.enroll({ csrPem: tamperedPem, addresses: ['x@example.com'], commonName: 'X' }),
).rejects.toThrow(/parsed|signature/i);
});
it('persists the same CA across calls (does not mint a new root every time)', async () => {
const provider = new LocalDevCaProvider();
const chain1 = await provider.getChain();
const chain2 = await provider.getChain();
expect(chain1[0]).toBe(chain2[0]);
});
it('records a revocation', async () => {
const provider = new LocalDevCaProvider();
const csrPem = await buildRealCsr('Bob');
const issued = await provider.enroll({ csrPem, addresses: ['bob@example.com'], commonName: 'Bob' });
await expect(provider.revoke(issued.serialNumber, 'keyCompromise')).resolves.toBeUndefined();
const stateFile = path.join(stateDir, 'smime-dev-ca.json');
const stored = JSON.parse(await (await import('node:fs/promises')).readFile(stateFile, 'utf-8'));
expect(stored.revokedSerials).toContain(issued.serialNumber);
});
});
+12 -1
View File
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs';
import { EjbcaProvider } from './ejbca';
import { LocalDevCaProvider } from './local-dev-provider';
import type { CaProvider } from './types';
export * from './types';
@@ -21,7 +22,17 @@ export function getCaProvider(): CaProvider | null {
function build(): CaProvider | null {
const baseUrl = process.env.SMIME_CA_URL;
if (!baseUrl) return null;
if (!baseUrl) {
// Explicit opt-in only, never a silent fallback: the real EJBCA needs a
// client mTLS credential this environment doesn't have and lives on the
// private dev-k8s network, unreachable from here tonight - see
// local-dev-provider.ts's module header for exactly what this is (and
// isn't) a substitute for.
if (process.env.SMIME_CA_DEV_LOCAL === 'true') {
return new LocalDevCaProvider();
}
return null;
}
// Read from the mounted secret by default (see deploy/k8s/ca/README.md § 5.3).
// Paths are overridable for local development against a throwaway CA.
+232
View File
@@ -0,0 +1,232 @@
// A real, working local CA implementing the same `CaProvider` seam as the
// production EJBCA integration (lib/smime-ca/ejbca.ts) — for when the real
// CA isn't reachable (it needs a client mTLS certificate + password this
// dev environment doesn't have; the real CA lives on the private dev-k8s
// network anyway). This is NOT a mock: it generates a real RSA-2048 root
// key, signs real CSRs into real, correctly-extensioned X.509 certificates
// using pkijs, and every plugin crypto operation (sign/encrypt/decrypt/
// verify) that runs against a cert issued here is exercising the exact same
// code path it would against a production EJBCA-issued cert. The ONLY
// difference from production is who signed the leaf.
//
// Loudly NOT for production: the root key is generated on first use and
// persisted in the app's own state dir, unprotected by an HSM or even a
// passphrase — exactly the kind of shortcut a real CA (§4 of
// deploy/k8s/ca/README.md) exists to avoid. `build()` in lib/smime-ca/index.ts
// only reaches for this when SMIME_CA_DEV_LOCAL=true is explicitly set,
// never as a silent fallback.
import { readFile, writeFile, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import * as asn1js from 'asn1js';
import * as pkijs from 'pkijs';
import { webcrypto } from 'node:crypto';
import { getStatePath, ensureStateDir } from '@/lib/admin/paths';
import { CaError, type CaProvider, type EnrollRequest, type IssuedCertificate, type RevocationReason } from './types';
pkijs.setEngine('node-webcrypto', new pkijs.CryptoEngine({ name: 'node-webcrypto', crypto: webcrypto as Crypto }));
const CA_STATE_FILE = 'smime-dev-ca.json';
const CERT_VALIDITY_DAYS = 397; // matches typical public-CA S/MIME leaf lifetimes
const CA_VALIDITY_YEARS = 5;
interface StoredCa {
privateKeyPkcs8Base64: string;
certificatePem: string;
revokedSerials: string[];
}
function pemToBer(pem: string): ArrayBuffer {
const b64 = pem.replace(/-----BEGIN[^-]+-----/, '').replace(/-----END[^-]+-----/, '').replace(/\s+/g, '');
const bin = Buffer.from(b64, 'base64');
return bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength);
}
function berToPem(der: ArrayBuffer, label: string): string {
const b64 = Buffer.from(der).toString('base64');
const lines = b64.match(/.{1,64}/g) ?? [];
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`;
}
function randomSerial(): asn1js.Integer {
const bytes = webcrypto.getRandomValues(new Uint8Array(16));
bytes[0] &= 0x7f; // keep it a positive INTEGER
return new asn1js.Integer({ valueHex: bytes.buffer });
}
function serialToHex(serial: asn1js.Integer): string {
return Buffer.from(serial.valueBlock.valueHexView).toString('hex');
}
function buildName(commonName: string, org: string): pkijs.AttributeTypeAndValue[] {
return [
new pkijs.AttributeTypeAndValue({
type: '2.5.4.3', // commonName
value: new asn1js.Utf8String({ value: commonName }),
}),
new pkijs.AttributeTypeAndValue({
type: '2.5.4.10', // organizationName
value: new asn1js.Utf8String({ value: org }),
}),
];
}
async function generateCaKeyPair(): Promise<webcrypto.CryptoKeyPair> {
return webcrypto.subtle.generateKey(
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
true,
['sign', 'verify'],
) as Promise<webcrypto.CryptoKeyPair>;
}
async function createSelfSignedCa(): Promise<{ cert: pkijs.Certificate; keys: webcrypto.CryptoKeyPair }> {
const keys = await generateCaKeyPair();
const cert = new pkijs.Certificate();
cert.version = 2;
cert.serialNumber = randomSerial();
cert.issuer.typesAndValues = buildName('VNCmail+ LOCAL DEV S/MIME CA — NOT FOR PRODUCTION', 'VNCmail+ dev');
cert.subject.typesAndValues = buildName('VNCmail+ LOCAL DEV S/MIME CA — NOT FOR PRODUCTION', 'VNCmail+ dev');
const now = new Date();
cert.notBefore.value = now;
cert.notAfter.value = new Date(now.getTime() + CA_VALIDITY_YEARS * 365 * 24 * 60 * 60 * 1000);
// pkijs's own type declarations expect the DOM CryptoKey type; Node's
// webcrypto.CryptoKey is structurally compatible at runtime (verified by
// the passing round-trip tests) but nominally distinct (KeyUsage union
// differs), hence the boundary casts at every pkijs call below.
await cert.subjectPublicKeyInfo.importKey(keys.publicKey as unknown as CryptoKey);
cert.extensions = [
new pkijs.Extension({
extnID: '2.5.29.19', // basicConstraints
critical: true,
extnValue: new pkijs.BasicConstraints({ cA: true, pathLenConstraint: 0 }).toSchema().toBER(false),
}),
new pkijs.Extension({
extnID: '2.5.29.15', // keyUsage: keyCertSign, cRLSign
critical: true,
extnValue: new asn1js.BitString({ valueHex: new Uint8Array([0b00000110]).buffer }).toBER(false),
}),
];
await cert.sign(keys.privateKey as unknown as CryptoKey, 'SHA-256');
return { cert, keys };
}
async function loadOrCreateCa(): Promise<{ cert: pkijs.Certificate; privateKey: webcrypto.CryptoKey; certificatePem: string; revokedSerials: string[] }> {
const path = getStatePath(CA_STATE_FILE);
if (existsSync(path)) {
const stored = JSON.parse(await readFile(path, 'utf-8')) as StoredCa;
const privateKey = await webcrypto.subtle.importKey(
'pkcs8',
Buffer.from(stored.privateKeyPkcs8Base64, 'base64'),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign'],
);
const cert = pkijs.Certificate.fromBER(pemToBer(stored.certificatePem));
return { cert, privateKey, certificatePem: stored.certificatePem, revokedSerials: stored.revokedSerials };
}
const { cert, keys } = await createSelfSignedCa();
const certificatePem = berToPem(cert.toSchema().toBER(false), 'CERTIFICATE');
const pkcs8 = await webcrypto.subtle.exportKey('pkcs8', keys.privateKey);
const stored: StoredCa = {
privateKeyPkcs8Base64: Buffer.from(pkcs8).toString('base64'),
certificatePem,
revokedSerials: [],
};
await ensureStateDir();
const tmp = path + '.tmp';
await writeFile(tmp, JSON.stringify(stored, null, 2), 'utf-8');
await rename(tmp, path);
return { cert, privateKey: keys.privateKey, certificatePem, revokedSerials: [] };
}
async function persistRevocation(serial: string): Promise<void> {
const path = getStatePath(CA_STATE_FILE);
const stored = JSON.parse(await readFile(path, 'utf-8')) as StoredCa;
if (!stored.revokedSerials.includes(serial)) stored.revokedSerials.push(serial);
await ensureStateDir();
const tmp = path + '.tmp';
await writeFile(tmp, JSON.stringify(stored, null, 2), 'utf-8');
await rename(tmp, path);
}
export class LocalDevCaProvider implements CaProvider {
readonly id = 'local-dev';
async enroll(request: EnrollRequest): Promise<IssuedCertificate> {
const ca = await loadOrCreateCa();
let csr: pkijs.CertificationRequest;
try {
csr = pkijs.CertificationRequest.fromBER(pemToBer(request.csrPem));
} catch (cause) {
throw new CaError('CSR could not be parsed', 400, cause);
}
// Proof of possession: the CSR must be signed by the private key
// matching its own public key. This is NOT identity verification (the
// interface's whole point is that identity comes from `request.addresses`,
// never the CSR) - it only confirms the requester actually holds the
// key they're asking to be certified, same as any CA would check.
const verified = await csr.verify().catch(() => false);
if (!verified) {
throw new CaError('CSR signature does not verify against its own public key', 400);
}
const leaf = new pkijs.Certificate();
leaf.version = 2;
leaf.serialNumber = randomSerial();
leaf.issuer.typesAndValues = ca.cert.subject.typesAndValues;
leaf.subject.typesAndValues = buildName(request.commonName, 'VNCmail+ dev');
const now = new Date();
leaf.notBefore.value = now;
leaf.notAfter.value = new Date(now.getTime() + CERT_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
leaf.subjectPublicKeyInfo = csr.subjectPublicKeyInfo;
const sanNames = request.addresses.map((address) => new pkijs.GeneralName({ type: 1, value: address })); // type 1 = rfc822Name
leaf.extensions = [
new pkijs.Extension({
extnID: '2.5.29.19',
critical: true,
extnValue: new pkijs.BasicConstraints({ cA: false }).toSchema().toBER(false),
}),
new pkijs.Extension({
// digitalSignature + nonRepudiation + keyEncipherment
extnID: '2.5.29.15',
critical: true,
extnValue: new asn1js.BitString({ valueHex: new Uint8Array([0b11100000]).buffer }).toBER(false),
}),
new pkijs.Extension({
extnID: '2.5.29.37', // extKeyUsage
critical: false,
extnValue: new pkijs.ExtKeyUsage({ keyPurposes: ['1.3.6.1.5.5.7.3.4'] }).toSchema().toBER(false), // emailProtection
}),
new pkijs.Extension({
extnID: '2.5.29.17', // subjectAltName
critical: false,
extnValue: new pkijs.GeneralNames({ names: sanNames }).toSchema().toBER(false),
}),
];
await leaf.sign(ca.privateKey as unknown as CryptoKey, 'SHA-256');
return {
certificatePem: berToPem(leaf.toSchema().toBER(false), 'CERTIFICATE'),
chainPem: [ca.certificatePem],
serialNumber: serialToHex(leaf.serialNumber),
issuerDn: 'CN=VNCmail+ LOCAL DEV S/MIME CA — NOT FOR PRODUCTION,O=VNCmail+ dev',
notAfter: leaf.notAfter.value.toISOString(),
};
}
async revoke(serialNumber: string, _reason: RevocationReason): Promise<void> {
await persistRevocation(serialNumber);
}
async getChain(): Promise<readonly string[]> {
const ca = await loadOrCreateCa();
return [ca.certificatePem];
}
}
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "التطبيقات الافتراضية",
"description": "اختر ما إذا كانت روابط البريد والتقويم تُفتح في Bulwark. من الناحية التقنية، يسجّل Bulwark نفسه كمعالج بروتوكول لروابط mailto: وwebcal:.",
"description": "اختر ما إذا كانت روابط البريد والتقويم تُفتح في VNCmail+. من الناحية التقنية، يسجّل VNCmail+ نفسه كمعالج بروتوكول لروابط mailto: وwebcal:.",
"unsupported": "هذا المتصفح أو الاتصال لا يدعم التسجيل اليدوي لمعالج البروتوكول. قد يمكنك مع ذلك استخدام تطبيق PWA المثبّت عبر إعدادات المتصفح أو النظام.",
"mailto_label": "روابط البريد الإلكتروني",
"mailto_description": "فتح روابط mailto: في Bulwark مع نافذة إنشاء رسالة معبأة مسبقًا.",
"mailto_description": "فتح روابط mailto: في VNCmail+ مع نافذة إنشاء رسالة معبأة مسبقًا.",
"protocol_open_mode_label": "عند فتح روابط البروتوكول",
"protocol_open_mode_description": "اختر ما إذا كان Bulwark يفتح روابط mailto: وwebcal: في علامة تبويب جديدة أو يعيد استخدام جلسة مفتوحة. يتطلب خيار الجلسة النشطة إذن الإشعارات حتى تتمكن من النقر على إشعار احتياطي لإحضار Bulwark إلى المقدمة إذا منع المتصفح التركيز.",
"protocol_open_mode_description": "اختر ما إذا كان VNCmail+ يفتح روابط mailto: وwebcal: في علامة تبويب جديدة أو يعيد استخدام جلسة مفتوحة. يتطلب خيار الجلسة النشطة إذن الإشعارات حتى تتمكن من النقر على إشعار احتياطي لإحضار VNCmail+ إلى المقدمة إذا منع المتصفح التركيز.",
"protocol_open_mode_active_session": "الفتح في الجلسة النشطة إن أمكن",
"protocol_open_mode_new_tab": "فتح علامة تبويب جديدة دائمًا",
"focus_notification_title": "فتح Bulwark",
"focus_notification_body": "تم فتح الرابط في Bulwark. انقر لإحضار النافذة إلى المقدمة.",
"focus_notification_title": "فتح VNCmail+",
"focus_notification_body": "تم فتح الرابط في VNCmail+. انقر لإحضار النافذة إلى المقدمة.",
"webcal_label": "روابط التقويم",
"webcal_description": "فتح روابط webcal: في Bulwark مع نافذة اشتراك تقويم معبأة مسبقًا.",
"webcal_description": "فتح روابط webcal: في VNCmail+ مع نافذة اشتراك تقويم معبأة مسبقًا.",
"register_mailto": "تسجيل تطبيق البريد",
"register_webcal": "تسجيل تطبيق التقويم",
"mailto_registered": "تم طلب تسجيل معالج البريد الإلكتروني",
@@ -165,7 +165,7 @@
"registration_failed": "فشل تسجيل معالج البروتوكول",
"opening_mailto": "جارٍ فتح نافذة الإنشاء...",
"opening_webcal": "جارٍ فتح التقويم...",
"browser_note": "قد يطلب منك المتصفح أو نظام التشغيل تأكيد ذلك، وقد يتطلب تثبيت Bulwark أولًا قبل إمكانية اختياره كتطبيق افتراضي.",
"browser_note": "قد يطلب منك المتصفح أو نظام التشغيل تأكيد ذلك، وقد يتطلب تثبيت VNCmail+ أولًا قبل إمكانية اختياره كتطبيق افتراضي.",
"select_account_title": "اختر الحساب",
"select_mailto_account": "اختر الحساب الذي سيفتح رابط البريد هذا.",
"select_webcal_account": "اختر الحساب الذي سيفتح رابط التقويم هذا.",
@@ -738,9 +738,9 @@
"app_title": "البريد الإلكتروني",
"reconnecting": "انقطع الاتصال. جارٍ محاولة إعادة الاتصال…",
"rate_limited_title": "مصادقة الخادم مقيّدة مؤقتًا بحد المعدل.",
"rate_limited_detail": "أوقف Bulwark الطلبات في الخلفية مؤقتًا لتجنب الحظر. ستتم إعادة المحاولة خلال {seconds} ثانية.",
"rate_limited_detail": "أوقف VNCmail+ الطلبات في الخلفية مؤقتًا لتجنب الحظر. ستتم إعادة المحاولة خلال {seconds} ثانية.",
"rate_limited_action_title": "تم إيقاف الطلب مؤقتًا لتجنب الحظر.",
"rate_limited_action_detail": "ينتظر Bulwark انتهاء فترة تهدئة الخادم قبل إرسال المزيد من الطلبات الموثّقة. حاول مرة أخرى خلال {seconds} ثانية."
"rate_limited_action_detail": "ينتظر VNCmail+ انتهاء فترة تهدئة الخادم قبل إرسال المزيد من الطلبات الموثّقة. حاول مرة أخرى خلال {seconds} ثانية."
},
"notifications": {
"email_sent": "تم إرسال الرسالة بنجاح",
@@ -1052,9 +1052,9 @@
},
"push": {
"title": "الإشعارات في الخلفية",
"description": "تلقّي إشعارات النظام للبريد الجديد عند إغلاق هذا الموقع. تُسلَّم عبر خادم ترحيل Bulwark؛ ولا يطّلع الترحيل على محتوى البريد أبدًا.",
"description": "تلقّي إشعارات النظام للبريد الجديد عند إغلاق هذا الموقع. تُسلَّم عبر خادم ترحيل VNCmail+؛ ولا يطّلع الترحيل على محتوى البريد أبدًا.",
"relay_label": "خادم ترحيل الإشعارات",
"relay_desc": "يستخدم افتراضيًا خادم ترحيل Bulwark المستضاف. غيّره فقط إذا كنت تستضيف بنفسك.",
"relay_desc": "يستخدم افتراضيًا خادم ترحيل VNCmail+ المستضاف. غيّره فقط إذا كنت تستضيف بنفسك.",
"relay_locked": "تم تعيينه بواسطة المسؤول",
"relay_locked_desc": "تم تعيين رابط خادم الترحيل بواسطة المسؤول ولا يمكن تغييره.",
"relay_placeholder": "https://notifications.relay.example.com",
@@ -1535,10 +1535,10 @@
},
"link_device": {
"title": "ربط تطبيق الجوال",
"description": "سجّل الدخول إلى تطبيق Bulwark Mail للجوال دون كتابة أي شيء. أنشئ رمز QR هنا وامسحه ضوئيًا من شاشة تسجيل الدخول في التطبيق.",
"description": "سجّل الدخول إلى تطبيق VNCmail+ للجوال دون كتابة أي شيء. أنشئ رمز QR هنا وامسحه ضوئيًا من شاشة تسجيل الدخول في التطبيق.",
"generate": "إظهار رمز QR",
"regenerate": "إظهار رمز جديد",
"instructions": "افتح تطبيق Bulwark Mail، اضغط على \"مسح رمز QR\" في شاشة تسجيل الدخول، ووجّه الكاميرا هنا.",
"instructions": "افتح تطبيق VNCmail+، اضغط على \"مسح رمز QR\" في شاشة تسجيل الدخول، ووجّه الكاميرا هنا.",
"expires_in": "تنتهي صلاحية هذا الرمز خلال {seconds} ثانية. يمكن استخدامه مرة واحدة فقط.",
"expired": "انتهت صلاحية هذا الرمز.",
"generating": "جارٍ التوليد…",
@@ -1713,7 +1713,7 @@
"button": "استيراد"
},
"about": {
"title": "بريد Bulwark الإلكتروني"
"title": "بريد VNCmail+ الإلكتروني"
}
},
"sidebar_apps": {
@@ -3055,7 +3055,7 @@
"start_tour": "بدء الجولة"
},
"demo_welcome": {
"title": "مرحبًا بك في بريد Bulwark",
"title": "مرحبًا بك في بريد VNCmail+",
"description": "استكشف عميل بريد ويب متكامل الميزات - مباشرة في متصفحك. تبقى جميع البيانات على جهازك، فلا تتردد في تجربة كل شيء.",
"feature_email": "قراءة الرسائل وكتابتها",
"feature_organize": "الوسوم والنجوم والمجلدات",
+39 -39
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Aplicacions predeterminades",
"description": "Trieu si els enllaços de correu i calendari s'obren al Bulwark. Tècnicament, el Bulwark es registra com a gestor de protocol per als enllaços mailto: i webcal:.",
"description": "Trieu si els enllaços de correu i calendari s'obren al VNCmail+. Tècnicament, el VNCmail+ es registra com a gestor de protocol per als enllaços mailto: i webcal:.",
"unsupported": "Aquest navegador o connexió no admet el registre manual de gestors de protocol. És possible que encara pugueu utilitzar la PWA instal·lada des de la configuració del navegador o del sistema operatiu.",
"mailto_label": "Enllaços de correu",
"mailto_description": "Obre els enllaços mailto: al Bulwark amb el redactor emplenat prèviament.",
"mailto_description": "Obre els enllaços mailto: al VNCmail+ amb el redactor emplenat prèviament.",
"protocol_open_mode_label": "En obrir enllaços de protocol",
"protocol_open_mode_description": "Trieu si el Bulwark obre els enllaços mailto: i webcal: en una pestanya nova o reutilitza una sessió oberta. L'opció de sessió activa necessita permís de notificacions perquè pugueu prémer una notificació alternativa per portar el Bulwark al davant si el navegador bloqueja el focus.",
"protocol_open_mode_description": "Trieu si el VNCmail+ obre els enllaços mailto: i webcal: en una pestanya nova o reutilitza una sessió oberta. L'opció de sessió activa necessita permís de notificacions perquè pugueu prémer una notificació alternativa per portar el VNCmail+ al davant si el navegador bloqueja el focus.",
"protocol_open_mode_active_session": "Obre en la sessió activa si és possible",
"protocol_open_mode_new_tab": "Obre sempre una pestanya nova",
"focus_notification_title": "Obre el Bulwark",
"focus_notification_body": "L'enllaç s'ha obert al Bulwark. Feu clic per portar la finestra al davant.",
"focus_notification_title": "Obre el VNCmail+",
"focus_notification_body": "L'enllaç s'ha obert al VNCmail+. Feu clic per portar la finestra al davant.",
"webcal_label": "Enllaços de calendari",
"webcal_description": "Obre els enllaços webcal: al Bulwark amb el diàleg de subscripció al calendari emplenat prèviament.",
"webcal_description": "Obre els enllaços webcal: al VNCmail+ amb el diàleg de subscripció al calendari emplenat prèviament.",
"register_mailto": "Registra com a aplicació de correu",
"register_webcal": "Registra com a aplicació de calendari",
"mailto_registered": "S'ha sol·licitat el registre del gestor de correu",
@@ -165,7 +165,7 @@
"registration_failed": "No s'ha pogut registrar el gestor de protocol",
"opening_mailto": "Obrint el redactor...",
"opening_webcal": "Obrint el calendari...",
"browser_note": "És possible que el navegador o el sistema operatiu us demani confirmar-ho, i que calgui tenir el Bulwark instal·lat per poder seleccionar-lo com a aplicació predeterminada.",
"browser_note": "És possible que el navegador o el sistema operatiu us demani confirmar-ho, i que calgui tenir el VNCmail+ instal·lat per poder seleccionar-lo com a aplicació predeterminada.",
"select_account_title": "Trieu un compte",
"select_mailto_account": "Trieu quin compte ha d'obrir aquest enllaç de correu.",
"select_webcal_account": "Trieu quin compte ha d'obrir aquest enllaç de calendari.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "S'ha perdut la connexió. S'està intentant reconnectar…",
"rate_limited_title": "L'autenticació al servidor està temporalment limitada.",
"rate_limited_detail": "El Bulwark ha aturat les sol·licituds en segon pla per evitar el bloqueig. Es tornarà a provar d'aquí a {seconds} s.",
"rate_limited_detail": "El VNCmail+ ha aturat les sol·licituds en segon pla per evitar el bloqueig. Es tornarà a provar d'aquí a {seconds} s.",
"rate_limited_action_title": "Sol·licitud aturada per evitar el bloqueig.",
"rate_limited_action_detail": "El Bulwark espera que acabi el temps d'espera del servidor abans d'enviar més sol·licituds autenticades. Torneu-ho a provar d'aquí a {seconds} s."
"rate_limited_action_detail": "El VNCmail+ espera que acabi el temps d'espera del servidor abans d'enviar més sol·licituds autenticades. Torneu-ho a provar d'aquí a {seconds} s."
},
"notifications": {
"email_sent": "Correu enviat correctament",
@@ -1052,9 +1052,9 @@
},
"push": {
"title": "Notificacions en segon pla",
"description": "Rebeu notificacions del sistema per al correu nou quan aquest lloc estigui tancat. S'entreguen mitjançant el repetidor push del Bulwark; el repetidor mai no veu el contingut del correu.",
"description": "Rebeu notificacions del sistema per al correu nou quan aquest lloc estigui tancat. S'entreguen mitjançant el repetidor push del VNCmail+; el repetidor mai no veu el contingut del correu.",
"relay_label": "Repetidor push",
"relay_desc": "Per defecte utilitza el repetidor allotjat del Bulwark. Canvieu-ho només si allotgeu el vostre propi servidor.",
"relay_desc": "Per defecte utilitza el repetidor allotjat del VNCmail+. Canvieu-ho només si allotgeu el vostre propi servidor.",
"relay_locked": "Establert per l'administrador",
"relay_locked_desc": "L'URL del repetidor push l'ha establert l'administrador i no es pot canviar.",
"relay_placeholder": "https://notifications.relay.example.com",
@@ -1535,10 +1535,10 @@
},
"link_device": {
"title": "Enllaça l'aplicació mòbil",
"description": "Inicieu la sessió a l'aplicació mòbil Bulwark Mail sense escriure res. Genereu un codi QR aquí i escanegeu-lo des de la pantalla d'inici de sessió de l'aplicació.",
"description": "Inicieu la sessió a l'aplicació mòbil VNCmail+ sense escriure res. Genereu un codi QR aquí i escanegeu-lo des de la pantalla d'inici de sessió de l'aplicació.",
"generate": "Mostra el codi QR",
"regenerate": "Mostra un codi nou",
"instructions": "Obriu l'aplicació Bulwark Mail, toqueu «Escaneja el codi QR» a la pantalla d'inici de sessió i apunteu la càmera aquí.",
"instructions": "Obriu l'aplicació VNCmail+, toqueu «Escaneja el codi QR» a la pantalla d'inici de sessió i apunteu la càmera aquí.",
"expires_in": "Aquest codi caduca d'aquí a {seconds} segons. Només es pot utilitzar una vegada.",
"expired": "Aquest codi ha caducat.",
"generating": "Generant…",
@@ -1650,34 +1650,34 @@
"error_role": "No s'ha pogut actualitzar el rol de la carpeta"
},
"advanced": {
"title": "Avan\u00e7at",
"description": "Opcions avan\u00e7ades i configuraci\u00f3 per a desenvolupadors",
"title": "Avançat",
"description": "Opcions avançades i configuració per a desenvolupadors",
"debug_mode": {
"label": "Mode de depuraci\u00f3",
"description": "Activa el registre detallat per a la resoluci\u00f3 de problemes"
"label": "Mode de depuració",
"description": "Activa el registre detallat per a la resolució de problemes"
},
"debug_categories": {
"description": "Seleccioneu quines categories registrar. Desactiveu les que no necessiteu per reduir el soroll a la consola.",
"jmap": "Client JMAP",
"jmap_description": "Operacions de b\u00fastia, obtenci\u00f3 de correus i sol\u00b7licituds del protocol JMAP",
"jmap_description": "Operacions de bústia, obtenció de correus i sol·licituds del protocol JMAP",
"calendar": "Calendari",
"calendar_description": "Esdeveniments del calendari, importacions i missatges de programaci\u00f3",
"calendar_description": "Esdeveniments del calendari, importacions i missatges de programació",
"tasks": "Tasques",
"tasks_description": "Creaci\u00f3, obtenci\u00f3 i actualitzaci\u00f3 de tasques del calendari",
"auth": "Autenticaci\u00f3",
"auth_description": "Inici de sessi\u00f3, TOTP, intercanvi de testimonis i gesti\u00f3 de sessions",
"tasks_description": "Creació, obtenció i actualització de tasques del calendari",
"auth": "Autenticació",
"auth_description": "Inici de sessió, TOTP, intercanvi de testimonis i gestió de sessions",
"filters": "Filtres",
"filters_description": "Regles de filtre Sieve i scripts de resposta autom\u00e0tica",
"email": "Visualitzaci\u00f3 de correu",
"email_description": "Renderitzaci\u00f3 de correu, processament TNEF i marcatge com a llegit",
"filters_description": "Regles de filtre Sieve i scripts de resposta automàtica",
"email": "Visualització de correu",
"email_description": "Renderització de correu, processament TNEF i marcatge com a llegit",
"push": "Notificacions push",
"push_description": "Configuraci\u00f3 i entrega de notificacions push",
"push_description": "Configuració i entrega de notificacions push",
"contacts": "Contactes i llibretes d'adreces",
"contacts_description": "Sincronitzaci\u00f3 de contactes, operacions de llibreta d'adreces i remitents de confian\u00e7a"
"contacts_description": "Sincronització de contactes, operacions de llibreta d'adreces i remitents de confiança"
},
"settings_sync": {
"label": "Sincronitzaci\u00f3 de la configuraci\u00f3",
"description": "Sincronitzeu la configuraci\u00f3 entre navegadors i dispositius"
"label": "Sincronització de la configuració",
"description": "Sincronitzeu la configuració entre navegadors i dispositius"
},
"sender_favicons": {
"label": "Icones dels remitents",
@@ -1685,7 +1685,7 @@
},
"show_avatars_in_junk": {
"label": "Mostra els avatars a la carpeta de brossa",
"description": "Mostra imatges de perfil i icones dels remitents a la carpeta de brossa. Desactivat per defecte per evitar donar aparen\u00e7a de legitimitat a intents de pesca electr\u00f2nica."
"description": "Mostra imatges de perfil i icones dels remitents a la carpeta de brossa. Desactivat per defecte per evitar donar aparença de legitimitat a intents de pesca electrònica."
},
"keyboard_shortcuts": {
"label": "Dreceres de teclat",
@@ -1694,26 +1694,26 @@
},
"refresh_cache": {
"label": "Actualitza les dades emmagatzemades",
"description": "Torna a carregar els contactes, els calendaris i les carpetes des del servidor. Mant\u00e9 els comptes i les sessions \u2014 soluciona una vista obsoleta o incorrecta sense tancar la sessi\u00f3.",
"description": "Torna a carregar els contactes, els calendaris i les carpetes des del servidor. Manté els comptes i les sessions soluciona una vista obsoleta o incorrecta sense tancar la sessió.",
"button": "Actualitza"
},
"reset_settings": {
"label": "Restableix la configuraci\u00f3",
"description": "Restaura tota la configuraci\u00f3 als valors per defecte",
"label": "Restableix la configuració",
"description": "Restaura tota la configuració als valors per defecte",
"button": "Restableix als valors per defecte"
},
"export_settings": {
"label": "Exporta la configuraci\u00f3",
"description": "Baixeu la configuraci\u00f3 en format JSON",
"label": "Exporta la configuració",
"description": "Baixeu la configuració en format JSON",
"button": "Exporta"
},
"import_settings": {
"label": "Importa la configuraci\u00f3",
"description": "Carregueu la configuraci\u00f3 des d'un fitxer JSON",
"label": "Importa la configuració",
"description": "Carregueu la configuració des d'un fitxer JSON",
"button": "Importa"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3055,7 +3055,7 @@
"start_tour": "Comença la visita guiada"
},
"demo_welcome": {
"title": "Us donem la benvinguda al Bulwark Mail",
"title": "Us donem la benvinguda al VNCmail+",
"description": "Exploreu un client de correu web complet, directament al navegador. Totes les dades es queden al vostre dispositiu, així que proveu-ho tot sense cap problema.",
"feature_email": "Llegiu i redacteu correus",
"feature_organize": "Etiquetes, estrelles i carpetes",
+13 -13
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Výchozí aplikace",
"description": "Zvolte, zda se mají e-mailové a kalendářové odkazy otevírat v Bulwarku. Technicky se Bulwark registruje jako obslužná aplikace protokolu pro odkazy mailto: a webcal:.",
"description": "Zvolte, zda se mají e-mailové a kalendářové odkazy otevírat v VNCmail+u. Technicky se VNCmail+ registruje jako obslužná aplikace protokolu pro odkazy mailto: a webcal:.",
"unsupported": "Tento prohlížeč nebo toto připojení nepodporuje ruční registraci obslužné aplikace protokolu. Nainstalovanou PWA můžete případně použít přes nastavení prohlížeče nebo systému.",
"mailto_label": "E-mailové odkazy",
"mailto_description": "Otevře odkazy mailto: v Bulwarku s předvyplněným editorem zprávy.",
"mailto_description": "Otevře odkazy mailto: v VNCmail+u s předvyplněným editorem zprávy.",
"protocol_open_mode_label": "Při otevírání odkazů protokolů",
"protocol_open_mode_description": "Zvolte, zda má Bulwark otevírat odkazy mailto: a webcal: v nové kartě, nebo znovu použít otevřenou relaci. Volba aktivní relace vyžaduje oprávnění k oznámením, abyste mohli kliknout na záložní oznámení a přenést Bulwark do popředí, pokud prohlížeč blokuje fokus.",
"protocol_open_mode_description": "Zvolte, zda má VNCmail+ otevírat odkazy mailto: a webcal: v nové kartě, nebo znovu použít otevřenou relaci. Volba aktivní relace vyžaduje oprávnění k oznámením, abyste mohli kliknout na záložní oznámení a přenést VNCmail+ do popředí, pokud prohlížeč blokuje fokus.",
"protocol_open_mode_active_session": "Otevřít v aktivní relaci, pokud je to možné",
"protocol_open_mode_new_tab": "Vždy otevřít novou kartu",
"focus_notification_title": "Otevřít Bulwark",
"focus_notification_body": "Odkaz byl otevřen v Bulwarku. Kliknutím přenesete okno do popředí.",
"focus_notification_title": "Otevřít VNCmail+",
"focus_notification_body": "Odkaz byl otevřen v VNCmail+u. Kliknutím přenesete okno do popředí.",
"webcal_label": "Kalendářové odkazy",
"webcal_description": "Otevře odkazy webcal: v Bulwarku s předvyplněným dialogem pro odběr kalendáře.",
"webcal_description": "Otevře odkazy webcal: v VNCmail+u s předvyplněným dialogem pro odběr kalendáře.",
"register_mailto": "Registrovat e-mailovou aplikaci",
"register_webcal": "Registrovat kalendářovou aplikaci",
"mailto_registered": "Registrace obsluhy e-mailových odkazů byla vyžádána",
@@ -165,7 +165,7 @@
"registration_failed": "Registrace obslužné aplikace protokolu selhala",
"opening_mailto": "Otevírá se editor...",
"opening_webcal": "Otevírá se kalendář...",
"browser_note": "Prohlížeč nebo operační systém vás může požádat o potvrzení a může vyžadovat, aby byl Bulwark nainstalovaný, než jej půjde vybrat jako výchozí aplikaci.",
"browser_note": "Prohlížeč nebo operační systém vás může požádat o potvrzení a může vyžadovat, aby byl VNCmail+ nainstalovaný, než jej půjde vybrat jako výchozí aplikaci.",
"select_account_title": "Vybrat účet",
"select_mailto_account": "Vyberte účet, ve kterém se má tento e-mailový odkaz otevřít.",
"select_webcal_account": "Vyberte účet, ve kterém se má tento kalendářový odkaz otevřít.",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "Toto zařízení přestane přijímat upozornění, když je web zavřený.",
"confirm_disable_title": "Zakázat oznámení na pozadí?",
"description": "Přijímat systémová oznámení o nové poště, když je tento web zavřený. Doručováno přes push relay Bulwark; relay nikdy nevidí obsah pošty.",
"description": "Přijímat systémová oznámení o nové poště, když je tento web zavřený. Doručováno přes push relay VNCmail+; relay nikdy nevidí obsah pošty.",
"disable": "Zakázat",
"enable": "Povolit",
"ios_hint": "Na iOS nejprve nainstalujte web na domovskou obrazovku Safari doručuje Web Push pouze nainstalovaným PWA.",
"reenable": "Znovu zaregistrovat",
"relay_desc": "Výchozí je hostovaný relay Bulwark. Změňte pouze pokud používáte vlastní hosting.",
"relay_desc": "Výchozí je hostovaný relay VNCmail+. Změňte pouze pokud používáte vlastní hosting.",
"relay_label": "Push relay",
"relay_locked": "Nastaveno administrátorem",
"relay_locked_desc": "URL push relay byla nastavena administrátorem a nelze ji změnit.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Propojit mobilní aplikaci",
"description": "Přihlaste se do mobilní aplikace Bulwark Mail bez psaní. Vygenerujte zde QR kód a naskenujte jej na přihlašovací obrazovce aplikace.",
"description": "Přihlaste se do mobilní aplikace VNCmail+ bez psaní. Vygenerujte zde QR kód a naskenujte jej na přihlašovací obrazovce aplikace.",
"generate": "Zobrazit QR kód",
"regenerate": "Zobrazit nový kód",
"instructions": "Otevřete aplikaci Bulwark Mail, na přihlašovací obrazovce klepněte na \"Naskenovat QR kód\" a namiřte fotoaparát sem.",
"instructions": "Otevřete aplikaci VNCmail+, na přihlašovací obrazovce klepněte na \"Naskenovat QR kód\" a namiřte fotoaparát sem.",
"expires_in": "Tento kód vyprší za {seconds} sekund. Lze jej použít pouze jednou.",
"expired": "Platnost tohoto kódu vypršela.",
"generating": "Generování…",
@@ -1706,7 +1706,7 @@
"button": "Importovat"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "Spustit průvodce"
},
"demo_welcome": {
"title": "Vítejte v Bulwark Mail",
"title": "Vítejte v VNCmail+",
"description": "Prozkoumejte plně funkčního webového e-mailového klienta - přímo v prohlížeči. Všechna data zůstávají na vašem zařízení, takže můžete bez obav vše otestovat.",
"feature_email": "Čtěte a pište e-maily",
"feature_organize": "Štítky, hvězdičky a složky",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Standardapps",
"description": "Vælg om e-mail- og kalenderlinks åbnes i Bulwark. Teknisk set registrerer Bulwark sig som protokolhåndtering for mailto:- og webcal:-links.",
"description": "Vælg om e-mail- og kalenderlinks åbnes i VNCmail+. Teknisk set registrerer VNCmail+ sig som protokolhåndtering for mailto:- og webcal:-links.",
"unsupported": "Denne browser eller forbindelse understøtter ikke manuel registrering af protokolhåndtering. Du kan muligvis stadig bruge den installerede PWA via browser- eller OS-indstillinger.",
"mailto_label": "E-mail-links",
"mailto_description": "Åbn mailto:-links i Bulwark med en forudfyldt komponist.",
"mailto_description": "Åbn mailto:-links i VNCmail+ med en forudfyldt komponist.",
"protocol_open_mode_label": "Ved åbning af protokollinks",
"protocol_open_mode_description": "Vælg om Bulwark åbner mailto:- og webcal:-links i en ny fane eller genbruger en åben session. Indstillingen \\\"aktiv session\\\" kræver notifikationstilladelse, så du kan klikke på en nødnotifikation for at bringe Bulwark frem, hvis browseren blokerer fokus.",
"protocol_open_mode_description": "Vælg om VNCmail+ åbner mailto:- og webcal:-links i en ny fane eller genbruger en åben session. Indstillingen \\\"aktiv session\\\" kræver notifikationstilladelse, så du kan klikke på en nødnotifikation for at bringe VNCmail+ frem, hvis browseren blokerer fokus.",
"protocol_open_mode_active_session": "Åbn i aktiv session hvis muligt",
"protocol_open_mode_new_tab": "Åbn altid ny fane",
"focus_notification_title": "Åbn Bulwark",
"focus_notification_body": "Linket blev åbnet i Bulwark. Klik for at bringe vinduet frem.",
"focus_notification_title": "Åbn VNCmail+",
"focus_notification_body": "Linket blev åbnet i VNCmail+. Klik for at bringe vinduet frem.",
"webcal_label": "Kalenderlinks",
"webcal_description": "Åbn webcal:-links i Bulwark med en forudfyldt kalenderabonnementsdialog.",
"webcal_description": "Åbn webcal:-links i VNCmail+ med en forudfyldt kalenderabonnementsdialog.",
"register_mailto": "Registrer e-mail-app",
"register_webcal": "Registrer kalender-app",
"mailto_registered": "Registrering af e-mail-håndtering anmodet",
@@ -165,7 +165,7 @@
"registration_failed": "Registrering af protokolhåndtering mislykkedes",
"opening_mailto": "Åbner komponist...",
"opening_webcal": "Åbner kalender...",
"browser_note": "Din browser eller dit operativsystem beder dig muligvis bekræfte dette og kræver muligvis, at Bulwark er installeret, før det kan vælges som standardapp.",
"browser_note": "Din browser eller dit operativsystem beder dig muligvis bekræfte dette og kræver muligvis, at VNCmail+ er installeret, før det kan vælges som standardapp.",
"select_account_title": "Vælg konto",
"select_mailto_account": "Vælg hvilken konto der skal åbne dette e-mail-link.",
"select_webcal_account": "Vælg hvilken konto der skal åbne dette kalenderlink.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Forbindelse mistet. Forsøger at genoprette forbindelse…",
"rate_limited_title": "Server-godkendelse er midlertidigt hastighedsbegrænset.",
"rate_limited_detail": "Bulwark har sat baggrundsanmodninger på pause for at undgå udelukkelse. Prøver igen om {seconds}s.",
"rate_limited_detail": "VNCmail+ har sat baggrundsanmodninger på pause for at undgå udelukkelse. Prøver igen om {seconds}s.",
"rate_limited_action_title": "Anmodning sat på pause for at undgå udelukkelse.",
"rate_limited_action_detail": "Bulwark venter på, at serverens nedkølingsperiode slutter, før der sendes flere godkendte anmodninger. Prøv igen om {seconds}s."
"rate_limited_action_detail": "VNCmail+ venter på, at serverens nedkølingsperiode slutter, før der sendes flere godkendte anmodninger. Prøv igen om {seconds}s."
},
"notifications": {
"email_sent": "E-mail sendt succesfuldt",
@@ -1052,9 +1052,9 @@
},
"push": {
"title": "Baggrundsnotifikationer",
"description": "Modtag systemnotifikationer for ny mail, når dette site er lukket. Leveres via Bulwark push-relæet; relæet ser aldrig mail-indhold.",
"description": "Modtag systemnotifikationer for ny mail, når dette site er lukket. Leveres via VNCmail+ push-relæet; relæet ser aldrig mail-indhold.",
"relay_label": "Push-relæ",
"relay_desc": "Som standard det hosted Bulwark-relæ. Ændr kun, hvis du selv-host'er.",
"relay_desc": "Som standard det hosted VNCmail+-relæ. Ændr kun, hvis du selv-host'er.",
"relay_locked": "Indstillet af administrator",
"relay_locked_desc": "Push-relæ-URL'en er indstillet af din administrator og kan ikke ændres.",
"relay_placeholder": "https://notifikationer.relay.eksempel.dk",
@@ -1531,10 +1531,10 @@
},
"link_device": {
"title": "Tilknyt mobilapp",
"description": "Log ind i Bulwark Mail-mobilappen uden at skrive noget. Generér en QR-kode her, og scan den fra appens loginskærm.",
"description": "Log ind i VNCmail+-mobilappen uden at skrive noget. Generér en QR-kode her, og scan den fra appens loginskærm.",
"generate": "Vis QR-kode",
"regenerate": "Vis en ny kode",
"instructions": "Åbn Bulwark Mail-appen, tryk på \"Scan QR-kode\" på loginskærmen, og ret dit kamera herhen.",
"instructions": "Åbn VNCmail+-appen, tryk på \"Scan QR-kode\" på loginskærmen, og ret dit kamera herhen.",
"expires_in": "Denne kode udløber om {seconds} sekunder. Den kan kun bruges én gang.",
"expired": "Denne kode er udløbet.",
"generating": "Genererer…",
@@ -1709,7 +1709,7 @@
"button": "Importér"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3055,7 +3055,7 @@
"start_tour": "Start rundvisning"
},
"demo_welcome": {
"title": "Velkommen til Bulwark Mail",
"title": "Velkommen til VNCmail+",
"description": "Udforsk en fuldt udstyret webmailklient - direkte i din browser. Alle data forbliver på din enhed, så du kan teste alt frit.",
"feature_email": "Læs & skriv e-mail",
"feature_organize": "Tags, stjerner & mapper",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Standard-Apps",
"description": "Legen Sie fest, ob E-Mail- und Kalender-Links in Bulwark geöffnet werden. Technisch registriert sich Bulwark dafür als Protokoll-Handler für mailto: und webcal:.",
"description": "Legen Sie fest, ob E-Mail- und Kalender-Links in VNCmail+ geöffnet werden. Technisch registriert sich VNCmail+ dafür als Protokoll-Handler für mailto: und webcal:.",
"unsupported": "Dieser Browser oder diese Verbindung unterstützt die manuelle Registrierung von Protokoll-Handlern nicht. Möglicherweise können Sie die installierte PWA trotzdem über Browser- oder Systemeinstellungen verwenden.",
"mailto_label": "E-Mail-Links",
"mailto_description": "Öffnet mailto:-Links in Bulwark mit vorausgefülltem Editor.",
"mailto_description": "Öffnet mailto:-Links in VNCmail+ mit vorausgefülltem Editor.",
"protocol_open_mode_label": "Beim Öffnen von Protokoll-Links",
"protocol_open_mode_description": "Wähle, ob Bulwark mailto:- und webcal:-Links immer in einem neuen Tab öffnet oder eine offene Sitzung wiederverwendet. Für die aktive Sitzung benötigt Bulwark Benachrichtigungen, damit du das Fenster per Klick in den Vordergrund holen kannst, falls der Browser den Fokus blockiert.",
"protocol_open_mode_description": "Wähle, ob VNCmail+ mailto:- und webcal:-Links immer in einem neuen Tab öffnet oder eine offene Sitzung wiederverwendet. Für die aktive Sitzung benötigt VNCmail+ Benachrichtigungen, damit du das Fenster per Klick in den Vordergrund holen kannst, falls der Browser den Fokus blockiert.",
"protocol_open_mode_active_session": "Wenn möglich in aktiver Sitzung öffnen",
"protocol_open_mode_new_tab": "Immer neuen Tab öffnen",
"focus_notification_title": "Bulwark öffnen",
"focus_notification_body": "Der Link wurde in Bulwark geöffnet. Klicke hier, um das Fenster in den Vordergrund zu holen.",
"focus_notification_title": "VNCmail+ öffnen",
"focus_notification_body": "Der Link wurde in VNCmail+ geöffnet. Klicke hier, um das Fenster in den Vordergrund zu holen.",
"webcal_label": "Kalender-Links",
"webcal_description": "Öffnet webcal:-Links in Bulwark mit vorausgefülltem Kalender-Abo-Dialog.",
"webcal_description": "Öffnet webcal:-Links in VNCmail+ mit vorausgefülltem Kalender-Abo-Dialog.",
"register_mailto": "Als E-Mail-App registrieren",
"register_webcal": "Als Kalender-App registrieren",
"mailto_registered": "Registrierung als E-Mail-Handler angefordert",
@@ -165,7 +165,7 @@
"registration_failed": "Protokoll-Handler konnte nicht registriert werden",
"opening_mailto": "Editor wird geöffnet...",
"opening_webcal": "Kalender wird geöffnet...",
"browser_note": "Ihr Browser oder Betriebssystem kann eine Bestätigung verlangen. Eventuell muss Bulwark installiert sein, bevor es als Standard-App ausgewählt werden kann.",
"browser_note": "Ihr Browser oder Betriebssystem kann eine Bestätigung verlangen. Eventuell muss VNCmail+ installiert sein, bevor es als Standard-App ausgewählt werden kann.",
"select_account_title": "Account auswählen",
"select_mailto_account": "Wähle aus, mit welchem Account dieser E-Mail-Link geöffnet werden soll.",
"select_webcal_account": "Wähle aus, mit welchem Account dieser Kalender-Link geöffnet werden soll.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Verbindung verloren. Verbindung wird wiederhergestellt…",
"rate_limited_title": "Die Serverauthentifizierung ist vorubergehend begrenzt.",
"rate_limited_detail": "Bulwark hat Hintergrundanfragen pausiert, um eine Sperre zu vermeiden. Neuer Versuch in {seconds}s.",
"rate_limited_detail": "VNCmail+ hat Hintergrundanfragen pausiert, um eine Sperre zu vermeiden. Neuer Versuch in {seconds}s.",
"rate_limited_action_title": "Anfrage pausiert, um eine Sperre zu vermeiden.",
"rate_limited_action_detail": "Bulwark wartet, bis die Serverabklingzeit endet, bevor weitere authentifizierte Anfragen gesendet werden. Versuchen Sie es in {seconds}s erneut."
"rate_limited_action_detail": "VNCmail+ wartet, bis die Serverabklingzeit endet, bevor weitere authentifizierte Anfragen gesendet werden. Versuchen Sie es in {seconds}s erneut."
},
"notifications": {
"email_sent": "E-Mail erfolgreich gesendet",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "Dieses Gerät erhält keine Benachrichtigungen mehr, wenn die Seite geschlossen ist.",
"confirm_disable_title": "Hintergrundbenachrichtigungen deaktivieren?",
"description": "Systembenachrichtigungen für neue E-Mails empfangen, wenn diese Seite geschlossen ist. Zustellung über das Bulwark Push-Relay; das Relay sieht niemals E-Mail-Inhalte.",
"description": "Systembenachrichtigungen für neue E-Mails empfangen, wenn diese Seite geschlossen ist. Zustellung über das VNCmail+ Push-Relay; das Relay sieht niemals E-Mail-Inhalte.",
"disable": "Deaktivieren",
"enable": "Aktivieren",
"ios_hint": "Installieren Sie die Seite unter iOS zuerst auf dem Startbildschirm Safari liefert Web Push nur an installierte PWAs.",
"reenable": "Neu registrieren",
"relay_desc": "Standardmäßig wird das gehostete Bulwark-Relay verwendet. Nur ändern, wenn Sie selbst hosten.",
"relay_desc": "Standardmäßig wird das gehostete VNCmail+-Relay verwendet. Nur ändern, wenn Sie selbst hosten.",
"relay_label": "Push-Relay",
"relay_locked": "Vom Administrator festgelegt",
"relay_locked_desc": "Die Push-Relay-URL wurde von Ihrem Administrator festgelegt und kann nicht geändert werden.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Mobile App verknüpfen",
"description": "Melden Sie sich in der Bulwark Mail App an, ohne etwas einzutippen. Erzeugen Sie hier einen QR-Code und scannen Sie ihn auf dem Anmeldebildschirm der App.",
"description": "Melden Sie sich in der VNCmail+ App an, ohne etwas einzutippen. Erzeugen Sie hier einen QR-Code und scannen Sie ihn auf dem Anmeldebildschirm der App.",
"generate": "QR-Code anzeigen",
"regenerate": "Neuen Code anzeigen",
"instructions": "Öffnen Sie die Bulwark Mail App, tippen Sie auf dem Anmeldebildschirm auf \"QR-Code scannen\" und richten Sie die Kamera hierauf.",
"instructions": "Öffnen Sie die VNCmail+ App, tippen Sie auf dem Anmeldebildschirm auf \"QR-Code scannen\" und richten Sie die Kamera hierauf.",
"expires_in": "Dieser Code läuft in {seconds} Sekunden ab. Er kann nur einmal verwendet werden.",
"expired": "Dieser Code ist abgelaufen.",
"generating": "Wird erstellt…",
@@ -1706,7 +1706,7 @@
"button": "Importieren"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "Tour starten"
},
"demo_welcome": {
"title": "Willkommen bei Bulwark Mail",
"title": "Willkommen bei VNCmail+",
"description": "Entdecken Sie einen voll ausgestatteten Webmail-Client - direkt in Ihrem Browser. Alle Daten bleiben auf Ihrem Gerät, also testen Sie alles.",
"feature_email": "E-Mails lesen & verfassen",
"feature_organize": "Tags, Sterne & Ordner",
+16 -16
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Default apps",
"description": "Choose whether email and calendar links open in Bulwark. Technically, Bulwark registers as a protocol handler for mailto: and webcal: links.",
"description": "Choose whether email and calendar links open in VNCmail+. Technically, VNCmail+ registers as a protocol handler for mailto: and webcal: links.",
"unsupported": "This browser or connection does not support manual protocol-handler registration. You may still be able to use the installed PWA via browser or OS settings.",
"mailto_label": "Email links",
"mailto_description": "Open mailto: links in Bulwark with a prefilled composer.",
"mailto_description": "Open mailto: links in VNCmail+ with a prefilled composer.",
"protocol_open_mode_label": "When opening protocol links",
"protocol_open_mode_description": "Choose whether Bulwark opens mailto: and webcal: links in a new tab or reuses an open session. The active-session option needs notification permission so you can click a fallback notification to bring Bulwark to the front if the browser blocks focus.",
"protocol_open_mode_description": "Choose whether VNCmail+ opens mailto: and webcal: links in a new tab or reuses an open session. The active-session option needs notification permission so you can click a fallback notification to bring VNCmail+ to the front if the browser blocks focus.",
"protocol_open_mode_active_session": "Open in active session if possible",
"protocol_open_mode_new_tab": "Always open a new tab",
"focus_notification_title": "Open Bulwark",
"focus_notification_body": "The link was opened in Bulwark. Click to bring the window to the front.",
"focus_notification_title": "Open VNCmail+",
"focus_notification_body": "The link was opened in VNCmail+. Click to bring the window to the front.",
"webcal_label": "Calendar links",
"webcal_description": "Open webcal: links in Bulwark with a prefilled calendar subscription dialog.",
"webcal_description": "Open webcal: links in VNCmail+ with a prefilled calendar subscription dialog.",
"register_mailto": "Register email app",
"register_webcal": "Register calendar app",
"mailto_registered": "Email handler registration requested",
@@ -165,7 +165,7 @@
"registration_failed": "Protocol handler registration failed",
"opening_mailto": "Opening composer...",
"opening_webcal": "Opening calendar...",
"browser_note": "Your browser or operating system may ask you to confirm this and may require Bulwark to be installed before it can be selected as the default app.",
"browser_note": "Your browser or operating system may ask you to confirm this and may require VNCmail+ to be installed before it can be selected as the default app.",
"select_account_title": "Choose account",
"select_mailto_account": "Choose which account should open this email link.",
"select_webcal_account": "Choose which account should open this calendar link.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Connection lost. Attempting to reconnect…",
"rate_limited_title": "Server authentication is temporarily rate limited.",
"rate_limited_detail": "Bulwark has paused background requests to avoid lockout. Retrying in {seconds}s.",
"rate_limited_detail": "VNCmail+ has paused background requests to avoid lockout. Retrying in {seconds}s.",
"rate_limited_action_title": "Request paused to avoid lockout.",
"rate_limited_action_detail": "Bulwark is waiting for the server cooldown to end before sending more authenticated requests. Try again in {seconds}s."
"rate_limited_action_detail": "VNCmail+ is waiting for the server cooldown to end before sending more authenticated requests. Try again in {seconds}s."
},
"notifications": {
"email_sent": "Email sent successfully",
@@ -1052,9 +1052,9 @@
},
"push": {
"title": "Background Notifications",
"description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
"description": "Receive system notifications for new mail when this site is closed. Delivered via the VNCmail+ push relay; the relay never sees mail content.",
"relay_label": "Push relay",
"relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.",
"relay_desc": "Defaults to the hosted VNCmail+ relay. Change only if you self-host.",
"relay_locked": "Set by administrator",
"relay_locked_desc": "The push relay URL has been set by your administrator and cannot be changed.",
"relay_placeholder": "https://notifications.relay.example.com",
@@ -1535,10 +1535,10 @@
},
"link_device": {
"title": "Link Mobile App",
"description": "Sign in to the Bulwark Mail mobile app without typing anything. Generate a QR code here and scan it from the app's login screen.",
"description": "Sign in to the VNCmail+ mobile app without typing anything. Generate a QR code here and scan it from the app's login screen.",
"generate": "Show QR code",
"regenerate": "Show a new code",
"instructions": "Open the Bulwark Mail app, tap \"Scan QR code\" on the login screen, and point your camera here.",
"instructions": "Open the VNCmail+ app, tap \"Scan QR code\" on the login screen, and point your camera here.",
"expires_in": "This code expires in {seconds} seconds. It can only be used once.",
"expired": "This code has expired.",
"generating": "Generating…",
@@ -1694,7 +1694,7 @@
},
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions \u2014 fixes a stale or wrong view without signing out.",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions fixes a stale or wrong view without signing out.",
"button": "Refresh"
},
"reset_settings": {
@@ -1713,7 +1713,7 @@
"button": "Import"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3055,7 +3055,7 @@
"start_tour": "Start Tour"
},
"demo_welcome": {
"title": "Welcome to Bulwark Mail",
"title": "Welcome to VNCmail+",
"description": "Explore a fully-featured webmail client - right in your browser. All data stays on your device, so feel free to test everything.",
"feature_email": "Read & compose email",
"feature_organize": "Tags, stars & folders",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Aplicaciones predeterminadas",
"description": "Elige si los enlaces de correo y calendario se abren en Bulwark. Técnicamente, Bulwark se registra como controlador de protocolo para enlaces mailto: y webcal:.",
"description": "Elige si los enlaces de correo y calendario se abren en VNCmail+. Técnicamente, VNCmail+ se registra como controlador de protocolo para enlaces mailto: y webcal:.",
"unsupported": "Este navegador o esta conexión no admite el registro manual de controladores de protocolo. Es posible que aún puedas usar la PWA instalada desde la configuración del navegador o del sistema.",
"mailto_label": "Enlaces de correo",
"mailto_description": "Abre enlaces mailto: en Bulwark con el redactor rellenado previamente.",
"mailto_description": "Abre enlaces mailto: en VNCmail+ con el redactor rellenado previamente.",
"protocol_open_mode_label": "Al abrir enlaces de protocolo",
"protocol_open_mode_description": "Elige si Bulwark abre los enlaces mailto: y webcal: en una nueva pestaña o reutiliza una sesión abierta. La opción de sesión activa necesita permiso de notificaciones para que puedas hacer clic en una notificación de respaldo y traer Bulwark al frente si el navegador bloquea el foco.",
"protocol_open_mode_description": "Elige si VNCmail+ abre los enlaces mailto: y webcal: en una nueva pestaña o reutiliza una sesión abierta. La opción de sesión activa necesita permiso de notificaciones para que puedas hacer clic en una notificación de respaldo y traer VNCmail+ al frente si el navegador bloquea el foco.",
"protocol_open_mode_active_session": "Abrir en la sesión activa si es posible",
"protocol_open_mode_new_tab": "Abrir siempre una nueva pestaña",
"focus_notification_title": "Abrir Bulwark",
"focus_notification_body": "El enlace se abrió en Bulwark. Haz clic para traer la ventana al frente.",
"focus_notification_title": "Abrir VNCmail+",
"focus_notification_body": "El enlace se abrió en VNCmail+. Haz clic para traer la ventana al frente.",
"webcal_label": "Enlaces de calendario",
"webcal_description": "Abre enlaces webcal: en Bulwark con un diálogo de suscripción al calendario rellenado previamente.",
"webcal_description": "Abre enlaces webcal: en VNCmail+ con un diálogo de suscripción al calendario rellenado previamente.",
"register_mailto": "Registrar aplicación de correo",
"register_webcal": "Registrar aplicación de calendario",
"mailto_registered": "Registro del controlador de correo solicitado",
@@ -165,7 +165,7 @@
"registration_failed": "No se pudo registrar el controlador de protocolo",
"opening_mailto": "Abriendo redactor...",
"opening_webcal": "Abriendo calendario...",
"browser_note": "Tu navegador o sistema operativo puede pedirte confirmación y puede requerir que Bulwark esté instalado antes de poder seleccionarlo como aplicación predeterminada.",
"browser_note": "Tu navegador o sistema operativo puede pedirte confirmación y puede requerir que VNCmail+ esté instalado antes de poder seleccionarlo como aplicación predeterminada.",
"select_account_title": "Elegir cuenta",
"select_mailto_account": "Elige qué cuenta debe abrir este enlace de correo.",
"select_webcal_account": "Elige qué cuenta debe abrir este enlace de calendario.",
@@ -738,9 +738,9 @@
"app_title": "Correo Web",
"reconnecting": "Conexión perdida. Intentando reconectar…",
"rate_limited_title": "La autenticación del servidor está limitada temporalmente.",
"rate_limited_detail": "Bulwark ha pausado las solicitudes en segundo plano para evitar un bloqueo. Reintentando en {seconds}s.",
"rate_limited_detail": "VNCmail+ ha pausado las solicitudes en segundo plano para evitar un bloqueo. Reintentando en {seconds}s.",
"rate_limited_action_title": "Solicitud pausada para evitar el bloqueo.",
"rate_limited_action_detail": "Bulwark está esperando a que termine el enfriamiento del servidor antes de enviar más solicitudes autenticadas. Inténtalo de nuevo en {seconds}s."
"rate_limited_action_detail": "VNCmail+ está esperando a que termine el enfriamiento del servidor antes de enviar más solicitudes autenticadas. Inténtalo de nuevo en {seconds}s."
},
"notifications": {
"email_sent": "Correo enviado exitosamente",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "Este dispositivo dejará de recibir alertas cuando el sitio esté cerrado.",
"confirm_disable_title": "¿Desactivar las notificaciones en segundo plano?",
"description": "Recibe notificaciones del sistema para correo nuevo cuando este sitio está cerrado. Se entrega a través del relay push de Bulwark; el relay nunca ve el contenido del correo.",
"description": "Recibe notificaciones del sistema para correo nuevo cuando este sitio está cerrado. Se entrega a través del relay push de VNCmail+; el relay nunca ve el contenido del correo.",
"disable": "Desactivar",
"enable": "Activar",
"ios_hint": "En iOS, instala primero el sitio en la pantalla de inicio: Safari solo entrega Web Push a PWAs instaladas.",
"reenable": "Volver a registrar",
"relay_desc": "Usa el relay alojado de Bulwark de forma predeterminada. Cámbialo solo si te alojas tú mismo.",
"relay_desc": "Usa el relay alojado de VNCmail+ de forma predeterminada. Cámbialo solo si te alojas tú mismo.",
"relay_label": "Relay push",
"relay_locked": "Establecido por el administrador",
"relay_locked_desc": "La URL del relay push ha sido establecida por tu administrador y no se puede cambiar.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Vincular aplicación móvil",
"description": "Inicie sesión en la aplicación móvil de Bulwark Mail sin escribir nada. Genere un código QR aquí y escanéelo desde la pantalla de inicio de sesión de la aplicación.",
"description": "Inicie sesión en la aplicación móvil de VNCmail+ sin escribir nada. Genere un código QR aquí y escanéelo desde la pantalla de inicio de sesión de la aplicación.",
"generate": "Mostrar código QR",
"regenerate": "Mostrar un código nuevo",
"instructions": "Abra la aplicación Bulwark Mail, toque \"Escanear código QR\" en la pantalla de inicio de sesión y apunte su cámara aquí.",
"instructions": "Abra la aplicación VNCmail+, toque \"Escanear código QR\" en la pantalla de inicio de sesión y apunte su cámara aquí.",
"expires_in": "Este código caduca en {seconds} segundos. Solo se puede usar una vez.",
"expired": "Este código ha caducado.",
"generating": "Generando…",
@@ -1706,7 +1706,7 @@
"button": "Importar"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "Iniciar tour"
},
"demo_welcome": {
"title": "Bienvenido a Bulwark Mail",
"title": "Bienvenido a VNCmail+",
"description": "Explora un cliente de correo web completo - directamente en tu navegador. Todos los datos quedan en tu dispositivo, así que prueba todo.",
"feature_email": "Leer y redactar correos",
"feature_organize": "Etiquetas, estrellas y carpetas",
+10 -10
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "برنامه‌های پیش‌فرض",
"description": "انتخاب کنید لینک‌های ایمیل و تقویم در Bulwark باز شوند.",
"description": "انتخاب کنید لینک‌های ایمیل و تقویم در VNCmail+ باز شوند.",
"unsupported": "این مرورگر از ثبت دستی پروتکل پشتیبانی نمی‌کند.",
"mailto_label": "لینک‌های ایمیل",
"mailto_description": "باز کردن لینک‌های mailto: در Bulwark",
"mailto_description": "باز کردن لینک‌های mailto: در VNCmail+",
"protocol_open_mode_label": "هنگام باز کردن لینک‌های پروتکل",
"protocol_open_mode_description": "انتخاب کنید لینک‌ها در تب جدید باز شوند یا نشست فعال.",
"protocol_open_mode_active_session": "باز کردن در نشست فعال",
"protocol_open_mode_new_tab": "همیشه در تب جدید",
"focus_notification_title": "باز کردن Bulwark",
"focus_notification_body": "لینک در Bulwark باز شد.",
"focus_notification_title": "باز کردن VNCmail+",
"focus_notification_body": "لینک در VNCmail+ باز شد.",
"webcal_label": "لینک‌های تقویم",
"webcal_description": "باز کردن لینک‌های webcal: در Bulwark",
"webcal_description": "باز کردن لینک‌های webcal: در VNCmail+",
"register_mailto": "ثبت برنامه ایمیل",
"register_webcal": "ثبت برنامه تقویم",
"mailto_registered": "ثبت مدیریت ایمیل درخواست شد",
@@ -738,9 +738,9 @@
"app_title": "ایمیل تحت وب",
"reconnecting": "ارتباط قطع شد. در حال تلاش برای اتصال مجدد…",
"rate_limited_title": "احراز هویت سرور موقتاً محدود شده است.",
"rate_limited_detail": "Bulwark درخواست‌های پس‌زمینه را متوقف کرده است. تلاش مجدد در {seconds} ثانیه.",
"rate_limited_detail": "VNCmail+ درخواست‌های پس‌زمینه را متوقف کرده است. تلاش مجدد در {seconds} ثانیه.",
"rate_limited_action_title": "درخواست برای جلوگیری از قفل متوقف شد.",
"rate_limited_action_detail": "Bulwark منتظر پایان زمان انتظار سرور است. در {seconds} ثانیه دوباره تلاش کنید."
"rate_limited_action_detail": "VNCmail+ منتظر پایان زمان انتظار سرور است. در {seconds} ثانیه دوباره تلاش کنید."
},
"notifications": {
"email_sent": "ایمیل با موفقیت ارسال شد",
@@ -1054,7 +1054,7 @@
"title": "اعلان‌های پس‌زمینه",
"description": "دریافت اعلان‌های سیستم برای ایمیل جدید",
"relay_label": "رله push",
"relay_desc": "پیش‌فرض رله میزبانی شده Bulwark",
"relay_desc": "پیش‌فرض رله میزبانی شده VNCmail+",
"relay_locked": "تنظیم شده توسط مدیر",
"relay_locked_desc": "قابل تغییر نیست",
"relay_placeholder": "https://notifications.relay.example.com",
@@ -1713,7 +1713,7 @@
"button": "وارد کردن"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3055,7 +3055,7 @@
"start_tour": "شروع تور"
},
"demo_welcome": {
"title": "به Bulwark Mail خوش آمدید",
"title": "به VNCmail+ خوش آمدید",
"description": "یک سرویس ایمیل کامل را در مرورگر خود کاوش کنید.",
"feature_email": "خواندن و نوشتن ایمیل",
"feature_organize": "برچسب‌ها، ستاره‌ها و پوشه‌ها",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Applications par défaut",
"description": "Choisissez si les liens d'e-mail et de calendrier s'ouvrent dans Bulwark. Techniquement, Bulwark s'enregistre comme gestionnaire de protocole pour les liens mailto: et webcal:.",
"description": "Choisissez si les liens d'e-mail et de calendrier s'ouvrent dans VNCmail+. Techniquement, VNCmail+ s'enregistre comme gestionnaire de protocole pour les liens mailto: et webcal:.",
"unsupported": "Ce navigateur ou cette connexion ne prend pas en charge l'enregistrement manuel des gestionnaires de protocole. Vous pourrez peut-être quand même utiliser la PWA installée via les paramètres du navigateur ou du système.",
"mailto_label": "Liens e-mail",
"mailto_description": "Ouvre les liens mailto: dans Bulwark avec un éditeur prérempli.",
"mailto_description": "Ouvre les liens mailto: dans VNCmail+ avec un éditeur prérempli.",
"protocol_open_mode_label": "À louverture des liens de protocole",
"protocol_open_mode_description": "Choisissez si Bulwark ouvre les liens mailto: et webcal: dans un nouvel onglet ou réutilise une session ouverte. Loption de session active nécessite lautorisation des notifications afin que vous puissiez cliquer sur une notification de secours pour ramener Bulwark au premier plan si le navigateur bloque le focus.",
"protocol_open_mode_description": "Choisissez si VNCmail+ ouvre les liens mailto: et webcal: dans un nouvel onglet ou réutilise une session ouverte. Loption de session active nécessite lautorisation des notifications afin que vous puissiez cliquer sur une notification de secours pour ramener VNCmail+ au premier plan si le navigateur bloque le focus.",
"protocol_open_mode_active_session": "Ouvrir dans la session active si possible",
"protocol_open_mode_new_tab": "Toujours ouvrir un nouvel onglet",
"focus_notification_title": "Ouvrir Bulwark",
"focus_notification_body": "Le lien a été ouvert dans Bulwark. Cliquez pour ramener la fenêtre au premier plan.",
"focus_notification_title": "Ouvrir VNCmail+",
"focus_notification_body": "Le lien a été ouvert dans VNCmail+. Cliquez pour ramener la fenêtre au premier plan.",
"webcal_label": "Liens de calendrier",
"webcal_description": "Ouvre les liens webcal: dans Bulwark avec une boîte de dialogue d'abonnement au calendrier préremplie.",
"webcal_description": "Ouvre les liens webcal: dans VNCmail+ avec une boîte de dialogue d'abonnement au calendrier préremplie.",
"register_mailto": "Enregistrer l'application e-mail",
"register_webcal": "Enregistrer l'application de calendrier",
"mailto_registered": "Enregistrement du gestionnaire d'e-mail demandé",
@@ -165,7 +165,7 @@
"registration_failed": "Échec de l'enregistrement du gestionnaire de protocole",
"opening_mailto": "Ouverture de l'éditeur...",
"opening_webcal": "Ouverture du calendrier...",
"browser_note": "Votre navigateur ou système d'exploitation peut vous demander de confirmer et peut exiger que Bulwark soit installé avant de pouvoir le sélectionner comme application par défaut.",
"browser_note": "Votre navigateur ou système d'exploitation peut vous demander de confirmer et peut exiger que VNCmail+ soit installé avant de pouvoir le sélectionner comme application par défaut.",
"select_account_title": "Choisir un compte",
"select_mailto_account": "Choisissez le compte qui doit ouvrir ce lien e-mail.",
"select_webcal_account": "Choisissez le compte qui doit ouvrir ce lien de calendrier.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Connexion perdue. Tentative de reconnexion…",
"rate_limited_title": "L'authentification du serveur est temporairement limitee.",
"rate_limited_detail": "Bulwark a suspendu les requetes en arriere-plan pour eviter un blocage. Nouvelle tentative dans {seconds}s.",
"rate_limited_detail": "VNCmail+ a suspendu les requetes en arriere-plan pour eviter un blocage. Nouvelle tentative dans {seconds}s.",
"rate_limited_action_title": "Requete suspendue pour eviter le blocage.",
"rate_limited_action_detail": "Bulwark attend la fin du delai impose par le serveur avant d'envoyer d'autres requetes authentifiees. Reessayez dans {seconds}s."
"rate_limited_action_detail": "VNCmail+ attend la fin du delai impose par le serveur avant d'envoyer d'autres requetes authentifiees. Reessayez dans {seconds}s."
},
"notifications": {
"email_sent": "Email envoyé avec succès",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "Cet appareil cessera de recevoir des alertes lorsque le site est fermé.",
"confirm_disable_title": "Désactiver les notifications en arrière-plan ?",
"description": "Recevez des notifications système pour les nouveaux courriers quand ce site est fermé. Livré via le relais push Bulwark ; le relais ne voit jamais le contenu des courriers.",
"description": "Recevez des notifications système pour les nouveaux courriers quand ce site est fermé. Livré via le relais push VNCmail+ ; le relais ne voit jamais le contenu des courriers.",
"disable": "Désactiver",
"enable": "Activer",
"ios_hint": "Sur iOS, installez d'abord le site sur l'écran d'accueil Safari ne livre Web Push qu'aux PWA installées.",
"reenable": "Réenregistrer",
"relay_desc": "Utilise par défaut le relais Bulwark hébergé. Ne le changez que si vous l'hébergez vous-même.",
"relay_desc": "Utilise par défaut le relais VNCmail+ hébergé. Ne le changez que si vous l'hébergez vous-même.",
"relay_label": "Relais push",
"relay_locked": "Défini par l'administrateur",
"relay_locked_desc": "L'URL du relais push a été définie par votre administrateur et ne peut pas être modifiée.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Associer l'application mobile",
"description": "Connectez-vous à l'application mobile Bulwark Mail sans rien saisir. Générez un QR code ici et scannez-le depuis l'écran de connexion de l'application.",
"description": "Connectez-vous à l'application mobile VNCmail+ sans rien saisir. Générez un QR code ici et scannez-le depuis l'écran de connexion de l'application.",
"generate": "Afficher le QR code",
"regenerate": "Afficher un nouveau code",
"instructions": "Ouvrez l'application Bulwark Mail, appuyez sur \"Scanner le QR code\" sur l'écran de connexion et pointez votre caméra ici.",
"instructions": "Ouvrez l'application VNCmail+, appuyez sur \"Scanner le QR code\" sur l'écran de connexion et pointez votre caméra ici.",
"expires_in": "Ce code expire dans {seconds} secondes. Il ne peut être utilisé qu'une seule fois.",
"expired": "Ce code a expiré.",
"generating": "Génération…",
@@ -1706,7 +1706,7 @@
"button": "Importer"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "Démarrer la visite"
},
"demo_welcome": {
"title": "Bienvenue sur Bulwark Mail",
"title": "Bienvenue sur VNCmail+",
"description": "Explorez un client webmail complet - directement dans votre navigateur. Toutes les données restent sur votre appareil, alors testez tout.",
"feature_email": "Lire et rédiger des e-mails",
"feature_organize": "Tags, étoiles et dossiers",
+14 -14
View File
@@ -703,9 +703,9 @@
"app_title": "Webmail",
"reconnecting": "החיבור אבד. מנסה להתחבר מחדש...",
"rate_limited_title": "אימות השרת מוגבל באופן זמני בקצב.",
"rate_limited_detail": "Bulwark השהתה בקשות ברקע כדי למנוע נעילה. מנסה שוב ב-{seconds}s.",
"rate_limited_detail": "VNCmail+ השהתה בקשות ברקע כדי למנוע נעילה. מנסה שוב ב-{seconds}s.",
"rate_limited_action_title": "הבקשה הושהתה כדי למנוע נעילה.",
"rate_limited_action_detail": "Bulwark ממתין עד שקירור השרת יסתיים לפני שליחת בקשות מאומתות יותר. נסה שוב ב-{seconds}s."
"rate_limited_action_detail": "VNCmail+ ממתין עד שקירור השרת יסתיים לפני שליחת בקשות מאומתות יותר. נסה שוב ב-{seconds}s."
},
"notifications": {
"email_sent": "האימייל נשלח בהצלחה",
@@ -1038,9 +1038,9 @@
},
"push": {
"title": "התראות בדפדפן",
"description": "קבל עדכונים מערכת עבור דוא״ל חדש כאשר אתר זה סגור. מסופק דרך ממסר Bulwark; הממסר לא רואה תוכן דוא״ל.",
"description": "קבל עדכונים מערכת עבור דוא״ל חדש כאשר אתר זה סגור. מסופק דרך ממסר VNCmail+; הממסר לא רואה תוכן דוא״ל.",
"relay_label": "ממסר דחיפה",
"relay_desc": "ברירת מחדל של ממסר Bulwark המתורח. שנה רק אם אתה מארח בעצמך.",
"relay_desc": "ברירת מחדל של ממסר VNCmail+ המתורח. שנה רק אם אתה מארח בעצמך.",
"relay_locked": "הוגדר על ידי מנהל",
"relay_locked_desc": "כתובת ממסר הדחיפה הוגדרה על ידי מנהל שלך ולא ניתן לשנות אותה.",
"relay_placeholder": "https://notifications.relay.example.com",
@@ -1493,10 +1493,10 @@
},
"link_device": {
"title": "הקשר יישום נייד",
"description": "היכנס ליישום הנייד Bulwark Mail ללא הקלדת דבר. צור קוד QR כאן וסרוק אותו מהמסך ההתחברות של היישום.",
"description": "היכנס ליישום הנייד VNCmail+ ללא הקלדת דבר. צור קוד QR כאן וסרוק אותו מהמסך ההתחברות של היישום.",
"generate": "הצג קוד QR",
"regenerate": "הצג קוד חדש",
"instructions": "פתח את אפליקציית Bulwark Mail, לחץ ״סרוק קוד QR״ במסך ההתחברות, וכיוון את המצלמה שלך לכאן.",
"instructions": "פתח את אפליקציית VNCmail+, לחץ ״סרוק קוד QR״ במסך ההתחברות, וכיוון את המצלמה שלך לכאן.",
"expires_in": "קוד זה פוקע בעוד {seconds} שניות. ניתן להשתמש בו רק פעם אחת.",
"expired": "קוד זה פקע.",
"generating": "יצירה…",
@@ -1668,7 +1668,7 @@
"button": "ייבוא"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
},
"show_avatars_in_junk": {
"label": "הצג אווטרים בתיקייה זבל",
@@ -3215,18 +3215,18 @@
"meta_description": "לקוח webmail מינימליסטי באמצעות פרוטוקול JMAP",
"protocol_handlers": {
"title": "יישומים ברירת מחדל",
"description": "בחר אם קישורי דוא״ל ולוח שנה ייפתחו ב־Bulwark. מבחינה טכנית, Bulwark רשום כטיפול בפרוטוקול עבור קישורי mailto: ו־webcal:.",
"description": "בחר אם קישורי דוא״ל ולוח שנה ייפתחו ב־VNCmail+. מבחינה טכנית, VNCmail+ רשום כטיפול בפרוטוקול עבור קישורי mailto: ו־webcal:.",
"unsupported": "דפדפן זה או חיבור זה לא תומך בהרשמה ידנית של טיפול בפרוטוקול. ייתכן שעדיין תוכל להשתמש ב־PWA המותקנת דרך הדפדפן או הגדרות מערכת ההפעלה.",
"mailto_label": "קישורי דוא״ל",
"mailto_description": "פתח קישורי mailto: ב־Bulwark עם מחבר שמלא מראש.",
"mailto_description": "פתח קישורי mailto: ב־VNCmail+ עם מחבר שמלא מראש.",
"protocol_open_mode_label": "בעת פתיחת קישורי פרוטוקול",
"protocol_open_mode_description": "בחר אם Bulwark יפתח קישורי mailto: ו־webcal: בלשונית חדשה או ישתמש בחיבור פתוח. אפשרות הישיבה הפעילה דורשת הרשאת עדכון כדי שתוכל ללחוץ על עדכון נופל כדי להביא את Bulwark לחזית אם הדפדפן חוסם מיקוד.",
"protocol_open_mode_description": "בחר אם VNCmail+ יפתח קישורי mailto: ו־webcal: בלשונית חדשה או ישתמש בחיבור פתוח. אפשרות הישיבה הפעילה דורשת הרשאת עדכון כדי שתוכל ללחוץ על עדכון נופל כדי להביא את VNCmail+ לחזית אם הדפדפן חוסם מיקוד.",
"protocol_open_mode_active_session": "פתח בישיבה פעילה אם אפשר",
"protocol_open_mode_new_tab": "פתח תמיד לשונית חדשה",
"focus_notification_title": "פתח את Bulwark",
"focus_notification_body": "הקישור נפתח ב־Bulwark. לחץ כדי להביא את החלון לחזית.",
"focus_notification_title": "פתח את VNCmail+",
"focus_notification_body": "הקישור נפתח ב־VNCmail+. לחץ כדי להביא את החלון לחזית.",
"webcal_label": "קישורי לוח שנה",
"webcal_description": "פתח קישורי webcal: ב־Bulwark עם תיבת דו־שיח מלא מראש להרשמה ללוח שנה.",
"webcal_description": "פתח קישורי webcal: ב־VNCmail+ עם תיבת דו־שיח מלא מראש להרשמה ללוח שנה.",
"register_mailto": "רשום יישום דוא״ל",
"register_webcal": "רשום יישום לוח שנה",
"mailto_registered": "בקש הרשמה של מטפל דוא״ל",
@@ -3234,7 +3234,7 @@
"registration_failed": "הרשמת טיפול בפרוטוקול נכשלה",
"opening_mailto": "פתיחת מחבר…",
"opening_webcal": "פתיחת לוח שנה…",
"browser_note": "הדפדפן או מערכת ההפעלה שלך עשויים לבקש ממך לאשר זאת ועשויים לדרוש שBulwark יותקן לפני שניתן לבחור אותו כיישום ברירת המחדל.",
"browser_note": "הדפדפן או מערכת ההפעלה שלך עשויים לבקש ממך לאשר זאת ועשויים לדרוש שVNCmail+ יותקן לפני שניתן לבחור אותו כיישום ברירת המחדל.",
"select_account_title": "בחר חשבון",
"select_mailto_account": "בחר איזה חשבון צריך לפתוח קישור דוא״ל זה.",
"select_webcal_account": "בחר איזה חשבון צריך לפתוח קישור לוח שנה זה.",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Alapértelmezett alkalmazások",
"description": "Válaszd ki, hogy az e-mail és naptár hivatkozások a Bulwarkban nyíljanak meg. Technikailag a Bulwark protokollkezelőként regisztrálja magát a mailto: és webcal: hivatkozásokhoz.",
"description": "Válaszd ki, hogy az e-mail és naptár hivatkozások a VNCmail+ban nyíljanak meg. Technikailag a VNCmail+ protokollkezelőként regisztrálja magát a mailto: és webcal: hivatkozásokhoz.",
"unsupported": "Ez a böngésző vagy kapcsolat nem támogatja a kézi protokollkezelő regisztrációt. Lehetőség lehet a telepített PWA használatára a böngésző vagy operációs rendszer beállításain keresztül.",
"mailto_label": "E-mail hivatkozások",
"mailto_description": "Mailto: hivatkozások megnyitása a Bulwarkban egy előre kitöltött szerkesztővel.",
"mailto_description": "Mailto: hivatkozások megnyitása a VNCmail+ban egy előre kitöltött szerkesztővel.",
"protocol_open_mode_label": "Protokoll hivatkozások megnyitásakor",
"protocol_open_mode_description": "Válaszd ki, hogy a Bulwark a mailto: és webcal: hivatkozásokat új lapon vagy a meglévő munkamenetben nyissa meg. Az aktív munkamenet opcióhoz értesítési engedély szükséges, hogy egy értesítésre kattintva előtérbe hozd a Bulwarkot, ha a böngésző blokkolja a fókuszt.",
"protocol_open_mode_description": "Válaszd ki, hogy a VNCmail+ a mailto: és webcal: hivatkozásokat új lapon vagy a meglévő munkamenetben nyissa meg. Az aktív munkamenet opcióhoz értesítési engedély szükséges, hogy egy értesítésre kattintva előtérbe hozd a VNCmail+ot, ha a böngésző blokkolja a fókuszt.",
"protocol_open_mode_active_session": "Megnyitás aktív munkamenetben, ha lehetséges",
"protocol_open_mode_new_tab": "Mindig új lapon nyisson",
"focus_notification_title": "Bulwark megnyitása",
"focus_notification_body": "A hivatkozás megnyílt a Bulwarkban. Kattints az ablak előtérbe hozásához.",
"focus_notification_title": "VNCmail+ megnyitása",
"focus_notification_body": "A hivatkozás megnyílt a VNCmail+ban. Kattints az ablak előtérbe hozásához.",
"webcal_label": "Naptár hivatkozások",
"webcal_description": "Webcal: hivatkozások megnyitása a Bulwarkban egy előre kitöltött naptárfeliratkozási párbeszédpanellel.",
"webcal_description": "Webcal: hivatkozások megnyitása a VNCmail+ban egy előre kitöltött naptárfeliratkozási párbeszédpanellel.",
"register_mailto": "E-mail alkalmazás regisztrálása",
"register_webcal": "Naptár alkalmazás regisztrálása",
"mailto_registered": "E-mail kezelő regisztráció kérve",
@@ -165,7 +165,7 @@
"registration_failed": "Protokollkezelő regisztráció sikertelen",
"opening_mailto": "Szerkesztő megnyitása...",
"opening_webcal": "Naptár megnyitása...",
"browser_note": "A böngésző vagy operációs rendszered megerősítést kérhet, és előfordulhat, hogy a Bulwarkot telepíteni kell, mielőtt alapértelmezett alkalmazásként kiválasztható.",
"browser_note": "A böngésző vagy operációs rendszered megerősítést kérhet, és előfordulhat, hogy a VNCmail+ot telepíteni kell, mielőtt alapértelmezett alkalmazásként kiválasztható.",
"select_account_title": "Fiók kiválasztása",
"select_mailto_account": "Válaszd ki, melyik fiók nyissa meg ezt az e-mail hivatkozást.",
"select_webcal_account": "Válaszd ki, melyik fiók nyissa meg ezt a naptár hivatkozást.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Kapcsolat megszakadt. Újracsatlakozás...",
"rate_limited_title": "A szerver hitelesítés átmenetileg korlátozva van.",
"rate_limited_detail": "A Bulwark szüneteltette a háttérkéréseket a lezárás elkerülése érdekében. Újrapróbálkozás {seconds} másodperc múlva.",
"rate_limited_detail": "A VNCmail+ szüneteltette a háttérkéréseket a lezárás elkerülése érdekében. Újrapróbálkozás {seconds} másodperc múlva.",
"rate_limited_action_title": "Kérés szüneteltetve a lezárás elkerülése érdekében.",
"rate_limited_action_detail": "A Bulwark várja a szerver lehűlését, mielőtt további hitelesített kéréseket küldene. Próbáld újra {seconds} másodperc múlva."
"rate_limited_action_detail": "A VNCmail+ várja a szerver lehűlését, mielőtt további hitelesített kéréseket küldene. Próbáld újra {seconds} másodperc múlva."
},
"notifications": {
"email_sent": "E-mail sikeresen elküldve",
@@ -1052,9 +1052,9 @@
},
"push": {
"title": "Háttérértesítések",
"description": "Rendszerértesítések fogadása új levelekről, amikor ez az oldal be van zárva. A Bulwark push relén keresztül kézbesítve; a relay soha nem látja a levél tartalmát.",
"description": "Rendszerértesítések fogadása új levelekről, amikor ez az oldal be van zárva. A VNCmail+ push relén keresztül kézbesítve; a relay soha nem látja a levél tartalmát.",
"relay_label": "Push relé",
"relay_desc": "Alapértelmezés szerint a tárolt Bulwark relay. Csak akkor módosítsd, ha sajátot üzemeltetsz.",
"relay_desc": "Alapértelmezés szerint a tárolt VNCmail+ relay. Csak akkor módosítsd, ha sajátot üzemeltetsz.",
"relay_locked": "Rendszergazda által beállítva",
"relay_locked_desc": "A push relay URL-t a rendszergazda állította be, és nem módosítható.",
"relay_placeholder": "https://notifications.relay.example.com",
@@ -1531,10 +1531,10 @@
},
"link_device": {
"title": "Mobilalkalmazás összekapcsolása",
"description": "Jelentkezz be a Bulwark Mail mobilalkalmazásba gépelés nélkül. Generálj itt egy QR-kódot, és olvasd be az alkalmazás bejelentkezési képernyőjén.",
"description": "Jelentkezz be a VNCmail+ mobilalkalmazásba gépelés nélkül. Generálj itt egy QR-kódot, és olvasd be az alkalmazás bejelentkezési képernyőjén.",
"generate": "QR-kód megjelenítése",
"regenerate": "Új kód megjelenítése",
"instructions": "Nyisd meg a Bulwark Mail alkalmazást, koppints a \"QR-kód beolvasása\" lehetőségre a bejelentkezési képernyőn, és irányítsd ide a kamerát.",
"instructions": "Nyisd meg a VNCmail+ alkalmazást, koppints a \"QR-kód beolvasása\" lehetőségre a bejelentkezési képernyőn, és irányítsd ide a kamerát.",
"expires_in": "Ez a kód {seconds} másodperc múlva lejár. Csak egyszer használható.",
"expired": "Ez a kód lejárt.",
"generating": "Generálás…",
@@ -1709,7 +1709,7 @@
"button": "Importálás"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3055,7 +3055,7 @@
"start_tour": "Bemutató indítása"
},
"demo_welcome": {
"title": "Üdvözlünk a Bulwark Mail-ben",
"title": "Üdvözlünk a VNCmail+-ben",
"description": "Fedezz fel egy teljes funkciókkal rendelkező webmail klienst - közvetlenül a böngésződben. Az összes adat az eszközödön marad, nyugodtan tesztelj mindent.",
"feature_email": "E-mailek olvasása és írása",
"feature_organize": "Címkék, csillagok és mappák",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "App predefinite",
"description": "Scegli se i link e-mail e calendario devono aprirsi in Bulwark. Tecnicamente, Bulwark si registra come gestore di protocollo per i link mailto: e webcal:.",
"description": "Scegli se i link e-mail e calendario devono aprirsi in VNCmail+. Tecnicamente, VNCmail+ si registra come gestore di protocollo per i link mailto: e webcal:.",
"unsupported": "Questo browser o questa connessione non supporta la registrazione manuale dei gestori di protocollo. Potresti comunque poter usare la PWA installata tramite le impostazioni del browser o del sistema.",
"mailto_label": "Link e-mail",
"mailto_description": "Apre i link mailto: in Bulwark con il compositore precompilato.",
"mailto_description": "Apre i link mailto: in VNCmail+ con il compositore precompilato.",
"protocol_open_mode_label": "All'apertura dei link di protocollo",
"protocol_open_mode_description": "Scegli se Bulwark deve aprire i link mailto: e webcal: in una nuova scheda o riutilizzare una sessione aperta. L'opzione sessione attiva richiede l'autorizzazione alle notifiche, così puoi fare clic su una notifica di fallback per portare Bulwark in primo piano se il browser blocca il focus.",
"protocol_open_mode_description": "Scegli se VNCmail+ deve aprire i link mailto: e webcal: in una nuova scheda o riutilizzare una sessione aperta. L'opzione sessione attiva richiede l'autorizzazione alle notifiche, così puoi fare clic su una notifica di fallback per portare VNCmail+ in primo piano se il browser blocca il focus.",
"protocol_open_mode_active_session": "Apri nella sessione attiva se possibile",
"protocol_open_mode_new_tab": "Apri sempre una nuova scheda",
"focus_notification_title": "Apri Bulwark",
"focus_notification_body": "Il link è stato aperto in Bulwark. Fai clic per portare la finestra in primo piano.",
"focus_notification_title": "Apri VNCmail+",
"focus_notification_body": "Il link è stato aperto in VNCmail+. Fai clic per portare la finestra in primo piano.",
"webcal_label": "Link calendario",
"webcal_description": "Apre i link webcal: in Bulwark con una finestra di dialogo di sottoscrizione al calendario precompilata.",
"webcal_description": "Apre i link webcal: in VNCmail+ con una finestra di dialogo di sottoscrizione al calendario precompilata.",
"register_mailto": "Registra app e-mail",
"register_webcal": "Registra app calendario",
"mailto_registered": "Registrazione del gestore e-mail richiesta",
@@ -165,7 +165,7 @@
"registration_failed": "Registrazione del gestore di protocollo non riuscita",
"opening_mailto": "Apertura compositore...",
"opening_webcal": "Apertura calendario...",
"browser_note": "Il browser o il sistema operativo potrebbe chiederti di confermare e potrebbe richiedere che Bulwark sia installato prima di poterlo selezionare come app predefinita.",
"browser_note": "Il browser o il sistema operativo potrebbe chiederti di confermare e potrebbe richiedere che VNCmail+ sia installato prima di poterlo selezionare come app predefinita.",
"select_account_title": "Scegli account",
"select_mailto_account": "Scegli quale account deve aprire questo link e-mail.",
"select_webcal_account": "Scegli quale account deve aprire questo link calendario.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Connessione persa. Tentativo di riconnessione…",
"rate_limited_title": "L'autenticazione del server e temporaneamente limitata.",
"rate_limited_detail": "Bulwark ha sospeso le richieste in background per evitare il blocco. Nuovo tentativo tra {seconds}s.",
"rate_limited_detail": "VNCmail+ ha sospeso le richieste in background per evitare il blocco. Nuovo tentativo tra {seconds}s.",
"rate_limited_action_title": "Richiesta sospesa per evitare il blocco.",
"rate_limited_action_detail": "Bulwark attende che termini il cooldown del server prima di inviare altre richieste autenticate. Riprova tra {seconds}s."
"rate_limited_action_detail": "VNCmail+ attende che termini il cooldown del server prima di inviare altre richieste autenticate. Riprova tra {seconds}s."
},
"notifications": {
"email_sent": "Messaggio inviato con successo",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "Questo dispositivo non riceverà più avvisi quando il sito è chiuso.",
"confirm_disable_title": "Disabilitare le notifiche in background?",
"description": "Ricevi notifiche di sistema per la nuova posta quando questo sito è chiuso. Consegnato tramite il relay push Bulwark; il relay non vede mai il contenuto della posta.",
"description": "Ricevi notifiche di sistema per la nuova posta quando questo sito è chiuso. Consegnato tramite il relay push VNCmail+; il relay non vede mai il contenuto della posta.",
"disable": "Disabilita",
"enable": "Abilita",
"ios_hint": "Su iOS, installa prima il sito sulla schermata Home: Safari consegna Web Push solo alle PWA installate.",
"reenable": "Registra di nuovo",
"relay_desc": "Per impostazione predefinita usa il relay Bulwark ospitato. Cambialo solo se ospiti in autonomia.",
"relay_desc": "Per impostazione predefinita usa il relay VNCmail+ ospitato. Cambialo solo se ospiti in autonomia.",
"relay_label": "Relay push",
"relay_locked": "Impostato dall'amministratore",
"relay_locked_desc": "L'URL del relay push è stato impostato dall'amministratore e non può essere modificato.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Collega l'app mobile",
"description": "Accedi all'app mobile Bulwark Mail senza digitare nulla. Genera qui un codice QR e scansionalo dalla schermata di accesso dell'app.",
"description": "Accedi all'app mobile VNCmail+ senza digitare nulla. Genera qui un codice QR e scansionalo dalla schermata di accesso dell'app.",
"generate": "Mostra codice QR",
"regenerate": "Mostra un nuovo codice",
"instructions": "Apri l'app Bulwark Mail, tocca \"Scansiona codice QR\" nella schermata di accesso e inquadra qui con la fotocamera.",
"instructions": "Apri l'app VNCmail+, tocca \"Scansiona codice QR\" nella schermata di accesso e inquadra qui con la fotocamera.",
"expires_in": "Questo codice scade tra {seconds} secondi. Può essere usato una sola volta.",
"expired": "Questo codice è scaduto.",
"generating": "Generazione…",
@@ -1706,7 +1706,7 @@
"button": "Importa"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "Inizia il tour"
},
"demo_welcome": {
"title": "Benvenuto su Bulwark Mail",
"title": "Benvenuto su VNCmail+",
"description": "Esplora un client webmail completo - direttamente nel tuo browser. Tutti i dati restano sul tuo dispositivo, quindi prova tutto.",
"feature_email": "Leggere e scrivere email",
"feature_organize": "Tag, stelle e cartelle",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "既定のアプリ",
"description": "メールとカレンダーのリンクを Bulwark で開くかどうかを選択します。技術的には、Bulwark は mailto: と webcal: リンクのプロトコル ハンドラーとして登録されます。",
"description": "メールとカレンダーのリンクを VNCmail+ で開くかどうかを選択します。技術的には、VNCmail+ は mailto: と webcal: リンクのプロトコル ハンドラーとして登録されます。",
"unsupported": "このブラウザーまたは接続では、プロトコル ハンドラーの手動登録がサポートされていません。インストール済みの PWA は、ブラウザーまたはシステム設定から使用できる場合があります。",
"mailto_label": "メールリンク",
"mailto_description": "mailto: リンクを、入力済みの作成画面で Bulwark に開きます。",
"mailto_description": "mailto: リンクを、入力済みの作成画面で VNCmail+ に開きます。",
"protocol_open_mode_label": "プロトコルリンクを開くとき",
"protocol_open_mode_description": "Bulwark が mailto: と webcal: のリンクを新しいタブで開くか、開いているセッションを再利用するかを選択します。アクティブなセッションのオプションでは通知の許可が必要です。ブラウザーがフォーカスをブロックした場合に、代替通知をクリックして Bulwark を前面に表示できます。",
"protocol_open_mode_description": "VNCmail+ が mailto: と webcal: のリンクを新しいタブで開くか、開いているセッションを再利用するかを選択します。アクティブなセッションのオプションでは通知の許可が必要です。ブラウザーがフォーカスをブロックした場合に、代替通知をクリックして VNCmail+ を前面に表示できます。",
"protocol_open_mode_active_session": "可能な場合はアクティブなセッションで開く",
"protocol_open_mode_new_tab": "常に新しいタブを開く",
"focus_notification_title": "Bulwark を開く",
"focus_notification_body": "リンクは Bulwark で開かれました。クリックするとウィンドウを前面に表示します。",
"focus_notification_title": "VNCmail+ を開く",
"focus_notification_body": "リンクは VNCmail+ で開かれました。クリックするとウィンドウを前面に表示します。",
"webcal_label": "カレンダーリンク",
"webcal_description": "webcal: リンクを、入力済みのカレンダー購読ダイアログで Bulwark に開きます。",
"webcal_description": "webcal: リンクを、入力済みのカレンダー購読ダイアログで VNCmail+ に開きます。",
"register_mailto": "メールアプリを登録",
"register_webcal": "カレンダーアプリを登録",
"mailto_registered": "メール ハンドラーの登録を要求しました",
@@ -165,7 +165,7 @@
"registration_failed": "プロトコル ハンドラーの登録に失敗しました",
"opening_mailto": "作成画面を開いています...",
"opening_webcal": "カレンダーを開いています...",
"browser_note": "ブラウザーまたはオペレーティング システムから確認を求められる場合があります。また、既定のアプリとして選択する前に Bulwark のインストールが必要な場合があります。",
"browser_note": "ブラウザーまたはオペレーティング システムから確認を求められる場合があります。また、既定のアプリとして選択する前に VNCmail+ のインストールが必要な場合があります。",
"select_account_title": "アカウントを選択",
"select_mailto_account": "このメールリンクを開くアカウントを選択してください。",
"select_webcal_account": "このカレンダーリンクを開くアカウントを選択してください。",
@@ -738,9 +738,9 @@
"app_title": "ウェブメール",
"reconnecting": "接続が切れました。再接続を試みています…",
"rate_limited_title": "サーバー認証は一時的に制限されています。",
"rate_limited_detail": "ロックアウトを避けるため、Bulwark はバックグラウンド要求を一時停止しました。{seconds} 秒後に再試行します。",
"rate_limited_detail": "ロックアウトを避けるため、VNCmail+ はバックグラウンド要求を一時停止しました。{seconds} 秒後に再試行します。",
"rate_limited_action_title": "ロックアウトを避けるため要求を一時停止しました。",
"rate_limited_action_detail": "Bulwark はサーバーのクールダウンが終わるまで、追加の認証付きリクエストを送信しません。{seconds} 秒後に再試行してください。"
"rate_limited_action_detail": "VNCmail+ はサーバーのクールダウンが終わるまで、追加の認証付きリクエストを送信しません。{seconds} 秒後に再試行してください。"
},
"notifications": {
"email_sent": "メールを送信しました",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "このデバイスは、サイトが閉じているときに通知を受信しなくなります。",
"confirm_disable_title": "バックグラウンド通知を無効にしますか?",
"description": "このサイトが閉じているときに新着メールのシステム通知を受信します。Bulwark プッシュリレー経由で配信され、リレーがメール内容を見ることはありません。",
"description": "このサイトが閉じているときに新着メールのシステム通知を受信します。VNCmail+ プッシュリレー経由で配信され、リレーがメール内容を見ることはありません。",
"disable": "無効化",
"enable": "有効化",
"ios_hint": "iOS では、最初にサイトをホーム画面にインストールしてください。Safari はインストールされた PWA にのみ Web Push を配信します。",
"reenable": "再登録",
"relay_desc": "デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。",
"relay_desc": "デフォルトはホストされた VNCmail+ リレーです。セルフホストする場合のみ変更してください。",
"relay_label": "プッシュリレー",
"relay_locked": "管理者が設定",
"relay_locked_desc": "プッシュリレーの URL は管理者によって設定されており、変更できません。",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "モバイルアプリを連携",
"description": "入力なしで Bulwark Mail モバイルアプリにサインインできます。ここで QR コードを生成し、アプリのログイン画面からスキャンしてください。",
"description": "入力なしで VNCmail+ モバイルアプリにサインインできます。ここで QR コードを生成し、アプリのログイン画面からスキャンしてください。",
"generate": "QRコードを表示",
"regenerate": "新しいコードを表示",
"instructions": "Bulwark Mail アプリを開き、ログイン画面で「QRコードをスキャン」をタップして、カメラをここに向けてください。",
"instructions": "VNCmail+ アプリを開き、ログイン画面で「QRコードをスキャン」をタップして、カメラをここに向けてください。",
"expires_in": "このコードは{seconds}秒で期限切れになります。一度しか使用できません。",
"expired": "このコードは期限切れです。",
"generating": "生成中…",
@@ -1706,7 +1706,7 @@
"button": "インポート"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "ツアーを開始"
},
"demo_welcome": {
"title": "Bulwark Mail へようこそ",
"title": "VNCmail+ へようこそ",
"description": "フル機能のウェブメールクライアントをブラウザで体験できます。すべてのデータはお使いのデバイスに保存されるので、自由にお試しください。",
"feature_email": "メールの読み書き",
"feature_organize": "タグ・スター・フォルダ",
+14 -14
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "기본 앱",
"description": "이메일 및 캘린더 링크를 Bulwark에서 열지 선택하세요. 기술적으로 Bulwark는 mailto: 및 webcal: 링크의 프로토콜 핸들러로 등록됩니다.",
"description": "이메일 및 캘린더 링크를 VNCmail+에서 열지 선택하세요. 기술적으로 VNCmail+는 mailto: 및 webcal: 링크의 프로토콜 핸들러로 등록됩니다.",
"unsupported": "이 브라우저 또는 연결은 수동 프로토콜 핸들러 등록을 지원하지 않습니다. 설치된 PWA는 브라우저 또는 시스템 설정을 통해 사용할 수 있을 수 있습니다.",
"mailto_label": "이메일 링크",
"mailto_description": "mailto: 링크를 Bulwark의 미리 채워진 작성 창에서 엽니다.",
"mailto_description": "mailto: 링크를 VNCmail+의 미리 채워진 작성 창에서 엽니다.",
"protocol_open_mode_label": "프로토콜 링크를 열 때",
"protocol_open_mode_description": "Bulwark가 mailto: 및 webcal: 링크를 새 탭에서 열지, 열린 세션을 재사용할지 선택하세요. 활성 세션 옵션은 브라우저가 포커스를 차단할 때 Bulwark를 앞으로 가져오기 위한 대체 알림을 클릭할 수 있도록 알림 권한이 필요합니다.",
"protocol_open_mode_description": "VNCmail+가 mailto: 및 webcal: 링크를 새 탭에서 열지, 열린 세션을 재사용할지 선택하세요. 활성 세션 옵션은 브라우저가 포커스를 차단할 때 VNCmail+를 앞으로 가져오기 위한 대체 알림을 클릭할 수 있도록 알림 권한이 필요합니다.",
"protocol_open_mode_active_session": "가능하면 활성 세션에서 열기",
"protocol_open_mode_new_tab": "항상 새 탭 열기",
"focus_notification_title": "Bulwark 열기",
"focus_notification_body": "링크가 Bulwark에서 열렸습니다. 창을 앞으로 가져오려면 클릭하세요.",
"focus_notification_title": "VNCmail+ 열기",
"focus_notification_body": "링크가 VNCmail+에서 열렸습니다. 창을 앞으로 가져오려면 클릭하세요.",
"webcal_label": "캘린더 링크",
"webcal_description": "webcal: 링크를 Bulwark의 미리 채워진 캘린더 구독 대화상자에서 엽니다.",
"webcal_description": "webcal: 링크를 VNCmail+의 미리 채워진 캘린더 구독 대화상자에서 엽니다.",
"register_mailto": "이메일 앱 등록",
"register_webcal": "캘린더 앱 등록",
"mailto_registered": "이메일 핸들러 등록을 요청했습니다",
@@ -165,7 +165,7 @@
"registration_failed": "프로토콜 핸들러 등록에 실패했습니다",
"opening_mailto": "작성 창을 여는 중...",
"opening_webcal": "캘린더를 여는 중...",
"browser_note": "브라우저 또는 운영 체제에서 확인을 요청할 수 있으며, 기본 앱으로 선택하기 전에 Bulwark 설치가 필요할 수 있습니다.",
"browser_note": "브라우저 또는 운영 체제에서 확인을 요청할 수 있으며, 기본 앱으로 선택하기 전에 VNCmail+ 설치가 필요할 수 있습니다.",
"select_account_title": "계정 선택",
"select_mailto_account": "이 이메일 링크를 열 계정을 선택하세요.",
"select_webcal_account": "이 캘린더 링크를 열 계정을 선택하세요.",
@@ -738,7 +738,7 @@
"app_title": "웹메일",
"reconnecting": "연결이 끊어졌어요. 다시 연결을 시도할게요...",
"rate_limited_title": "서버 인증이 일시적으로 제한되었어요.",
"rate_limited_detail": "Bulwark가 계정 잠금을 방지하기 위해 백그라운드 요청을 일시 중지했어요. {seconds}초 후에 다시 시도할게요.",
"rate_limited_detail": "VNCmail+가 계정 잠금을 방지하기 위해 백그라운드 요청을 일시 중지했어요. {seconds}초 후에 다시 시도할게요.",
"rate_limited_action_title": "계정 잠금 방지를 위해 요청이 일시 중지되었어요.",
"rate_limited_action_detail": "추가 인증 요청을 보내기 전에 서버의 쿨다운이 끝날 때까지 대기 중이에요. {seconds}초 후에 다시 시도해 주세요."
},
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "이 사이트가 닫혀 있을 때 이 기기는 알림을 더 이상 받지 않습니다.",
"confirm_disable_title": "백그라운드 알림을 비활성화하시겠습니까?",
"description": "이 사이트가 닫혀 있을 때 새 메일에 대한 시스템 알림을 받습니다. Bulwark 푸시 릴레이를 통해 전달되며, 릴레이는 메일 내용을 절대 보지 않습니다.",
"description": "이 사이트가 닫혀 있을 때 새 메일에 대한 시스템 알림을 받습니다. VNCmail+ 푸시 릴레이를 통해 전달되며, 릴레이는 메일 내용을 절대 보지 않습니다.",
"disable": "비활성화",
"enable": "활성화",
"ios_hint": "iOS에서는 먼저 사이트를 홈 화면에 설치하세요. Safari는 설치된 PWA에만 Web Push를 전달합니다.",
"reenable": "다시 등록",
"relay_desc": "기본값은 호스팅된 Bulwark 릴레이입니다. 셀프 호스팅 시에만 변경하세요.",
"relay_desc": "기본값은 호스팅된 VNCmail+ 릴레이입니다. 셀프 호스팅 시에만 변경하세요.",
"relay_label": "푸시 릴레이",
"relay_locked": "관리자에 의해 설정됨",
"relay_locked_desc": "푸시 릴레이 URL은 관리자가 설정했으며 변경할 수 없어요.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "모바일 앱 연결",
"description": "아무것도 입력하지 않고 Bulwark Mail 모바일 앱에 로그인하세요. 여기에서 QR 코드를 생성하고 앱의 로그인 화면에서 스캔하세요.",
"description": "아무것도 입력하지 않고 VNCmail+ 모바일 앱에 로그인하세요. 여기에서 QR 코드를 생성하고 앱의 로그인 화면에서 스캔하세요.",
"generate": "QR 코드 표시",
"regenerate": "새 코드 표시",
"instructions": "Bulwark Mail 앱을 열고 로그인 화면에서 \"QR 코드 스캔\"을 누른 다음 카메라를 여기에 비추세요.",
"instructions": "VNCmail+ 앱을 열고 로그인 화면에서 \"QR 코드 스캔\"을 누른 다음 카메라를 여기에 비추세요.",
"expires_in": "이 코드는 {seconds}초 후에 만료됩니다. 한 번만 사용할 수 있습니다.",
"expired": "이 코드는 만료되었습니다.",
"generating": "생성 중…",
@@ -1706,7 +1706,7 @@
"button": "가져오기"
},
"about": {
"title": "Bulwark Webmail 정보"
"title": "VNCmail+ 정보"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "둘러보기 시작"
},
"demo_welcome": {
"title": "Bulwark Mail에 오신 것을 환영해요",
"title": "VNCmail+에 오신 것을 환영해요",
"description": "브라우저에서 바로 완전한 기능을 갖춘 웹메일 클라이언트를 경험해 보세요. 모든 데이터는 내 기기에만 저장되니 안심하고 테스트해 보셔도 좋아요.",
"feature_email": "메일 읽기 및 쓰기",
"feature_organize": "태그, 별표, 폴더 관리",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Noklusējuma lietotnes",
"description": "Izvēlieties, vai e-pasta un kalendāra saites atvērt Bulwark. Tehniski Bulwark reģistrējas kā protokola apstrādātājs mailto: un webcal: saitēm.",
"description": "Izvēlieties, vai e-pasta un kalendāra saites atvērt VNCmail+. Tehniski VNCmail+ reģistrējas kā protokola apstrādātājs mailto: un webcal: saitēm.",
"unsupported": "Šī pārlūkprogramma vai savienojums neatbalsta manuālu protokola apstrādātāja reģistrāciju. Iespējams, instalēto PWA joprojām var izmantot pārlūkprogrammas vai sistēmas iestatījumos.",
"mailto_label": "E-pasta saites",
"mailto_description": "Atver mailto: saites Bulwark ar iepriekš aizpildītu ziņojuma redaktoru.",
"mailto_description": "Atver mailto: saites VNCmail+ ar iepriekš aizpildītu ziņojuma redaktoru.",
"protocol_open_mode_label": "Atverot protokola saites",
"protocol_open_mode_description": "Izvēlieties, vai Bulwark atver mailto: un webcal: saites jaunā cilnē vai atkārtoti izmanto atvērtu sesiju. Aktīvās sesijas opcijai nepieciešama paziņojumu atļauja, lai jūs varētu noklikšķināt uz rezerves paziņojuma un izcelt Bulwark priekšplānā, ja pārlūkprogramma bloķē fokusu.",
"protocol_open_mode_description": "Izvēlieties, vai VNCmail+ atver mailto: un webcal: saites jaunā cilnē vai atkārtoti izmanto atvērtu sesiju. Aktīvās sesijas opcijai nepieciešama paziņojumu atļauja, lai jūs varētu noklikšķināt uz rezerves paziņojuma un izcelt VNCmail+ priekšplānā, ja pārlūkprogramma bloķē fokusu.",
"protocol_open_mode_active_session": "Ja iespējams, atvērt aktīvajā sesijā",
"protocol_open_mode_new_tab": "Vienmēr atvērt jaunu cilni",
"focus_notification_title": "Atvērt Bulwark",
"focus_notification_body": "Saite tika atvērta Bulwark. Noklikšķiniet, lai izceltu logu priekšplānā.",
"focus_notification_title": "Atvērt VNCmail+",
"focus_notification_body": "Saite tika atvērta VNCmail+. Noklikšķiniet, lai izceltu logu priekšplānā.",
"webcal_label": "Kalendāra saites",
"webcal_description": "Atver webcal: saites Bulwark ar iepriekš aizpildītu kalendāra abonēšanas dialogu.",
"webcal_description": "Atver webcal: saites VNCmail+ ar iepriekš aizpildītu kalendāra abonēšanas dialogu.",
"register_mailto": "Reģistrēt e-pasta lietotni",
"register_webcal": "Reģistrēt kalendāra lietotni",
"mailto_registered": "E-pasta apstrādātāja reģistrācija pieprasīta",
@@ -165,7 +165,7 @@
"registration_failed": "Protokola apstrādātāja reģistrācija neizdevās",
"opening_mailto": "Tiek atvērts redaktors...",
"opening_webcal": "Tiek atvērts kalendārs...",
"browser_note": "Pārlūkprogramma vai operētājsistēma var lūgt apstiprinājumu un var prasīt, lai Bulwark būtu instalēts, pirms to var izvēlēties kā noklusējuma lietotni.",
"browser_note": "Pārlūkprogramma vai operētājsistēma var lūgt apstiprinājumu un var prasīt, lai VNCmail+ būtu instalēts, pirms to var izvēlēties kā noklusējuma lietotni.",
"select_account_title": "Izvēlieties kontu",
"select_mailto_account": "Izvēlieties, kurā kontā atvērt šo e-pasta saiti.",
"select_webcal_account": "Izvēlieties, kurā kontā atvērt šo kalendāra saiti.",
@@ -738,9 +738,9 @@
"app_title": "Tīmekļa pasts",
"reconnecting": "Savienojums pārtraukts. Mēģina izveidot savienojumu...",
"rate_limited_title": "Autentifikācija serverī ir īslaicīgi ierobežota.",
"rate_limited_detail": "Bulwark ir apturējis fonā veiktos pieprasījumus, lai izvairītos no bloķēšanas. Mēģinās vēlreiz pēc {seconds} s.",
"rate_limited_detail": "VNCmail+ ir apturējis fonā veiktos pieprasījumus, lai izvairītos no bloķēšanas. Mēģinās vēlreiz pēc {seconds} s.",
"rate_limited_action_title": "Pieprasījums apturēts, lai izvairītos no bloķēšanas.",
"rate_limited_action_detail": "Bulwark gaida servera noildzes beigas pirms jaunu autentificētu pieprasījumu sūtīšanas. Mēģiniet vēlreiz pēc {seconds} s."
"rate_limited_action_detail": "VNCmail+ gaida servera noildzes beigas pirms jaunu autentificētu pieprasījumu sūtīšanas. Mēģiniet vēlreiz pēc {seconds} s."
},
"notifications": {
"email_sent": "Vēstule veiksmīgi nosūtīta",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "Šī ierīce vairs nesaņems brīdinājumus, kad vietne būs aizvērta.",
"confirm_disable_title": "Atspējot fona paziņojumus?",
"description": "Saņem sistēmas paziņojumus par jaunu pastu, kad šī vietne ir aizvērta. Piegādāts caur Bulwark push releju; relejs nekad neredz pasta saturu.",
"description": "Saņem sistēmas paziņojumus par jaunu pastu, kad šī vietne ir aizvērta. Piegādāts caur VNCmail+ push releju; relejs nekad neredz pasta saturu.",
"disable": "Atspējot",
"enable": "Iespējot",
"ios_hint": "Operētājsistēmā iOS vispirms instalējiet vietni sākuma ekrānā Safari piegādā Web Push tikai instalētajām PWA.",
"reenable": "Reģistrēt vēlreiz",
"relay_desc": "Pēc noklusējuma izmanto izmitināto Bulwark releju. Mainiet tikai tad, ja izmitināt pats.",
"relay_desc": "Pēc noklusējuma izmanto izmitināto VNCmail+ releju. Mainiet tikai tad, ja izmitināt pats.",
"relay_label": "Push relejs",
"relay_locked": "Iestatījis administrators",
"relay_locked_desc": "Push releja URL ir iestatījis administrators, un to nevar mainīt.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Saistīt mobilo lietotni",
"description": "Pierakstieties Bulwark Mail mobilajā lietotnē, neko nerakstot. Šeit izveidojiet QR kodu un noskenējiet to lietotnes pierakstīšanās ekrānā.",
"description": "Pierakstieties VNCmail+ mobilajā lietotnē, neko nerakstot. Šeit izveidojiet QR kodu un noskenējiet to lietotnes pierakstīšanās ekrānā.",
"generate": "Rādīt QR kodu",
"regenerate": "Rādīt jaunu kodu",
"instructions": "Atveriet Bulwark Mail lietotni, pierakstīšanās ekrānā pieskarieties \"Skenēt QR kodu\" un pavērsiet kameru šeit.",
"instructions": "Atveriet VNCmail+ lietotni, pierakstīšanās ekrānā pieskarieties \"Skenēt QR kodu\" un pavērsiet kameru šeit.",
"expires_in": "Šī koda derīgums beigsies pēc {seconds} sekundēm. To var izmantot tikai vienu reizi.",
"expired": "Šī koda derīgums ir beidzies.",
"generating": "Ģenerē…",
@@ -1706,7 +1706,7 @@
"contacts_description": "Kontaktu sinhronizācija, adrešu grāmatu darbības un uzticamie sūtītāji"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "Sākt ekskursiju"
},
"demo_welcome": {
"title": "Laipni lūdzam Bulwark Mail",
"title": "Laipni lūdzam VNCmail+",
"description": "Atklājiet pilnvērtīgu tīmekļa e-pasta klientu tieši pārlūkā. Visi dati tiek glabāti jūsu ierīcē.",
"feature_email": "Vēstuļu lasīšana un rakstīšana",
"feature_organize": "Etiķetes, zvaigznītes un mapes",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Standaardapps",
"description": "Kies of e-mail- en kalenderlinks in Bulwark worden geopend. Technisch registreert Bulwark zich als protocolhandler voor mailto:- en webcal:-links.",
"description": "Kies of e-mail- en kalenderlinks in VNCmail+ worden geopend. Technisch registreert VNCmail+ zich als protocolhandler voor mailto:- en webcal:-links.",
"unsupported": "Deze browser of verbinding ondersteunt geen handmatige registratie van protocolhandlers. Mogelijk kun je de geïnstalleerde PWA nog gebruiken via de browser- of systeeminstellingen.",
"mailto_label": "E-maillinks",
"mailto_description": "Opent mailto:-links in Bulwark met een vooraf ingevulde opsteller.",
"mailto_description": "Opent mailto:-links in VNCmail+ met een vooraf ingevulde opsteller.",
"protocol_open_mode_label": "Bij het openen van protocollinks",
"protocol_open_mode_description": "Kies of Bulwark mailto:- en webcal:-links in een nieuw tabblad opent of een geopende sessie hergebruikt. Voor de optie actieve sessie is toestemming voor meldingen nodig, zodat je op een fallbackmelding kunt klikken om Bulwark naar voren te halen als de browser focus blokkeert.",
"protocol_open_mode_description": "Kies of VNCmail+ mailto:- en webcal:-links in een nieuw tabblad opent of een geopende sessie hergebruikt. Voor de optie actieve sessie is toestemming voor meldingen nodig, zodat je op een fallbackmelding kunt klikken om VNCmail+ naar voren te halen als de browser focus blokkeert.",
"protocol_open_mode_active_session": "Indien mogelijk openen in actieve sessie",
"protocol_open_mode_new_tab": "Altijd een nieuw tabblad openen",
"focus_notification_title": "Bulwark openen",
"focus_notification_body": "De link is geopend in Bulwark. Klik om het venster naar voren te halen.",
"focus_notification_title": "VNCmail+ openen",
"focus_notification_body": "De link is geopend in VNCmail+. Klik om het venster naar voren te halen.",
"webcal_label": "Kalenderlinks",
"webcal_description": "Opent webcal:-links in Bulwark met een vooraf ingevuld dialoogvenster voor kalenderabonnementen.",
"webcal_description": "Opent webcal:-links in VNCmail+ met een vooraf ingevuld dialoogvenster voor kalenderabonnementen.",
"register_mailto": "E-mailapp registreren",
"register_webcal": "Kalenderapp registreren",
"mailto_registered": "Registratie van e-mailhandler aangevraagd",
@@ -165,7 +165,7 @@
"registration_failed": "Registratie van protocolhandler mislukt",
"opening_mailto": "Opsteller wordt geopend...",
"opening_webcal": "Kalender wordt geopend...",
"browser_note": "Je browser of besturingssysteem kan om bevestiging vragen en kan vereisen dat Bulwark is geïnstalleerd voordat het als standaardapp kan worden geselecteerd.",
"browser_note": "Je browser of besturingssysteem kan om bevestiging vragen en kan vereisen dat VNCmail+ is geïnstalleerd voordat het als standaardapp kan worden geselecteerd.",
"select_account_title": "Account kiezen",
"select_mailto_account": "Kies welk account deze e-maillink moet openen.",
"select_webcal_account": "Kies welk account deze kalenderlink moet openen.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Verbinding verloren. Opnieuw verbinden…",
"rate_limited_title": "Serverauthenticatie is tijdelijk beperkt.",
"rate_limited_detail": "Bulwark heeft achtergrondverzoeken gepauzeerd om een blokkade te voorkomen. Nieuwe poging over {seconds}s.",
"rate_limited_detail": "VNCmail+ heeft achtergrondverzoeken gepauzeerd om een blokkade te voorkomen. Nieuwe poging over {seconds}s.",
"rate_limited_action_title": "Verzoek gepauzeerd om blokkade te voorkomen.",
"rate_limited_action_detail": "Bulwark wacht tot de servercooldown voorbij is voordat er nieuwe geauthenticeerde verzoeken worden verzonden. Probeer het over {seconds}s opnieuw."
"rate_limited_action_detail": "VNCmail+ wacht tot de servercooldown voorbij is voordat er nieuwe geauthenticeerde verzoeken worden verzonden. Probeer het over {seconds}s opnieuw."
},
"notifications": {
"email_sent": "E-mail succesvol verzonden",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "Dit apparaat ontvangt geen meldingen meer als de site is gesloten.",
"confirm_disable_title": "Achtergrondmeldingen uitschakelen?",
"description": "Ontvang systeemmeldingen voor nieuwe e-mail wanneer deze site gesloten is. Geleverd via de Bulwark-pushrelay; de relay ziet nooit e-mailinhoud.",
"description": "Ontvang systeemmeldingen voor nieuwe e-mail wanneer deze site gesloten is. Geleverd via de VNCmail+-pushrelay; de relay ziet nooit e-mailinhoud.",
"disable": "Uitschakelen",
"enable": "Inschakelen",
"ios_hint": "Installeer de site op iOS eerst op het beginscherm Safari levert Web Push alleen aan geïnstalleerde PWA's.",
"reenable": "Opnieuw registreren",
"relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.",
"relay_desc": "Gebruikt standaard de gehoste VNCmail+-relay. Wijzig alleen als je zelf hostt.",
"relay_label": "Push-relay",
"relay_locked": "Ingesteld door beheerder",
"relay_locked_desc": "De push-relay-URL is ingesteld door je beheerder en kan niet worden gewijzigd.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Mobiele app koppelen",
"description": "Log in bij de Bulwark Mail mobiele app zonder iets te typen. Genereer hier een QR-code en scan deze vanaf het inlogscherm van de app.",
"description": "Log in bij de VNCmail+ mobiele app zonder iets te typen. Genereer hier een QR-code en scan deze vanaf het inlogscherm van de app.",
"generate": "QR-code tonen",
"regenerate": "Nieuwe code tonen",
"instructions": "Open de Bulwark Mail app, tik op \"QR-code scannen\" op het inlogscherm en richt uw camera hierop.",
"instructions": "Open de VNCmail+ app, tik op \"QR-code scannen\" op het inlogscherm en richt uw camera hierop.",
"expires_in": "Deze code verloopt over {seconds} seconden. Hij kan maar één keer worden gebruikt.",
"expired": "Deze code is verlopen.",
"generating": "Genereren…",
@@ -1706,7 +1706,7 @@
"button": "Importeren"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "Tour starten"
},
"demo_welcome": {
"title": "Welkom bij Bulwark Mail",
"title": "Welkom bij VNCmail+",
"description": "Ontdek een volwaardige webmail-client - rechtstreeks in je browser. Alle gegevens blijven op je apparaat, dus test gerust alles.",
"feature_email": "E-mails lezen en schrijven",
"feature_organize": "Tags, sterren en mappen",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Aplikacje domyślne",
"description": "Wybierz, czy linki e-mail i kalendarza mają otwierać się w Bulwark. Technicznie Bulwark rejestruje się jako obsługa protokołu dla linków mailto: i webcal:.",
"description": "Wybierz, czy linki e-mail i kalendarza mają otwierać się w VNCmail+. Technicznie VNCmail+ rejestruje się jako obsługa protokołu dla linków mailto: i webcal:.",
"unsupported": "Ta przeglądarka lub to połączenie nie obsługuje ręcznej rejestracji obsługi protokołu. Nadal możesz mieć możliwość użycia zainstalowanej aplikacji PWA w ustawieniach przeglądarki lub systemu.",
"mailto_label": "Linki e-mail",
"mailto_description": "Otwiera linki mailto: w Bulwark z wstępnie wypełnionym edytorem wiadomości.",
"mailto_description": "Otwiera linki mailto: w VNCmail+ z wstępnie wypełnionym edytorem wiadomości.",
"protocol_open_mode_label": "Podczas otwierania linków protokołu",
"protocol_open_mode_description": "Wybierz, czy Bulwark ma otwierać linki mailto: i webcal: w nowej karcie, czy ponownie używać otwartej sesji. Opcja aktywnej sesji wymaga uprawnienia do powiadomień, aby można było kliknąć powiadomienie awaryjne i przenieść Bulwark na pierwszy plan, jeśli przeglądarka blokuje fokus.",
"protocol_open_mode_description": "Wybierz, czy VNCmail+ ma otwierać linki mailto: i webcal: w nowej karcie, czy ponownie używać otwartej sesji. Opcja aktywnej sesji wymaga uprawnienia do powiadomień, aby można było kliknąć powiadomienie awaryjne i przenieść VNCmail+ na pierwszy plan, jeśli przeglądarka blokuje fokus.",
"protocol_open_mode_active_session": "Jeśli to możliwe, otwórz w aktywnej sesji",
"protocol_open_mode_new_tab": "Zawsze otwieraj nową kartę",
"focus_notification_title": "Otwórz Bulwark",
"focus_notification_body": "Link został otwarty w Bulwark. Kliknij, aby przenieść okno na pierwszy plan.",
"focus_notification_title": "Otwórz VNCmail+",
"focus_notification_body": "Link został otwarty w VNCmail+. Kliknij, aby przenieść okno na pierwszy plan.",
"webcal_label": "Linki kalendarza",
"webcal_description": "Otwiera linki webcal: w Bulwark z wstępnie wypełnionym oknem subskrypcji kalendarza.",
"webcal_description": "Otwiera linki webcal: w VNCmail+ z wstępnie wypełnionym oknem subskrypcji kalendarza.",
"register_mailto": "Zarejestruj aplikację e-mail",
"register_webcal": "Zarejestruj aplikację kalendarza",
"mailto_registered": "Zażądano rejestracji obsługi e-mail",
@@ -165,7 +165,7 @@
"registration_failed": "Rejestracja obsługi protokołu nie powiodła się",
"opening_mailto": "Otwieranie edytora...",
"opening_webcal": "Otwieranie kalendarza...",
"browser_note": "Przeglądarka lub system operacyjny może poprosić o potwierdzenie i może wymagać zainstalowania Bulwark, zanim będzie można wybrać go jako aplikację domyślną.",
"browser_note": "Przeglądarka lub system operacyjny może poprosić o potwierdzenie i może wymagać zainstalowania VNCmail+, zanim będzie można wybrać go jako aplikację domyślną.",
"select_account_title": "Wybierz konto",
"select_mailto_account": "Wybierz konto, które ma otworzyć ten link e-mail.",
"select_webcal_account": "Wybierz konto, które ma otworzyć ten link kalendarza.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Utracono połączenie. Próba ponownego połączenia…",
"rate_limited_title": "Uwierzytelnianie serwera jest tymczasowo ograniczone.",
"rate_limited_detail": "Bulwark wstrzymał żądania w tle, aby uniknąć blokady. Ponowna próba za {seconds}s.",
"rate_limited_detail": "VNCmail+ wstrzymał żądania w tle, aby uniknąć blokady. Ponowna próba za {seconds}s.",
"rate_limited_action_title": "Żądanie wstrzymane, aby uniknąć blokady.",
"rate_limited_action_detail": "Bulwark czeka na zakończenie okresu blokady serwera przed wysłaniem kolejnych uwierzytelnionych żądań. Spróbuj ponownie za {seconds}s."
"rate_limited_action_detail": "VNCmail+ czeka na zakończenie okresu blokady serwera przed wysłaniem kolejnych uwierzytelnionych żądań. Spróbuj ponownie za {seconds}s."
},
"notifications": {
"email_sent": "Wiadomość wysłana pomyślnie",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "To urządzenie przestanie otrzymywać powiadomienia, gdy strona jest zamknięta.",
"confirm_disable_title": "Wyłączyć powiadomienia w tle?",
"description": "Otrzymuj powiadomienia systemowe o nowych wiadomościach, gdy ta strona jest zamknięta. Dostarczane przez przekaźnik push Bulwark; przekaźnik nigdy nie widzi treści wiadomości.",
"description": "Otrzymuj powiadomienia systemowe o nowych wiadomościach, gdy ta strona jest zamknięta. Dostarczane przez przekaźnik push VNCmail+; przekaźnik nigdy nie widzi treści wiadomości.",
"disable": "Wyłącz",
"enable": "Włącz",
"ios_hint": "W systemie iOS najpierw zainstaluj stronę na ekranie głównym Safari dostarcza Web Push tylko zainstalowanym PWA.",
"reenable": "Zarejestruj ponownie",
"relay_desc": "Domyślnie używa hostowanego przekaźnika Bulwark. Zmień tylko jeśli hostujesz samodzielnie.",
"relay_desc": "Domyślnie używa hostowanego przekaźnika VNCmail+. Zmień tylko jeśli hostujesz samodzielnie.",
"relay_label": "Przekaźnik push",
"relay_locked": "Ustawione przez administratora",
"relay_locked_desc": "Adres URL przekaźnika push został ustawiony przez administratora i nie można go zmienić.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Połącz aplikację mobilną",
"description": "Zaloguj się do aplikacji mobilnej Bulwark Mail bez wpisywania czegokolwiek. Wygeneruj tutaj kod QR i zeskanuj go na ekranie logowania aplikacji.",
"description": "Zaloguj się do aplikacji mobilnej VNCmail+ bez wpisywania czegokolwiek. Wygeneruj tutaj kod QR i zeskanuj go na ekranie logowania aplikacji.",
"generate": "Pokaż kod QR",
"regenerate": "Pokaż nowy kod",
"instructions": "Otwórz aplikację Bulwark Mail, na ekranie logowania stuknij \"Skanuj kod QR\" i skieruj aparat tutaj.",
"instructions": "Otwórz aplikację VNCmail+, na ekranie logowania stuknij \"Skanuj kod QR\" i skieruj aparat tutaj.",
"expires_in": "Ten kod wygaśnie za {seconds} sekund. Można go użyć tylko raz.",
"expired": "Ten kod wygasł.",
"generating": "Generowanie…",
@@ -1706,7 +1706,7 @@
"button": "Importuj"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "Rozpocznij przewodnik"
},
"demo_welcome": {
"title": "Witaj w Bulwark Mail",
"title": "Witaj w VNCmail+",
"description": "Poznaj w pełni funkcjonalnego klienta poczty webowej - bezpośrednio w przeglądarce. Wszystkie dane pozostają na Twoim urządzeniu, więc możesz śmiało wszystko przetestować.",
"feature_email": "Czytaj i pisz wiadomości e-mail",
"feature_organize": "Etykiety, gwiazdki i foldery",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Aplicativos padrão",
"description": "Escolha se links de e-mail e calendário devem abrir no Bulwark. Tecnicamente, o Bulwark se registra como manipulador de protocolo para links mailto: e webcal:.",
"description": "Escolha se links de e-mail e calendário devem abrir no VNCmail+. Tecnicamente, o VNCmail+ se registra como manipulador de protocolo para links mailto: e webcal:.",
"unsupported": "Este navegador ou esta conexão não oferece suporte ao registro manual de manipuladores de protocolo. Talvez você ainda consiga usar o PWA instalado pelas configurações do navegador ou do sistema.",
"mailto_label": "Links de e-mail",
"mailto_description": "Abre links mailto: no Bulwark com o editor preenchido previamente.",
"mailto_description": "Abre links mailto: no VNCmail+ com o editor preenchido previamente.",
"protocol_open_mode_label": "Ao abrir links de protocolo",
"protocol_open_mode_description": "Escolha se o Bulwark abre links mailto: e webcal: em uma nova guia ou reutiliza uma sessão aberta. A opção de sessão ativa precisa da permissão de notificações para que você possa clicar em uma notificação alternativa e trazer o Bulwark para a frente se o navegador bloquear o foco.",
"protocol_open_mode_description": "Escolha se o VNCmail+ abre links mailto: e webcal: em uma nova guia ou reutiliza uma sessão aberta. A opção de sessão ativa precisa da permissão de notificações para que você possa clicar em uma notificação alternativa e trazer o VNCmail+ para a frente se o navegador bloquear o foco.",
"protocol_open_mode_active_session": "Abrir na sessão ativa se possível",
"protocol_open_mode_new_tab": "Sempre abrir uma nova guia",
"focus_notification_title": "Abrir Bulwark",
"focus_notification_body": "O link foi aberto no Bulwark. Clique para trazer a janela para a frente.",
"focus_notification_title": "Abrir VNCmail+",
"focus_notification_body": "O link foi aberto no VNCmail+. Clique para trazer a janela para a frente.",
"webcal_label": "Links de calendário",
"webcal_description": "Abre links webcal: no Bulwark com uma janela de assinatura de calendário preenchida previamente.",
"webcal_description": "Abre links webcal: no VNCmail+ com uma janela de assinatura de calendário preenchida previamente.",
"register_mailto": "Registrar aplicativo de e-mail",
"register_webcal": "Registrar aplicativo de calendário",
"mailto_registered": "Registro do manipulador de e-mail solicitado",
@@ -165,7 +165,7 @@
"registration_failed": "Falha ao registrar manipulador de protocolo",
"opening_mailto": "Abrindo editor...",
"opening_webcal": "Abrindo calendário...",
"browser_note": "Seu navegador ou sistema operacional pode pedir confirmação e pode exigir que o Bulwark esteja instalado antes de poder ser selecionado como aplicativo padrão.",
"browser_note": "Seu navegador ou sistema operacional pode pedir confirmação e pode exigir que o VNCmail+ esteja instalado antes de poder ser selecionado como aplicativo padrão.",
"select_account_title": "Escolher conta",
"select_mailto_account": "Escolha qual conta deve abrir este link de e-mail.",
"select_webcal_account": "Escolha qual conta deve abrir este link de calendário.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Conexão perdida. Tentando reconectar…",
"rate_limited_title": "A autenticação do servidor está temporariamente limitada.",
"rate_limited_detail": "O Bulwark pausou as solicitações em segundo plano para evitar bloqueio. Nova tentativa em {seconds}s.",
"rate_limited_detail": "O VNCmail+ pausou as solicitações em segundo plano para evitar bloqueio. Nova tentativa em {seconds}s.",
"rate_limited_action_title": "Solicitação pausada para evitar bloqueio.",
"rate_limited_action_detail": "O Bulwark está aguardando o fim do cooldown do servidor antes de enviar mais solicitações autenticadas. Tente novamente em {seconds}s."
"rate_limited_action_detail": "O VNCmail+ está aguardando o fim do cooldown do servidor antes de enviar mais solicitações autenticadas. Tente novamente em {seconds}s."
},
"notifications": {
"email_sent": "E-mail enviado com sucesso",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "Este dispositivo deixará de receber alertas quando o site estiver fechado.",
"confirm_disable_title": "Desativar notificações em segundo plano?",
"description": "Receba notificações do sistema para novas mensagens quando este site estiver fechado. Entregue através do relay push do Bulwark; o relay nunca vê o conteúdo da mensagem.",
"description": "Receba notificações do sistema para novas mensagens quando este site estiver fechado. Entregue através do relay push do VNCmail+; o relay nunca vê o conteúdo da mensagem.",
"disable": "Desativar",
"enable": "Ativar",
"ios_hint": "No iOS, instale o site primeiro na tela inicial o Safari só entrega Web Push para PWAs instalados.",
"reenable": "Registrar novamente",
"relay_desc": "Usa o relay Bulwark hospedado por padrão. Altere apenas se você hospedar.",
"relay_desc": "Usa o relay VNCmail+ hospedado por padrão. Altere apenas se você hospedar.",
"relay_label": "Relay push",
"relay_locked": "Definido pelo administrador",
"relay_locked_desc": "A URL do relay push foi definida pelo seu administrador e não pode ser alterada.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Vincular aplicativo móvel",
"description": "Entre no aplicativo móvel Bulwark Mail sem digitar nada. Gere um código QR aqui e escaneie-o na tela de login do aplicativo.",
"description": "Entre no aplicativo móvel VNCmail+ sem digitar nada. Gere um código QR aqui e escaneie-o na tela de login do aplicativo.",
"generate": "Mostrar código QR",
"regenerate": "Mostrar um novo código",
"instructions": "Abra o aplicativo Bulwark Mail, toque em \"Escanear código QR\" na tela de login e aponte sua câmera para cá.",
"instructions": "Abra o aplicativo VNCmail+, toque em \"Escanear código QR\" na tela de login e aponte sua câmera para cá.",
"expires_in": "Este código expira em {seconds} segundos. Ele só pode ser usado uma vez.",
"expired": "Este código expirou.",
"generating": "Gerando…",
@@ -1706,7 +1706,7 @@
"button": "Importar"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "Iniciar tour"
},
"demo_welcome": {
"title": "Bem-vindo ao Bulwark Mail",
"title": "Bem-vindo ao VNCmail+",
"description": "Explore um cliente de webmail completo - diretamente no seu navegador. Todos os dados ficam no seu dispositivo, então teste tudo.",
"feature_email": "Ler e escrever e-mails",
"feature_organize": "Tags, estrelas e pastas",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Aplicații implicite",
"description": "Alegeți dacă linkurile către e-mail și calendar se deschid în Bulwark. Din punct de vedere tehnic, Bulwark se înregistrează ca gestionar de protocol pentru linkurile de tip mailto: și webcal:.",
"description": "Alegeți dacă linkurile către e-mail și calendar se deschid în VNCmail+. Din punct de vedere tehnic, VNCmail+ se înregistrează ca gestionar de protocol pentru linkurile de tip mailto: și webcal:.",
"unsupported": "Acest browser sau această conexiune nu acceptă înregistrarea manuală a gestionarului de protocoale. Este posibil să puteți utiliza totuși aplicația PWA instalată prin setările browserului sau ale sistemului de operare.",
"mailto_label": "Linkuri din e-mail",
"mailto_description": "Deschideți linkurile „mailto:” în Bulwark cu o fereastră de redactare precompletată.",
"mailto_description": "Deschideți linkurile „mailto:” în VNCmail+ cu o fereastră de redactare precompletată.",
"protocol_open_mode_label": "La deschiderea linkurilor de protocol",
"protocol_open_mode_description": "Alegeți dacă Bulwark deschide linkurile mailto: și webcal: într-o filă nouă sau reutilizează o sesiune deschisă. Opțiunea „sesiune activă” necesită permisiunea de notificare, astfel încât să puteți face clic pe o notificare de rezervă pentru a aduce Bulwark în prim-plan dacă browserul blochează focalizarea.",
"protocol_open_mode_description": "Alegeți dacă VNCmail+ deschide linkurile mailto: și webcal: într-o filă nouă sau reutilizează o sesiune deschisă. Opțiunea „sesiune activă” necesită permisiunea de notificare, astfel încât să puteți face clic pe o notificare de rezervă pentru a aduce VNCmail+ în prim-plan dacă browserul blochează focalizarea.",
"protocol_open_mode_active_session": "Deschideți într-o sesiune activă, dacă este posibil",
"protocol_open_mode_new_tab": "Deschideți întotdeauna o filă nouă",
"focus_notification_title": "Deschideți Bulwark",
"focus_notification_body": "Linkul a fost deschis în Bulwark. Faceți clic pentru a aduce fereastra în prim-plan.",
"focus_notification_title": "Deschideți VNCmail+",
"focus_notification_body": "Linkul a fost deschis în VNCmail+. Faceți clic pentru a aduce fereastra în prim-plan.",
"webcal_label": "Linkuri către calendar",
"webcal_description": "Deschideți linkurile webcal: din Bulwark cu o fereastră de dialog de abonare la calendar completată în prealabil.",
"webcal_description": "Deschideți linkurile webcal: din VNCmail+ cu o fereastră de dialog de abonare la calendar completată în prealabil.",
"register_mailto": "Înregistrați aplicația de e-mail",
"register_webcal": "Înregistrați aplicația de calendar",
"mailto_registered": "S-a solicitat înregistrarea unui gestionar de e-mail",
@@ -165,7 +165,7 @@
"registration_failed": "Înregistrarea gestionarului de protocol a eșuat",
"opening_mailto": "Se deschide fereastra de redactare...",
"opening_webcal": "Se deschide calendarul...",
"browser_note": "Este posibil ca browserul sau sistemul de operare să vă solicite confirmarea acestei acțiuni și să necesite instalareBulwarkului înainte de a putea fi selectat ca aplicație implicită.",
"browser_note": "Este posibil ca browserul sau sistemul de operare să vă solicite confirmarea acestei acțiuni și să necesite instalareVNCmail+ului înainte de a putea fi selectat ca aplicație implicită.",
"select_account_title": "Alegeți contul",
"select_mailto_account": "Alegeți contul în care să se deschidă acest link de e-mail.",
"select_webcal_account": "Alegeți contul care ar trebui să deschidă acest link către calendar.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Conexiune pierdută. Se încearcă reconectarea…",
"rate_limited_title": "Autentificarea pe server este temporar limitată ca frecvență.",
"rate_limited_detail": "Bulwark A suspendat solicitările de fundal pentru a evita blocarea. Se va încerca din nou în câteva {seconds} s.",
"rate_limited_detail": "VNCmail+ A suspendat solicitările de fundal pentru a evita blocarea. Se va încerca din nou în câteva {seconds} s.",
"rate_limited_action_title": "Solicitarea a fost pusă în așteptare pentru a evita blocarea contului.",
"rate_limited_action_detail": "Bulwark Așteaptă încheierea perioadei de așteptare a serverului înainte de a trimite alte cereri autentificate. Încercați din nou în câteva {seconds}."
"rate_limited_action_detail": "VNCmail+ Așteaptă încheierea perioadei de așteptare a serverului înainte de a trimite alte cereri autentificate. Încercați din nou în câteva {seconds}."
},
"notifications": {
"email_sent": "E-mailul a fost trimis cu succes",
@@ -1052,9 +1052,9 @@
},
"push": {
"title": "Notificări în fundal",
"description": "Primiți notificări de sistem pentru e-mailuri noi chiar și atunci când acest site este închis. Notificările sunt transmise prin intermediul serverului de push Bulwark; serverul nu vede niciodată conținutul e-mailurilor.",
"description": "Primiți notificări de sistem pentru e-mailuri noi chiar și atunci când acest site este închis. Notificările sunt transmise prin intermediul serverului de push VNCmail+; serverul nu vede niciodată conținutul e-mailurilor.",
"relay_label": "Relay push",
"relay_desc": "Implicit este setat serverul de retransmitere găzduit Bulwark. Modificați această setare numai dacă utilizați un server propriu.",
"relay_desc": "Implicit este setat serverul de retransmitere găzduit VNCmail+. Modificați această setare numai dacă utilizați un server propriu.",
"relay_locked": "Configurat de administrator",
"relay_locked_desc": "Relay-ul push URL a fost configurat de administratorul dvs. și nu poate fi modificat.",
"relay_placeholder": "https://notifications.relay.example.com",
@@ -1535,10 +1535,10 @@
},
"link_device": {
"title": "Aplicație mobilă",
"description": "Conectați-vă la aplicația mobilă „Bulwark” fără a introduce nimic. Generați un cod QR aici și scanați-l din ecranul de conectare al aplicației.",
"description": "Conectați-vă la aplicația mobilă „VNCmail+” fără a introduce nimic. Generați un cod QR aici și scanați-l din ecranul de conectare al aplicației.",
"generate": "Afișați codul QR",
"regenerate": "Afișați un cod nou",
"instructions": "Deschideți aplicația „BulwarkMail”, atingeți „Scanează codul QR” pe ecranul de autentificare și îndreptați camera în această direcție.",
"instructions": "Deschideți aplicația „VNCmail+Mail”, atingeți „Scanează codul QR” pe ecranul de autentificare și îndreptați camera în această direcție.",
"expires_in": "Acest cod expiră în {seconds} secunde. Poate fi utilizat o singură dată.",
"expired": "Acest cod a expirat.",
"generating": "Se generează…",
@@ -1713,7 +1713,7 @@
"button": "Import"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3055,7 +3055,7 @@
"start_tour": "Începe turul"
},
"demo_welcome": {
"title": "Bine ați venit la Bulwark Mail",
"title": "Bine ați venit la VNCmail+",
"description": "Explorați un client de e-mail web cu funcționalități complete direct în browserul dvs. Toate datele rămân pe dispozitivul dvs., așa că nu ezitați să testați totul.",
"feature_email": "Citiți și redactați e-mailuri",
"feature_organize": "Etichete, stele și dosare",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Приложения по умолчанию",
"description": "Выберите, должны ли ссылки электронной почты и календаря открываться в Bulwark. Технически Bulwark регистрируется как обработчик протокола для ссылок mailto: и webcal:.",
"description": "Выберите, должны ли ссылки электронной почты и календаря открываться в VNCmail+. Технически VNCmail+ регистрируется как обработчик протокола для ссылок mailto: и webcal:.",
"unsupported": "Этот браузер или это соединение не поддерживает ручную регистрацию обработчиков протоколов. Возможно, установленное PWA всё же можно использовать через настройки браузера или системы.",
"mailto_label": "Ссылки электронной почты",
"mailto_description": "Открывает ссылки mailto: в Bulwark с предварительно заполненным редактором письма.",
"mailto_description": "Открывает ссылки mailto: в VNCmail+ с предварительно заполненным редактором письма.",
"protocol_open_mode_label": "При открытии ссылок протоколов",
"protocol_open_mode_description": "Выберите, будет ли Bulwark открывать ссылки mailto: и webcal: в новой вкладке или повторно использовать открытую сессию. Для варианта с активной сессией нужно разрешение на уведомления, чтобы можно было нажать на резервное уведомление и вывести Bulwark на передний план, если браузер блокирует фокус.",
"protocol_open_mode_description": "Выберите, будет ли VNCmail+ открывать ссылки mailto: и webcal: в новой вкладке или повторно использовать открытую сессию. Для варианта с активной сессией нужно разрешение на уведомления, чтобы можно было нажать на резервное уведомление и вывести VNCmail+ на передний план, если браузер блокирует фокус.",
"protocol_open_mode_active_session": "Открывать в активной сессии, если возможно",
"protocol_open_mode_new_tab": "Всегда открывать новую вкладку",
"focus_notification_title": "Открыть Bulwark",
"focus_notification_body": "Ссылка была открыта в Bulwark. Нажмите, чтобы вывести окно на передний план.",
"focus_notification_title": "Открыть VNCmail+",
"focus_notification_body": "Ссылка была открыта в VNCmail+. Нажмите, чтобы вывести окно на передний план.",
"webcal_label": "Ссылки календаря",
"webcal_description": "Открывает ссылки webcal: в Bulwark с предварительно заполненным диалогом подписки на календарь.",
"webcal_description": "Открывает ссылки webcal: в VNCmail+ с предварительно заполненным диалогом подписки на календарь.",
"register_mailto": "Зарегистрировать почтовое приложение",
"register_webcal": "Зарегистрировать приложение календаря",
"mailto_registered": "Запрошена регистрация обработчика электронной почты",
@@ -165,7 +165,7 @@
"registration_failed": "Не удалось зарегистрировать обработчик протокола",
"opening_mailto": "Открытие редактора...",
"opening_webcal": "Открытие календаря...",
"browser_note": "Браузер или операционная система может запросить подтверждение и может потребовать, чтобы Bulwark был установлен, прежде чем его можно будет выбрать приложением по умолчанию.",
"browser_note": "Браузер или операционная система может запросить подтверждение и может потребовать, чтобы VNCmail+ был установлен, прежде чем его можно будет выбрать приложением по умолчанию.",
"select_account_title": "Выберите аккаунт",
"select_mailto_account": "Выберите, в каком аккаунте открыть эту ссылку электронной почты.",
"select_webcal_account": "Выберите, в каком аккаунте открыть эту ссылку календаря.",
@@ -738,9 +738,9 @@
"app_title": "Веб-почта",
"reconnecting": "Соединение потеряно. Попытка переподключения…",
"rate_limited_title": "Аутентификация на сервере временно ограничена.",
"rate_limited_detail": "Bulwark приостановил фоновые запросы, чтобы избежать блокировки. Повтор через {seconds}с.",
"rate_limited_detail": "VNCmail+ приостановил фоновые запросы, чтобы избежать блокировки. Повтор через {seconds}с.",
"rate_limited_action_title": "Запрос приостановлен, чтобы избежать блокировки.",
"rate_limited_action_detail": "Bulwark ждет окончания серверного тайм-аута перед отправкой новых аутентифицированных запросов. Повторите через {seconds}с."
"rate_limited_action_detail": "VNCmail+ ждет окончания серверного тайм-аута перед отправкой новых аутентифицированных запросов. Повторите через {seconds}с."
},
"notifications": {
"email_sent": "Письмо успешно отправлено",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "Это устройство перестанет получать оповещения, когда сайт закрыт.",
"confirm_disable_title": "Отключить фоновые уведомления?",
"description": "Получайте системные уведомления о новых письмах, когда этот сайт закрыт. Доставляется через push-релей Bulwark; релей никогда не видит содержимое писем.",
"description": "Получайте системные уведомления о новых письмах, когда этот сайт закрыт. Доставляется через push-релей VNCmail+; релей никогда не видит содержимое писем.",
"disable": "Отключить",
"enable": "Включить",
"ios_hint": "В iOS сначала установите сайт на главный экран – Safari доставляет Web Push только установленным PWA.",
"reenable": "Перерегистрировать",
"relay_desc": "По умолчанию используется размещённый релей Bulwark. Меняйте только если хостите самостоятельно.",
"relay_desc": "По умолчанию используется размещённый релей VNCmail+. Меняйте только если хостите самостоятельно.",
"relay_label": "Push-релей",
"relay_locked": "Установлено администратором",
"relay_locked_desc": "URL push-релея установлен вашим администратором и не может быть изменён.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Привязать мобильное приложение",
"description": "Войдите в мобильное приложение Bulwark Mail без ввода данных. Создайте здесь QR-код и отсканируйте его на экране входа в приложении.",
"description": "Войдите в мобильное приложение VNCmail+ без ввода данных. Создайте здесь QR-код и отсканируйте его на экране входа в приложении.",
"generate": "Показать QR-код",
"regenerate": "Показать новый код",
"instructions": "Откройте приложение Bulwark Mail, нажмите \"Сканировать QR-код\" на экране входа и наведите камеру сюда.",
"instructions": "Откройте приложение VNCmail+, нажмите \"Сканировать QR-код\" на экране входа и наведите камеру сюда.",
"expires_in": "Срок действия кода истечёт через {seconds} секунд. Его можно использовать только один раз.",
"expired": "Срок действия кода истёк.",
"generating": "Создание…",
@@ -1706,7 +1706,7 @@
"button": "Импортировать"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "Начать тур"
},
"demo_welcome": {
"title": "Добро пожаловать в Bulwark Mail",
"title": "Добро пожаловать в VNCmail+",
"description": "Откройте для себя полнофункциональный веб-клиент электронной почты - прямо в браузере. Все данные хранятся на вашем устройстве, поэтому вы можете смело всё проверить.",
"feature_email": "Чтение и написание писем",
"feature_organize": "Метки, звёздочки и папки",
+14 -14
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Predvolené aplikácie",
"description": "Vyberte, či sa e-mailové a kalendárové odkazy otvárajú v Bulwarku. Bulwark sa registruje ako obslužná aplikácia protokolu pre odkazy mailto: a webcal:.",
"description": "Vyberte, či sa e-mailové a kalendárové odkazy otvárajú v VNCmail+u. VNCmail+ sa registruje ako obslužná aplikácia protokolu pre odkazy mailto: a webcal:.",
"unsupported": "Tento prehľadávač alebo pripojenie nepodporuje manuálnu registráciu obslužnej aplikácie protokolu.",
"mailto_label": "E-mailové odkazy",
"mailto_description": "Otvoriť odkazy mailto: v Bulwarku s predvyplneným editorom správy.",
"mailto_description": "Otvoriť odkazy mailto: v VNCmail+u s predvyplneným editorom správy.",
"protocol_open_mode_label": "Pri otváraní odkazov protokolov",
"protocol_open_mode_description": "Vyberte, či má Bulwark otvárať odkazy mailto: a webcal: v novej karte alebo znovu použiť otvorenú reláciu.",
"protocol_open_mode_description": "Vyberte, či má VNCmail+ otvárať odkazy mailto: a webcal: v novej karte alebo znovu použiť otvorenú reláciu.",
"protocol_open_mode_active_session": "Otvoriť v aktívnej relácii, ak je to možné",
"protocol_open_mode_new_tab": "Vždy otvoriť novú kartu",
"focus_notification_title": "Otvoriť Bulwark",
"focus_notification_body": "Odkaz bol otvorený v Bulwarku. Kliknutím prenesú okno do popredia.",
"focus_notification_title": "Otvoriť VNCmail+",
"focus_notification_body": "Odkaz bol otvorený v VNCmail+u. Kliknutím prenesú okno do popredia.",
"webcal_label": "Kalendárové odkazy",
"webcal_description": "Otvoriť odkazy webcal: v Bulwarku s predvyplneným dialógom pre odber kalendára.",
"webcal_description": "Otvoriť odkazy webcal: v VNCmail+u s predvyplneným dialógom pre odber kalendára.",
"register_mailto": "Zaregistrovať e-mailovú aplikáciu",
"register_webcal": "Zaregistrovať kalendárovú aplikáciu",
"mailto_registered": "Registrácia obsluhy e-mailových odkazov bola požiadaná",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Spojenie prerušené. Pokus o opätovné pripojenie…",
"rate_limited_title": "Overenie servera je dočasne obmedzené.",
"rate_limited_detail": "Bulwark pozastavil požiadavky na pozadí, aby zabránil zablokovaniu. Opätovný pokus za {seconds} s.",
"rate_limited_detail": "VNCmail+ pozastavil požiadavky na pozadí, aby zabránil zablokovaniu. Opätovný pokus za {seconds} s.",
"rate_limited_action_title": "Požiadavka pozastavená, aby sa zabránilo zablokovaniu.",
"rate_limited_action_detail": "Bulwark čaká na ukončenie doby blokovania servera. Skúste to znova za {seconds} s."
"rate_limited_action_detail": "VNCmail+ čaká na ukončenie doby blokovania servera. Skúste to znova za {seconds} s."
},
"notifications": {
"email_sent": "E-mail bol úspešne odoslaný",
@@ -1076,9 +1076,9 @@
},
"push": {
"title": "Oznámenia na pozadí",
"description": "Prijímať systémové oznámenia o novej pošte, keď je táto stránka zatvorená. Doručované cez push relay Bulwark.",
"description": "Prijímať systémové oznámenia o novej pošte, keď je táto stránka zatvorená. Doručované cez push relay VNCmail+.",
"relay_label": "Push relay",
"relay_desc": "Predvolený je hostovaný relay Bulwark. Zmeňte iba ak používate vlastný hosting.",
"relay_desc": "Predvolený je hostovaný relay VNCmail+. Zmeňte iba ak používate vlastný hosting.",
"relay_locked": "Nastavené administrátorom",
"relay_locked_desc": "URL push relay bola nastavená administrátorom a nedá sa zmeniť.",
"relay_placeholder": "https://notifications.relay.example.com",
@@ -1535,10 +1535,10 @@
},
"link_device": {
"title": "Prepojiť mobilnú aplikáciu",
"description": "Prihláste sa do mobilnej aplikácie Bulwark Mail bez písania. Vygenerujte tu QR kód a naskenujte ho z prihlasovacej obrazovky aplikácie.",
"description": "Prihláste sa do mobilnej aplikácie VNCmail+ bez písania. Vygenerujte tu QR kód a naskenujte ho z prihlasovacej obrazovky aplikácie.",
"generate": "Zobraziť QR kód",
"regenerate": "Zobraziť nový kód",
"instructions": "Otvorte aplikáciu Bulwark Mail, klepnite na \"Skenovať QR kód\" na prihlasovacej obrazovke a namieste fotoaparát sem.",
"instructions": "Otvorte aplikáciu VNCmail+, klepnite na \"Skenovať QR kód\" na prihlasovacej obrazovke a namieste fotoaparát sem.",
"expires_in": "Tento kód vyprší za {seconds} sekúnd. Dá sa použiť iba raz.",
"expired": "Platnosť tohto kódu vypršala.",
"generating": "Generovanie…",
@@ -1713,7 +1713,7 @@
"button": "Importovať"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3055,7 +3055,7 @@
"start_tour": "Spustiť sprievodcu"
},
"demo_welcome": {
"title": "Vitajte v Bulwark Mail",
"title": "Vitajte v VNCmail+",
"description": "Preskúmajte plnohodnotného webového e-mailového klienta priamo v prehliadači. Všetky dáta zostávajú na vašom zariadení.",
"feature_email": "Čítajte a píšte e-maily",
"feature_organize": "Štítky, hviezdičky a priečinky",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Varsayılan uygulamalar",
"description": "E-posta ve takvim bağlantılarının Bulwark'ta açılıp açılmayacağını seçin. Teknik olarak Bulwark, mailto: ve webcal: bağlantıları için protokol işleyicisi olarak kaydolur.",
"description": "E-posta ve takvim bağlantılarının VNCmail+'ta açılıp açılmayacağını seçin. Teknik olarak VNCmail+, mailto: ve webcal: bağlantıları için protokol işleyicisi olarak kaydolur.",
"unsupported": "Bu tarayıcı veya bağlantı manuel protokol işleyicisi kaydını desteklemiyor. Yüklü PWA'yı yine de tarayıcı veya işletim sistemi ayarlarından kullanabilirsiniz.",
"mailto_label": "E-posta bağlantıları",
"mailto_description": "mailto: bağlantılarını Bulwark'ta önceden doldurulmuş düzenleyiciyle açın.",
"mailto_description": "mailto: bağlantılarını VNCmail+'ta önceden doldurulmuş düzenleyiciyle açın.",
"protocol_open_mode_label": "Protokol bağlantıları açılırken",
"protocol_open_mode_description": "Bulwark'ın mailto: ve webcal: bağlantılarını yeni bir sekmede açmasını mı yoksa açık bir oturumu yeniden kullanmasını mı istediğinizi seçin. Etkin oturum seçeneği, tarayıcı odağı engellerse Bulwark'ı öne getirmek için yedek bildirime tıklayabilmeniz amacıyla bildirim izni gerektirir.",
"protocol_open_mode_description": "VNCmail+'ın mailto: ve webcal: bağlantılarını yeni bir sekmede açmasını mı yoksa açık bir oturumu yeniden kullanmasını mı istediğinizi seçin. Etkin oturum seçeneği, tarayıcı odağı engellerse VNCmail+'ı öne getirmek için yedek bildirime tıklayabilmeniz amacıyla bildirim izni gerektirir.",
"protocol_open_mode_active_session": "Mümkünse etkin oturumda aç",
"protocol_open_mode_new_tab": "Her zaman yeni sekme aç",
"focus_notification_title": "Bulwark'ı aç",
"focus_notification_body": "Bağlantı Bulwark'ta açıldı. Pencereyi öne getirmek için tıklayın.",
"focus_notification_title": "VNCmail+'ı aç",
"focus_notification_body": "Bağlantı VNCmail+'ta açıldı. Pencereyi öne getirmek için tıklayın.",
"webcal_label": "Takvim bağlantıları",
"webcal_description": "webcal: bağlantılarını Bulwark'ta önceden doldurulmuş takvim aboneliği penceresiyle açın.",
"webcal_description": "webcal: bağlantılarını VNCmail+'ta önceden doldurulmuş takvim aboneliği penceresiyle açın.",
"register_mailto": "E-posta uygulaması olarak kaydet",
"register_webcal": "Takvim uygulaması olarak kaydet",
"mailto_registered": "E-posta işleyicisi kaydı istendi",
@@ -165,7 +165,7 @@
"registration_failed": "Protokol işleyicisi kaydı başarısız oldu",
"opening_mailto": "Düzenleyici açılıyor...",
"opening_webcal": "Takvim açılıyor...",
"browser_note": "Tarayıcınız veya işletim sisteminiz bunu onaylamanızı isteyebilir ve Bulwark'ın varsayılan uygulama olarak seçilebilmesi için yüklenmiş olmasını gerektirebilir.",
"browser_note": "Tarayıcınız veya işletim sisteminiz bunu onaylamanızı isteyebilir ve VNCmail+'ın varsayılan uygulama olarak seçilebilmesi için yüklenmiş olmasını gerektirebilir.",
"select_account_title": "Hesap seç",
"select_mailto_account": "Bu e-posta bağlantısını hangi hesabın açacağını seçin.",
"select_webcal_account": "Bu takvim bağlantısını hangi hesabın açacağını seçin.",
@@ -738,9 +738,9 @@
"app_title": "Webmail",
"reconnecting": "Bağlantı kesildi. Yeniden bağlanılmaya çalışılıyor…",
"rate_limited_title": "Sunucu kimlik doğrulaması geçici olarak hız sınırlamasına tabi.",
"rate_limited_detail": "Hesap kilitlenmesini önlemek için Bulwark arka plan isteklerini duraklattı. {seconds} saniye içinde yeniden deneniyor.",
"rate_limited_detail": "Hesap kilitlenmesini önlemek için VNCmail+ arka plan isteklerini duraklattı. {seconds} saniye içinde yeniden deneniyor.",
"rate_limited_action_title": "Kilitlenmeyi önlemek için istek duraklatıldı.",
"rate_limited_action_detail": "Bulwark, daha fazla kimlik doğrulama isteği göndermeden önce sunucunun soğuma süresinin bitmesini bekliyor. {seconds} saniye içinde tekrar deneyin."
"rate_limited_action_detail": "VNCmail+, daha fazla kimlik doğrulama isteği göndermeden önce sunucunun soğuma süresinin bitmesini bekliyor. {seconds} saniye içinde tekrar deneyin."
},
"notifications": {
"email_sent": "E-posta başarıyla gönderildi",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "Bu cihaz, site kapalıyken uyarı almayı durduracak.",
"confirm_disable_title": "Arka plan bildirimleri devre dışı bırakılsın mı?",
"description": "Bu site kapalıyken yeni postalar için sistem bildirimleri alın. Bulwark push röle aracılığıyla iletilir; röle posta içeriğini asla görmez.",
"description": "Bu site kapalıyken yeni postalar için sistem bildirimleri alın. VNCmail+ push röle aracılığıyla iletilir; röle posta içeriğini asla görmez.",
"disable": "Devre dışı bırak",
"enable": "Etkinleştir",
"ios_hint": "iOS'ta önce siteyi ana ekranınıza yükleyin Safari Web Push'u yalnızca yüklü PWA'lara teslim eder.",
"reenable": "Yeniden kaydet",
"relay_desc": "Varsayılan olarak barındırılan Bulwark rölesini kullanır. Yalnızca kendiniz barındırıyorsanız değiştirin.",
"relay_desc": "Varsayılan olarak barındırılan VNCmail+ rölesini kullanır. Yalnızca kendiniz barındırıyorsanız değiştirin.",
"relay_label": "Push röle",
"relay_locked": "Yönetici tarafından ayarlandı",
"relay_locked_desc": "Push röle URL'si yöneticiniz tarafından ayarlandı ve değiştirilemez.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Mobil uygulamayı bağla",
"description": "Hiçbir şey yazmadan Bulwark Mail mobil uygulamasında oturum açın. Burada bir QR kodu oluşturun ve uygulamanın giriş ekranından tarayın.",
"description": "Hiçbir şey yazmadan VNCmail+ mobil uygulamasında oturum açın. Burada bir QR kodu oluşturun ve uygulamanın giriş ekranından tarayın.",
"generate": "QR kodunu göster",
"regenerate": "Yeni kod göster",
"instructions": "Bulwark Mail uygulamasını açın, giriş ekranında \"QR kodu tara\" seçeneğine dokunun ve kameranızı buraya doğrultun.",
"instructions": "VNCmail+ uygulamasını açın, giriş ekranında \"QR kodu tara\" seçeneğine dokunun ve kameranızı buraya doğrultun.",
"expires_in": "Bu kodun süresi {seconds} saniye içinde dolacak. Yalnızca bir kez kullanılabilir.",
"expired": "Bu kodun süresi doldu.",
"generating": "Oluşturuluyor…",
@@ -1706,7 +1706,7 @@
"button": "İçe Aktar"
},
"about": {
"title": "Bulwark Webmail"
"title": "VNCmail+"
}
},
"sidebar_apps": {
@@ -3055,7 +3055,7 @@
"start_tour": "Turu Başlat"
},
"demo_welcome": {
"title": "Bulwark Mail'e Hoş Geldiniz",
"title": "VNCmail+'e Hoş Geldiniz",
"description": "Tam özellikli bir web posta istemcisini doğrudan tarayıcınızda keşfedin. Tüm veriler cihazınızda kalır, her şeyi özgürce test edin.",
"feature_email": "E-posta oku ve oluştur",
"feature_organize": "Etiketler, yıldızlar ve klasörler",
+14 -14
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "Програми за замовчуванням",
"description": "Виберіть, чи відкривати посилання електронної пошти та календаря в Bulwark. Технічно Bulwark реєструється як обробник протоколу для посилань mailto: і webcal:.",
"description": "Виберіть, чи відкривати посилання електронної пошти та календаря в VNCmail+. Технічно VNCmail+ реєструється як обробник протоколу для посилань mailto: і webcal:.",
"unsupported": "Цей браузер або це з'єднання не підтримує ручну реєстрацію обробників протоколів. Можливо, встановлену PWA все одно можна використати через налаштування браузера або системи.",
"mailto_label": "Посилання електронної пошти",
"mailto_description": "Відкриває посилання mailto: у Bulwark із попередньо заповненим редактором листа.",
"mailto_description": "Відкриває посилання mailto: у VNCmail+ із попередньо заповненим редактором листа.",
"protocol_open_mode_label": "Під час відкриття посилань протоколів",
"protocol_open_mode_description": "Виберіть, чи Bulwark має відкривати посилання mailto: і webcal: у новій вкладці, чи повторно використовувати відкритий сеанс. Для варіанта активного сеансу потрібен дозвіл на сповіщення, щоб ви могли натиснути резервне сповіщення й вивести Bulwark на передній план, якщо браузер блокує фокус.",
"protocol_open_mode_description": "Виберіть, чи VNCmail+ має відкривати посилання mailto: і webcal: у новій вкладці, чи повторно використовувати відкритий сеанс. Для варіанта активного сеансу потрібен дозвіл на сповіщення, щоб ви могли натиснути резервне сповіщення й вивести VNCmail+ на передній план, якщо браузер блокує фокус.",
"protocol_open_mode_active_session": "Якщо можливо, відкривати в активному сеансі",
"protocol_open_mode_new_tab": "Завжди відкривати нову вкладку",
"focus_notification_title": "Відкрити Bulwark",
"focus_notification_body": "Посилання було відкрито в Bulwark. Натисніть, щоб вивести вікно на передній план.",
"focus_notification_title": "Відкрити VNCmail+",
"focus_notification_body": "Посилання було відкрито в VNCmail+. Натисніть, щоб вивести вікно на передній план.",
"webcal_label": "Посилання календаря",
"webcal_description": "Відкриває посилання webcal: у Bulwark із попередньо заповненим діалогом підписки на календар.",
"webcal_description": "Відкриває посилання webcal: у VNCmail+ із попередньо заповненим діалогом підписки на календар.",
"register_mailto": "Зареєструвати поштову програму",
"register_webcal": "Зареєструвати програму календаря",
"mailto_registered": "Реєстрацію обробника електронної пошти запитано",
@@ -165,7 +165,7 @@
"registration_failed": "Не вдалося зареєструвати обробник протоколу",
"opening_mailto": "Відкриття редактора...",
"opening_webcal": "Відкриття календаря...",
"browser_note": "Браузер або операційна система може попросити підтвердження та може вимагати, щоб Bulwark був встановлений, перш ніж його можна буде вибрати програмою за замовчуванням.",
"browser_note": "Браузер або операційна система може попросити підтвердження та може вимагати, щоб VNCmail+ був встановлений, перш ніж його можна буде вибрати програмою за замовчуванням.",
"select_account_title": "Виберіть акаунт",
"select_mailto_account": "Виберіть акаунт, у якому слід відкрити це посилання електронної пошти.",
"select_webcal_account": "Виберіть акаунт, у якому слід відкрити це посилання календаря.",
@@ -738,9 +738,9 @@
"app_title": "Веб-пошта",
"reconnecting": "З'єднання втрачено. Спроба повторного підключення…",
"rate_limited_title": "Швидкість автентифікації сервера тимчасово обмежена.",
"rate_limited_detail": "Bulwark призупинив фонові запити, щоб уникнути блокування. Повторна спроба через {seconds} с.",
"rate_limited_detail": "VNCmail+ призупинив фонові запити, щоб уникнути блокування. Повторна спроба через {seconds} с.",
"rate_limited_action_title": "Запит призупинено, щоб уникнути блокування.",
"rate_limited_action_detail": "Bulwark чекає закінчення часу відновлення сервера, перш ніж надсилати додаткові автентифіковані запити. Повторіть спробу через {seconds} с."
"rate_limited_action_detail": "VNCmail+ чекає закінчення часу відновлення сервера, перш ніж надсилати додаткові автентифіковані запити. Повторіть спробу через {seconds} с."
},
"notifications": {
"email_sent": "Електронна пошта успішно надіслана",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "Цей пристрій перестане отримувати сповіщення, коли сайт закритий.",
"confirm_disable_title": "Вимкнути фонові сповіщення?",
"description": "Отримуйте системні сповіщення про нові листи, коли цей сайт закритий. Доставляється через push-реле Bulwark; реле ніколи не бачить вміст листів.",
"description": "Отримуйте системні сповіщення про нові листи, коли цей сайт закритий. Доставляється через push-реле VNCmail+; реле ніколи не бачить вміст листів.",
"disable": "Вимкнути",
"enable": "Увімкнути",
"ios_hint": "На iOS спочатку встановіть сайт на головний екран – Safari доставляє Web Push лише встановленим PWA.",
"reenable": "Перереєструвати",
"relay_desc": "Типово використовується розміщене реле Bulwark. Змінюйте лише, якщо хостите самостійно.",
"relay_desc": "Типово використовується розміщене реле VNCmail+. Змінюйте лише, якщо хостите самостійно.",
"relay_label": "Push-реле",
"relay_locked": "Встановлено адміністратором",
"relay_locked_desc": "URL push-реле встановлено вашим адміністратором і його не можна змінити.",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "Прив'язати мобільний застосунок",
"description": "Увійдіть у мобільний застосунок Bulwark Mail без введення даних. Створіть тут QR-код і відскануйте його на екрані входу в застосунку.",
"description": "Увійдіть у мобільний застосунок VNCmail+ без введення даних. Створіть тут QR-код і відскануйте його на екрані входу в застосунку.",
"generate": "Показати QR-код",
"regenerate": "Показати новий код",
"instructions": "Відкрийте застосунок Bulwark Mail, натисніть \"Сканувати QR-код\" на екрані входу та наведіть камеру сюди.",
"instructions": "Відкрийте застосунок VNCmail+, натисніть \"Сканувати QR-код\" на екрані входу та наведіть камеру сюди.",
"expires_in": "Термін дії коду закінчиться через {seconds} секунд. Його можна використати лише один раз.",
"expired": "Термін дії цього коду закінчився.",
"generating": "Створення…",
@@ -3032,7 +3032,7 @@
"start_tour": "Розпочати тур"
},
"demo_welcome": {
"title": "Ласкаво просимо до Bulwark Mail",
"title": "Ласкаво просимо до VNCmail+",
"description": "Відкрийте для себе повнофункціональний клієнт веб-пошти - прямо у вашому браузері. Усі дані залишаються на вашому пристрої, тому сміливо тестуйте все.",
"feature_email": "Читайте та створюйте електронний лист",
"feature_organize": "Теги, зірочки та папки",
+15 -15
View File
@@ -146,18 +146,18 @@
},
"protocol_handlers": {
"title": "默认应用",
"description": "选择是否在 Bulwark 中打开电子邮件和日历链接。从技术上讲,Bulwark 会注册为 mailto: 和 webcal: 链接的协议处理程序。",
"description": "选择是否在 VNCmail+ 中打开电子邮件和日历链接。从技术上讲,VNCmail+ 会注册为 mailto: 和 webcal: 链接的协议处理程序。",
"unsupported": "此浏览器或连接不支持手动注册协议处理程序。你仍可尝试通过浏览器或系统设置使用已安装的 PWA。",
"mailto_label": "电子邮件链接",
"mailto_description": "在 Bulwark 中打开 mailto: 链接,并预先填好撰写窗口。",
"mailto_description": "在 VNCmail+ 中打开 mailto: 链接,并预先填好撰写窗口。",
"protocol_open_mode_label": "打开协议链接时",
"protocol_open_mode_description": "选择 Bulwark 是在新标签页中打开 mailto: 和 webcal: 链接,还是复用已打开的会话。活动会话选项需要通知权限,这样当浏览器阻止聚焦时,你可以点击备用通知将 Bulwark 窗口带到前台。",
"protocol_open_mode_description": "选择 VNCmail+ 是在新标签页中打开 mailto: 和 webcal: 链接,还是复用已打开的会话。活动会话选项需要通知权限,这样当浏览器阻止聚焦时,你可以点击备用通知将 VNCmail+ 窗口带到前台。",
"protocol_open_mode_active_session": "尽可能在活动会话中打开",
"protocol_open_mode_new_tab": "始终打开新标签页",
"focus_notification_title": "打开 Bulwark",
"focus_notification_body": "链接已在 Bulwark 中打开。点击可将窗口带到前台。",
"focus_notification_title": "打开 VNCmail+",
"focus_notification_body": "链接已在 VNCmail+ 中打开。点击可将窗口带到前台。",
"webcal_label": "日历链接",
"webcal_description": "在 Bulwark 中打开 webcal: 链接,并预先填好日历订阅对话框。",
"webcal_description": "在 VNCmail+ 中打开 webcal: 链接,并预先填好日历订阅对话框。",
"register_mailto": "注册电子邮件应用",
"register_webcal": "注册日历应用",
"mailto_registered": "已请求注册电子邮件处理程序",
@@ -165,7 +165,7 @@
"registration_failed": "协议处理程序注册失败",
"opening_mailto": "正在打开撰写窗口...",
"opening_webcal": "正在打开日历...",
"browser_note": "你的浏览器或操作系统可能会要求确认,并且可能需要先安装 Bulwark,才能将其选为默认应用。",
"browser_note": "你的浏览器或操作系统可能会要求确认,并且可能需要先安装 VNCmail+,才能将其选为默认应用。",
"select_account_title": "选择账户",
"select_mailto_account": "选择用于打开此电子邮件链接的账户。",
"select_webcal_account": "选择用于打开此日历链接的账户。",
@@ -738,9 +738,9 @@
"app_title": "网页邮箱",
"reconnecting": "连接已断开,正在尝试重连…",
"rate_limited_title": "服务器身份验证暂时受到速率限制。",
"rate_limited_detail": "Bulwark 已暂停后台请求以避免账户被锁定。将在 {seconds} 秒后重试。",
"rate_limited_detail": "VNCmail+ 已暂停后台请求以避免账户被锁定。将在 {seconds} 秒后重试。",
"rate_limited_action_title": "请求已暂停以避免账户被锁定。",
"rate_limited_action_detail": "Bulwark 正在等待服务器冷却,然后再发送更多请求。请在 {seconds} 秒后重试。"
"rate_limited_action_detail": "VNCmail+ 正在等待服务器冷却,然后再发送更多请求。请在 {seconds} 秒后重试。"
},
"notifications": {
"email_sent": "邮件发送成功",
@@ -1074,12 +1074,12 @@
"push": {
"confirm_disable_message": "当网站关闭时,此设备将停止接收提醒。",
"confirm_disable_title": "禁用后台通知?",
"description": "在此网站关闭时接收新邮件的系统通知。通过 Bulwark 推送中继传送;中继永远不会看到邮件内容。",
"description": "在此网站关闭时接收新邮件的系统通知。通过 VNCmail+ 推送中继传送;中继永远不会看到邮件内容。",
"disable": "禁用",
"enable": "启用",
"ios_hint": "在 iOS 上,请先将网站安装到主屏幕 – Safari 仅向已安装的 PWA 发送 Web Push。",
"reenable": "重新注册",
"relay_desc": "默认使用托管的 Bulwark 中继。仅当您自托管时才更改。",
"relay_desc": "默认使用托管的 VNCmail+ 中继。仅当您自托管时才更改。",
"relay_label": "推送中继",
"relay_locked": "由管理员设置",
"relay_locked_desc": "推送中继 URL 已由您的管理员设置,无法更改。",
@@ -1528,10 +1528,10 @@
},
"link_device": {
"title": "关联移动应用",
"description": "无需输入任何内容即可登录 Bulwark Mail 移动应用。在此生成二维码,然后在应用的登录界面扫描。",
"description": "无需输入任何内容即可登录 VNCmail+ 移动应用。在此生成二维码,然后在应用的登录界面扫描。",
"generate": "显示二维码",
"regenerate": "显示新的二维码",
"instructions": "打开 Bulwark Mail 应用,在登录界面点按\"扫描二维码\",然后将相机对准此处。",
"instructions": "打开 VNCmail+ 应用,在登录界面点按\"扫描二维码\",然后将相机对准此处。",
"expires_in": "此二维码将在 {seconds} 秒后过期,且只能使用一次。",
"expired": "此二维码已过期。",
"generating": "生成中…",
@@ -1706,7 +1706,7 @@
"button": "导入"
},
"about": {
"title": "Bulwark 网页邮箱"
"title": "VNCmail+ 网页邮箱"
}
},
"sidebar_apps": {
@@ -3032,7 +3032,7 @@
"start_tour": "开始导览"
},
"demo_welcome": {
"title": "欢迎使用 Bulwark Mail",
"title": "欢迎使用 VNCmail+",
"description": "探索功能齐全的网页邮件客户端--就在您的浏览器中。所有数据都保留在您的设备上,请随意体验所有功能。",
"feature_email": "阅读和撰写邮件",
"feature_organize": "标签、星标和文件夹",
+2 -2
View File
@@ -2,8 +2,8 @@
"name": "vncmail-plus",
"version": "1.7.8",
"main": "dist-electron/main.js",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"description": "VNCmail+ - a modern webmail client built for Stalwart Mail Server",
"author": "VNC AG",
"license": "AGPL-3.0-only",
"repository": {
"type": "git",
+16 -1
View File
@@ -109,7 +109,22 @@ export async function proxy(request: NextRequest) {
// https-served production app already gets unencrypted connections
// blocked as mixed content by the browser itself, so allowing bare `ws:`
// here would add no capability, only a false sense of one.
const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https: wss:`;
// `http://127.0.0.1:*`/`http://localhost:*` in production alongside
// `https:`: the AI Assistant's `local` provider class (lib/ai/local-client.ts)
// talks directly to a loopback Ollama-compatible runtime, over plain HTTP -
// Ollama has no built-in TLS story, and there's no realistic MITM risk to
// guard against on loopback (no network hop ever occurs). This is
// deliberately NOT the same relaxation as blanket `http:` in dev: a
// narrow, loopback-only allowance doesn't reopen the mixed-content-style
// downgrade risk documented below for `wss:`. Confirmed as a real gap, not
// theoretical: before this fix, a production build's own Electron shell
// blocked `fetch('http://127.0.0.1:11434/...')` before any network
// attempt happened at all (a CSP violation, connect-src as the violated
// directive) - `local` was entirely inert in a production build,
// Electron or browser alike.
const connectSrc = isDev
? `'self' http: https: ws: wss:`
: `'self' https: wss: http://127.0.0.1:* http://localhost:*`;
const frameAncestors = isSandboxPath
? `'self'`