Merge branch 'dev'

This commit is contained in:
Linus Rath
2026-03-31 18:34:49 +02:00
59 changed files with 1302 additions and 455 deletions
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## 1.4.11 (2026-03-31)
### Features
- **Logging**: Add logging categories for better log management
### Fixes
- **Security**: Harden security with CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, and PDF iframe sandbox
- **Security**: Harden proxy authentication and SSRF defenses
- **Security**: Block plugins with dangerous JS patterns and enforce strict session secret length validation
- **S/MIME**: Add self-signed certificate detection and update status messages for S/MIME signatures
- **Email**: Auto-focus input fields in email composer for improved user experience (#126)
- **Mailbox**: Prevent orphaning of nested mailboxes by restricting deduplication to root-level folders
- **JMAP**: Strip server-immutable fields from updates before sending to JMAP (#128)
- **Files**: Update file feature disabled messages and add stability warnings
- **i18n**: Add missing translation keys to all non-English locales
## 1.4.10 (2026-03-31)
### Features
+1 -1
View File
@@ -1 +1 @@
1.4.10
1.4.11
+1 -1
View File
@@ -765,7 +765,7 @@ export default function CalendarPage() {
return;
}
debug.log('Calendar visibility summary', {
debug.log('calendar', 'Calendar visibility summary', {
totalEvents: events.length,
visibleEvents: visibleEvents.length,
hiddenEvents: hiddenEvents.length,
+18 -1
View File
@@ -17,15 +17,18 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsMobile } from "@/hooks/use-media-query";
import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AlertTriangle } from "lucide-react";
export default function FilesPage() {
const router = useRouter();
const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -393,11 +396,24 @@ export default function FilesPage() {
)}
<div className="flex-1 min-h-0">
{supportsFiles === false ? (
{!filesEnabled ? (
<div className="flex items-center justify-center h-full">
<div className="max-w-lg text-center space-y-3 px-4">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">{t("disabled_title")}</p>
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
</div>
</div>
) : supportsFiles === false ? (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div>
) : (
<div className="flex flex-col flex-1 min-h-0">
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
</div>
<FileBrowser
currentPath={currentPath}
resources={resources}
@@ -442,6 +458,7 @@ export default function FilesPage() {
onToggleDetails={handleToggleDetails}
detailResource={detailResource}
/>
</div>
)}
</div>
</div>
+11 -11
View File
@@ -352,13 +352,13 @@ export default function Home() {
if (pushEnabled) {
setPushConnected(true);
debug.log('[Push] Push notifications successfully enabled');
debug.log('push', '[Push] Push notifications successfully enabled');
} else {
debug.log('[Push] Push notifications not available on this server');
debug.log('push', '[Push] Push notifications not available on this server');
}
} catch (error) {
// Push notifications are optional - don't break the app if they fail
debug.log('[Push] Failed to setup push notifications:', error);
debug.log('push', '[Push] Failed to setup push notifications:', error);
}
} catch (error) {
console.error('Error loading email data:', error);
@@ -392,7 +392,7 @@ export default function Home() {
useEffect(() => {
// Clear any existing timeout when email changes
if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Clearing previous timeout');
debug.log('email', '[Mark as Read] Clearing previous timeout');
clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null;
}
@@ -404,20 +404,20 @@ export default function Home() {
// Get current setting value
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
debug.log('email', '[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
if (markAsReadDelay === -1) {
// Never mark as read automatically
debug.log('[Mark as Read] Never mode - email will stay unread');
debug.log('email', '[Mark as Read] Never mode - email will stay unread');
} else if (markAsReadDelay === 0) {
// Mark as read instantly
debug.log('[Mark as Read] Instant mode - marking as read now');
debug.log('email', '[Mark as Read] Instant mode - marking as read now');
markAsRead(client, selectedEmail.id, true);
} else {
// Mark as read after delay
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
debug.log('email', '[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
markAsReadTimeoutRef.current = setTimeout(() => {
debug.log('[Mark as Read] Timeout fired - marking as read now');
debug.log('email', '[Mark as Read] Timeout fired - marking as read now');
markAsRead(client, selectedEmail.id, true);
markAsReadTimeoutRef.current = null;
}, markAsReadDelay);
@@ -426,7 +426,7 @@ export default function Home() {
// Cleanup on unmount or when dependencies change
return () => {
if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Cleanup - clearing timeout');
debug.log('email', '[Mark as Read] Cleanup - clearing timeout');
clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null;
}
@@ -441,7 +441,7 @@ export default function Home() {
if (emailNotificationsEnabled && emailNotificationSound) {
playNotificationSound(notificationSoundChoice);
}
debug.log('New email received:', newEmailNotification.subject);
debug.log('email', 'New email received:', newEmailNotification.subject);
clearNewEmailNotification();
}
}, [newEmailNotification, clearNewEmailNotification]);
+2 -7
View File
@@ -24,6 +24,7 @@ import {
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store';
@@ -78,13 +79,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
}, [pathname]);
function getJmapHeaders(): Record<string, string> {
const client = useAuthStore.getState().client;
if (!client) return {};
return {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
return getActiveAccountSlotHeaders();
}
async function checkAuth() {
+1
View File
@@ -19,6 +19,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
};
const RESTRICTABLE_SETTINGS = [
+13 -3
View File
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
httpOnly: true,
@@ -69,10 +70,19 @@ export async function POST(request: NextRequest) {
}
// If session cookie exists, update it with the new password
const cookieStore = await cookies();
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
}
if (creds.authHeader.startsWith('Basic ')) {
setStalwartAuthContextInStore(cookieStore, creds.slot, {
serverUrl: creds.serverUrl,
username: creds.username,
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
});
}
return NextResponse.json({ ok: true });
+20
View File
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
const code = await jsFile.async('string');
// Block plugins with dangerous JS patterns
const DANGEROUS_JS_PATTERNS = [
{ pattern: /\beval\s*\(/g, label: 'eval()' },
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
{ pattern: /document\.cookie/g, label: 'document.cookie' },
{ pattern: /document\.write/g, label: 'document.write' },
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
];
const dangerousFindings: string[] = [];
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
if (pattern.test(code)) dangerousFindings.push(label);
pattern.lastIndex = 0;
}
if (dangerousFindings.length > 0) {
return NextResponse.json(
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
{ status: 400 },
);
}
// Validate permissions
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
+9 -3
View File
@@ -139,12 +139,18 @@ export async function POST(request: NextRequest) {
}
const code = await entryFile.async('string');
// Security warnings (logged but not blocking for admin)
// Security: block plugins containing dangerous JS patterns
const warnings: string[] = [];
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
if (pattern.test(code)) warnings.push(`Contains ${label}`);
pattern.lastIndex = 0;
}
if (warnings.length > 0) {
return NextResponse.json(
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
{ status: 400 },
);
}
const now = new Date().toISOString();
const plugin: ServerPlugin = {
@@ -165,9 +171,9 @@ export async function POST(request: NextRequest) {
};
await savePlugin(plugin, code);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
return NextResponse.json({ plugin, warnings });
return NextResponse.json({ plugin });
} catch (error) {
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+40 -4
View File
@@ -4,6 +4,11 @@ import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import {
clearStalwartAuthContextInStore,
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
...getCookieOptions(),
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot);
const token = encryptSession(serverUrl, username, password);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -56,9 +72,16 @@ export async function GET(request: NextRequest) {
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
const { serverUrl, username } = credentials;
return NextResponse.json(
@@ -73,13 +96,17 @@ export async function GET(request: NextRequest) {
/**
* PUT — retrieve full credentials (including password) for session restoration.
* Protected by Sec-Fetch-Site to ensure only same-origin browser requests succeed.
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
* browser fetch() requests succeed. Non-browser clients cannot forge these.
*/
export async function PUT(request: NextRequest) {
try {
// Block non-browser and cross-origin requests
// Require all Sec-Fetch-* headers to match a same-origin fetch() call.
// Browsers set these automatically and they cannot be overridden by JS.
const secFetchSite = request.headers.get('sec-fetch-site');
if (secFetchSite !== 'same-origin') {
const secFetchMode = request.headers.get('sec-fetch-mode');
const secFetchDest = request.headers.get('sec-fetch-dest');
if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
@@ -95,9 +122,16 @@ export async function PUT(request: NextRequest) {
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
return NextResponse.json(credentials, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
@@ -116,10 +150,12 @@ export async function DELETE(request: NextRequest) {
// Delete all session cookies (slots 0-4)
for (let i = 0; i <= 4; i++) {
cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i);
}
} else {
const slot = getSlot(request);
cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
}
return NextResponse.json({ ok: true });
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
return bodySlot;
}
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
}
export async function POST(request: NextRequest) {
try {
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !authHeader) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Failed to store Stalwart auth context', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+86 -21
View File
@@ -1,9 +1,39 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { NextRequest, NextResponse } from 'next/server';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000;
function isValidExternalUrl(urlString: string): boolean {
const blockedAddressRanges = new BlockList();
blockedAddressRanges.addAddress('0.0.0.0');
blockedAddressRanges.addAddress('127.0.0.1');
blockedAddressRanges.addSubnet('10.0.0.0', 8);
blockedAddressRanges.addSubnet('172.16.0.0', 12);
blockedAddressRanges.addSubnet('192.168.0.0', 16);
blockedAddressRanges.addSubnet('169.254.0.0', 16);
blockedAddressRanges.addAddress('::', 'ipv6');
blockedAddressRanges.addAddress('::1', 'ipv6');
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function isBlockedIpAddress(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized);
if (family === 4) {
return blockedAddressRanges.check(normalized, 'ipv4');
}
if (family === 6) {
return blockedAddressRanges.check(normalized, 'ipv6');
}
return false;
}
async function isValidExternalUrl(urlString: string): Promise<boolean> {
let url: URL;
try {
url = new URL(urlString);
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
return false;
}
const hostname = url.hostname.toLowerCase();
const hostname = normalizeHostname(url.hostname);
// Block private/internal hostnames
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.endsWith('.localhost') ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
hostname.endsWith('.localdomain')
) {
return false;
}
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
return false;
}
return true;
if (isBlockedIpAddress(hostname)) {
return false;
}
if (isIP(hostname)) {
return true;
}
try {
const records = await lookup(hostname, { all: true, verbatim: true });
if (records.length === 0) {
return false;
}
return records.every((record) => !isBlockedIpAddress(record.address));
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
if (!isValidExternalUrl(url)) {
if (!(await isValidExternalUrl(url))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
@@ -64,20 +106,43 @@ export async function POST(request: NextRequest) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'follow',
});
const MAX_REDIRECTS = 5;
let currentUrl = url;
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!(await isValidExternalUrl(currentUrl))) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
}
response = await fetch(currentUrl, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'manual',
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
}
// Resolve relative redirects
currentUrl = new URL(location, currentUrl).toString();
continue;
}
break;
}
clearTimeout(timeout);
if (!response.ok) {
if (!response || !response.ok) {
return NextResponse.json(
{ error: `Remote server returned ${response.status}` },
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
{ status: 502 }
);
}
+32 -8
View File
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
function normalizeDavRelativePath(rawPath: string): string {
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
const segments = sanitized.split('/').filter(Boolean);
return segments.map((segment) => {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
throw new Error('Invalid WebDAV path encoding');
}
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
throw new Error('Invalid WebDAV path segment');
}
return encodeURIComponent(decoded);
}).join('/');
}
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
const relativePath = normalizeDavRelativePath(rawPath);
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
}
/**
* POST /api/webdav
* Proxies WebDAV requests to the Stalwart server.
@@ -29,11 +55,8 @@ export async function POST(request: NextRequest) {
}
const davPath = request.headers.get('X-WebDAV-Path') || '/';
const cleanPath = davPath.replace(/^\/+/, '');
const baseUrl = creds.apiUrl.replace(/\/$/, '');
const targetUrl = cleanPath
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
// Build headers for the upstream request
const upstreamHeaders: Record<string, string> = {
@@ -50,10 +73,7 @@ export async function POST(request: NextRequest) {
// For MOVE/COPY, construct the full Destination URL from the relative path
const destination = request.headers.get('X-WebDAV-Destination');
if (destination) {
const cleanDest = destination.replace(/^\/+/, '');
upstreamHeaders['Destination'] = cleanDest
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
}
const overwrite = request.headers.get('Overwrite');
@@ -104,6 +124,10 @@ export async function POST(request: NextRequest) {
status: response.status,
});
} catch (error) {
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
+62 -11
View File
@@ -332,6 +332,17 @@ export function EmailComposer({
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
// Auto-focus the To field when composing a new email or forwarding
useEffect(() => {
if (mode === 'forward' || mode === 'compose') {
// Small delay to ensure the input is rendered
const timer = setTimeout(() => {
toInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [mode]);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
@@ -339,10 +350,26 @@ export function EmailComposer({
const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = useRef<HTMLInputElement>(null);
const bccInputRef = useRef<HTMLInputElement>(null);
const subjectInputRef = useRef<HTMLInputElement>(null);
const bodyRef = useRef<HTMLTextAreaElement>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null);
const ccDropdownRef = useRef<HTMLDivElement>(null);
const bccDropdownRef = useRef<HTMLDivElement>(null);
const focusSubject = useCallback(() => {
subjectInputRef.current?.focus();
}, []);
const focusBody = useCallback(() => {
if (plainTextMode) {
bodyRef.current?.focus();
} else {
const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null;
proseMirror?.focus();
}
}, [plainTextMode]);
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current);
@@ -1071,6 +1098,7 @@ export function EmailComposer({
onInsertAutocomplete={insertAutocomplete}
validationError={validationErrors.to}
validationMessage={t('validation.recipient_required')}
onTab={focusSubject}
/>
<div className="flex gap-0.5 shrink-0">
<Button
@@ -1140,6 +1168,7 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input
ref={subjectInputRef}
type="text"
placeholder={t('subject_placeholder')}
value={subject}
@@ -1147,6 +1176,12 @@ export function EmailComposer({
setSubject(e.target.value);
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
}}
onKeyDown={(e) => {
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
focusBody();
}
}}
className={cn(
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
@@ -1159,6 +1194,7 @@ export function EmailComposer({
{/* Body */}
{plainTextMode ? (
<textarea
ref={bodyRef}
value={body}
onChange={(e) => {
setBody(e.target.value);
@@ -1173,16 +1209,18 @@ export function EmailComposer({
aria-invalid={validationErrors.body || undefined}
/>
) : (
<RichTextEditor
content={body}
onChange={(html) => {
setBody(html);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
/>
<div ref={editorContainerRef}>
<RichTextEditor
content={body}
onChange={(html) => {
setBody(html);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
/>
</div>
)}
{plainTextMode ? (
@@ -1514,6 +1552,7 @@ function RecipientChipInput({
onInsertAutocomplete,
validationError,
validationMessage,
onTab,
}: {
value: string;
onChange: (value: string) => void;
@@ -1530,6 +1569,7 @@ function RecipientChipInput({
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
}) {
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
const hasTrailingComma = value.trimEnd().endsWith(',');
@@ -1563,7 +1603,18 @@ function RecipientChipInput({
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
if (e.key !== 'Tab') e.preventDefault();
commitCurrentInput();
setTimeout(() => inputRef.current?.focus(), 0);
if (e.key === 'Tab' && onTab) {
e.preventDefault();
setTimeout(() => onTab(), 0);
} else {
setTimeout(() => inputRef.current?.focus(), 0);
}
return;
}
if (e.key === 'Tab' && !e.shiftKey && onTab) {
e.preventDefault();
onTab();
return;
}
+24 -24
View File
@@ -1820,12 +1820,12 @@ export function EmailViewer({
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
if (!tnefAtt?.blobId) {
debug.log('TNEF: No winmail.dat attachment found in email', email?.id);
debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id);
return;
}
debug.group('TNEF Processing');
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
debug.group('TNEF Processing', 'email');
debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
// Check if the email already has a usable HTML body with real content
// Outlook often forwards TNEF emails with an HTML body that's just Word
@@ -1835,46 +1835,46 @@ export function EmailViewer({
let hasRealHtmlBody = !!htmlValue;
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
hasRealHtmlBody = false;
debug.log('TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
debug.log('email', 'TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
}
if (hasRealHtmlBody) {
debug.log('TNEF: Email has real HTML body, will extract attachments only');
debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only');
} else {
debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
}
let cancelled = false;
async function processTnef() {
try {
debug.time('TNEF fetch blob');
debug.time('TNEF fetch blob', 'email');
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
debug.timeEnd('TNEF fetch blob');
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
debug.timeEnd('TNEF fetch blob', 'email');
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
if (cancelled) {
debug.log('TNEF: Processing cancelled after fetch');
debug.log('email', 'TNEF: Processing cancelled after fetch');
debug.groupEnd();
return;
}
if (blobBytes.byteLength === 0) {
debug.warn('TNEF: Fetched blob is empty (0 bytes)');
debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)');
debug.groupEnd();
return;
}
const tnefData = new Uint8Array(blobBytes);
debug.time('TNEF parse');
debug.time('TNEF parse', 'email');
const parsed = parseTnef(tnefData);
debug.timeEnd('TNEF parse');
debug.timeEnd('TNEF parse', 'email');
if (cancelled) {
debug.log('TNEF: Processing cancelled after parse');
debug.log('email', 'TNEF: Processing cancelled after parse');
debug.groupEnd();
return;
}
debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
if (parsed.htmlBody && !hasRealHtmlBody) {
setTnefHtml(parsed.htmlBody);
@@ -1884,11 +1884,11 @@ export function EmailViewer({
}
if (parsed.attachments.length > 0) {
setTnefAttachments(parsed.attachments);
debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
}
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
}
debug.groupEnd();
@@ -1926,13 +1926,13 @@ export function EmailViewer({
const hasRealText = !!textValue;
if (hasRealHtml || hasRealText) {
debug.log('Embedded RFC822: Outer email has real body content, not unwrapping');
debug.log('email', 'Embedded RFC822: Outer email has real body content, not unwrapping');
return;
}
debug.group('Embedded RFC822 Unwrapping');
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
debug.log('Outer email body is empty, will unwrap embedded email');
debug.group('Embedded RFC822 Unwrapping', 'email');
debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
debug.log('email', 'Outer email body is empty, will unwrap embedded email');
let cancelled = false;
@@ -1941,7 +1941,7 @@ export function EmailViewer({
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
if (cancelled) { debug.groupEnd(); return; }
if (blobBytes.byteLength === 0) {
debug.warn('Embedded RFC822: Fetched blob is empty');
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
debug.groupEnd();
return;
}
@@ -1951,7 +1951,7 @@ export function EmailViewer({
const parsed = await parser.parse(new Uint8Array(blobBytes));
if (cancelled) { debug.groupEnd(); return; }
debug.log('Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
', attachments:', parsed.attachments?.length ?? 0);
@@ -1963,7 +1963,7 @@ export function EmailViewer({
}
if (parsed.attachments && parsed.attachments.length > 0) {
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
debug.log('Embedded RFC822 attachments:', parsed.attachments.map(
debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map(
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
).join(', '));
}
+7 -1
View File
@@ -55,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
// Signature status
if (status.isSigned) {
if (status.signatureValid === true) {
if (status.signerEmailMatch === false) {
if (status.selfSigned) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_self_signed'),
variant: 'warning',
});
} else if (status.signerEmailMatch === false) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_mismatch'),
+1
View File
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
{!loading && !error && fileType === "pdf" && objectUrl && (
<iframe
src={objectUrl}
sandbox="allow-same-origin"
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
+5 -8
View File
@@ -17,6 +17,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -169,6 +170,7 @@ export function NavigationRail({
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
@@ -218,13 +220,8 @@ export function NavigationRail({
useEffect(() => {
let cancelled = false;
const { client } = useAuthStore.getState();
if (!client) return;
const headers: Record<string, string> = {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
const headers = getActiveAccountSlotHeaders();
if (!headers['X-JMAP-Cookie-Slot']) return;
fetch('/api/admin/stalwart-check', { headers })
.then(res => res.json())
.then(data => {
@@ -246,7 +243,7 @@ export function NavigationRail({
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
];
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
+26 -1
View File
@@ -7,11 +7,12 @@ import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
export function AdvancedSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const { debugMode, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false);
@@ -72,6 +73,30 @@ export function AdvancedSettings() {
</SettingItem>
)}
{/* Debug Categories */}
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
{ALL_DEBUG_CATEGORIES.map((cat) => (
<SettingItem
key={cat.id}
label={t(`debug_categories.${cat.labelKey}`)}
description={t(`debug_categories.${cat.labelKey}_description`)}
>
<ToggleSwitch
checked={debugCategories?.[cat.id] !== false}
onChange={(checked) => {
updateSetting('debugCategories', {
...debugCategories,
[cat.id]: checked,
});
}}
/>
</SettingItem>
))}
</div>
)}
{/* Settings Sync */}
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
@@ -203,7 +204,10 @@ export function CalendarManagementSettings() {
fetch('/api/caldav/discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...getActiveAccountSlotHeaders(),
},
body: JSON.stringify({
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
}),
+75 -36
View File
@@ -263,48 +263,37 @@ describe('mailbox deep nesting (depth 4+)', () => {
});
});
describe('mailbox deduplication side effects on nesting', () => {
it('should not remove an intermediate parent whose name matches a role mailbox', () => {
// Scenario: A non-role folder named "Sent" is an intermediate parent.
// The deduplication logic might remove it because it matches the role "sent" mailbox name.
describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () => {
it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
// Reporter's exact scenario: two subfolders with the same name.
// The dedup uses substring matching and removes non-role folders whose name
// matches a role folder — even if they're deep in the tree with children.
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
// A user-created folder also named "Sent" that's a child of Inbox
// User-created subfolder also named "Sent" nested under Inbox
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
// Child of the custom "Sent" folder
// Child of the custom "Sent" folder — becomes orphaned if parent is deduped
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const rootIds = tree.map(n => n.id);
// sent-custom MUST be kept because it has children — removing it orphans sent-child
const sentCustom = flat.find(n => n.id === 'sent-custom');
expect(sentCustom).toBeDefined();
expect(sentCustom!.depth).toBe(1); // nested under Inbox
// The custom "Sent" (sent-custom) might be filtered by deduplication.
// If it IS removed, then "sent-child" loses its parent and becomes root — BAD.
const sentChild = flat.find(n => n.id === 'sent-child');
expect(sentChild).toBeDefined();
// Check if sent-child is orphaned at root (the bug)
const rootIds = tree.map(n => n.id);
const isOrphaned = rootIds.includes('sent-child');
if (isOrphaned) {
// This demonstrates the deduplication bug: removing an intermediate parent
// causes its children to become orphaned at root level
console.warn(
'BUG DETECTED: Deduplication removed intermediate parent "sent-custom", ' +
'orphaning "sent-child" to root level.'
);
}
// Document the current behavior (this test is diagnostic)
// Ideally: sent-child should be nested under sent-custom at depth 2
// If dedup removes sent-custom: sent-child ends up at root with depth 0
expect(sentChild!.depth).toBeGreaterThanOrEqual(0);
expect(sentChild!.depth).toBe(2); // nested under sent-custom
expect(rootIds).not.toContain('sent-child'); // must NOT be orphaned at root
});
it('should not remove a parent folder whose name is a substring of a role name', () => {
// Folder named "Draft" (substring of "Drafts" role) used as intermediate parent
it('should keep nested folders when name is substring of a role name', () => {
// "Draft" is a substring of "Drafts" — dedup removes it, orphaning children
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
@@ -318,16 +307,66 @@ describe('mailbox deduplication side effects on nesting', () => {
const draftChild = flat.find(n => n.id === 'draft-child');
expect(draftChild).toBeDefined();
expect(draftChild!.depth).toBe(2);
expect(rootIds).not.toContain('draft-child');
});
const isOrphaned = rootIds.includes('draft-child');
if (isOrphaned) {
console.warn(
'BUG DETECTED: Deduplication removed "draft-folder" (substring match with "Drafts"), ' +
'orphaning "draft-child" to root level.'
);
}
it('should handle the exact reported structure with duplicate names at different depths', () => {
// Stalwart allows creating subfolders with the same name at different levels.
// If any of those names match a role mailbox name, dedup could remove them.
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'trash-role', name: 'Trash', role: 'trash' }),
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
// User created a subfolder named "Trash" under BOOKINGS (e.g. for old bookings)
makeMailbox({ id: 'trash-custom', name: 'Trash', parentId: 'bookings' }),
// Depth 4: child of the custom Trash folder
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'trash-custom' }),
];
expect(draftChild!.depth).toBeGreaterThanOrEqual(0);
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const rootIds = tree.map(n => n.id);
// RESTAURANT must be at depth 4, not orphaned at root
const restaurant = flat.find(n => n.id === 'restaurant');
expect(restaurant).toBeDefined();
expect(restaurant!.depth).toBe(4);
expect(rootIds).not.toContain('restaurant');
// Custom "Trash" must be kept as it has children
const trashCustom = flat.find(n => n.id === 'trash-custom');
expect(trashCustom).toBeDefined();
expect(trashCustom!.depth).toBe(3);
});
it('should only dedup root-level non-role mailboxes that duplicate role mailboxes', () => {
// Dedup should only remove mailboxes that are BOTH:
// 1. At root level (no parentId) — same structural position as role mailbox
// 2. Name-matching a role mailbox
// Nested mailboxes with matching names should always be kept.
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level duplicate — OK to remove
makeMailbox({ id: 'proj', name: 'Projects', parentId: 'inbox' }),
makeMailbox({ id: 'sent-nested', name: 'Sent', parentId: 'proj' }), // nested — must keep
makeMailbox({ id: 'report', name: 'Report', parentId: 'sent-nested' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
// "Sent Mail" at root (no parentId) can be deduped — that's fine
// But "Sent" nested under Projects must be kept
const sentNested = flat.find(n => n.id === 'sent-nested');
expect(sentNested).toBeDefined();
expect(sentNested!.depth).toBe(2);
const report = flat.find(n => n.id === 'report');
expect(report).toBeDefined();
expect(report!.depth).toBe(3);
});
});
+22 -1
View File
@@ -8,9 +8,17 @@ const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
);
}
return createHash('sha256').update(secret).digest();
}
@@ -116,11 +124,24 @@ export async function clearAdminSessionCookie(): Promise<void> {
/**
* Get the client IP from the request headers.
*
* Proxies typically *append* to X-Forwarded-For, so the last entry
* before our trusted proxy is the most reliable client IP. When a
* single reverse proxy sits in front of the app the rightmost entry
* is the one added by that proxy. We take the rightmost entry to
* avoid trusting attacker-controlled values prepended to the header.
*
* If you run behind multiple trusted proxies, set TRUSTED_PROXY_DEPTH
* to the number of trusted proxies (default 1).
*/
export function getClientIP(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0].trim();
const parts = forwarded.split(',').map(s => s.trim()).filter(Boolean);
const depth = Math.max(1, parseInt(process.env.TRUSTED_PROXY_DEPTH || '1', 10));
// Take the entry at position (length - depth), clamped to 0
const index = Math.max(0, parts.length - depth);
return parts[index] || '0.0.0.0';
}
return request.headers.get('x-real-ip') || '0.0.0.0';
}
+2
View File
@@ -37,6 +37,7 @@ export interface FeatureGates {
debugModeEnabled: boolean;
folderIconsEnabled: boolean;
hoverActionsConfigEnabled: boolean;
filesEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -54,6 +55,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
debugModeEnabled: true,
folderIconsEnabled: true,
hoverActionsConfigEnabled: true,
filesEnabled: true,
};
export interface ThemePolicy {
+18
View File
@@ -0,0 +1,18 @@
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
export function getActiveAccountSlot(): number | null {
const authState = useAuthStore.getState();
const accountState = useAccountStore.getState();
const activeAccountId = authState.activeAccountId ?? accountState.activeAccountId;
const activeAccount = activeAccountId
? accountState.getAccountById(activeAccountId)
: accountState.getActiveAccount();
return typeof activeAccount?.cookieSlot === 'number' ? activeAccount.cookieSlot : null;
}
export function getActiveAccountSlotHeaders(): Record<string, string> {
const slot = getActiveAccountSlot();
return slot === null ? {} : { 'X-JMAP-Cookie-Slot': String(slot) };
}
+8
View File
@@ -5,9 +5,17 @@ const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
);
}
return createHash('sha256').update(secret).digest();
}
+80
View File
@@ -0,0 +1,80 @@
const VERIFY_TIMEOUT_MS = 10000;
export class JmapAuthVerificationError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'JmapAuthVerificationError';
this.status = status;
}
}
function isSupportedProtocol(protocol: string): boolean {
return protocol === 'http:' || protocol === 'https:';
}
export function normalizeJmapServerUrl(serverUrl: string): string {
let url: URL;
try {
url = new URL(serverUrl);
} catch {
throw new JmapAuthVerificationError('Invalid server URL', 400);
}
if (!isSupportedProtocol(url.protocol)) {
throw new JmapAuthVerificationError('Unsupported server URL protocol', 400);
}
url.hash = '';
url.search = '';
return url.toString().replace(/\/+$/, '');
}
export function validateProxyAuthHeader(authHeader: string): void {
if (!/^(?:Basic|Bearer)\s+\S+$/i.test(authHeader)) {
throw new JmapAuthVerificationError('Invalid Authorization header', 400);
}
}
export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise<string> {
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
validateProxyAuthHeader(authHeader);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
try {
const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, {
method: 'GET',
headers: { Authorization: authHeader },
signal: controller.signal,
});
if (!response.ok) {
throw new JmapAuthVerificationError(
response.status === 401 || response.status === 403
? 'Authentication failed'
: 'Failed to verify JMAP session',
response.status === 401 || response.status === 403 ? 401 : 502,
);
}
const session = await response.json().catch(() => null) as { apiUrl?: unknown; accounts?: unknown } | null;
if (!session || typeof session.apiUrl !== 'string' || typeof session.accounts !== 'object' || session.accounts === null) {
throw new JmapAuthVerificationError('Invalid JMAP session response', 502);
}
return normalizedServerUrl;
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
throw error;
}
if (error instanceof Error && error.name === 'AbortError') {
throw new JmapAuthVerificationError('JMAP session verification timed out', 504);
}
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
} finally {
clearTimeout(timeout);
}
}
+57 -24
View File
@@ -1,25 +1,53 @@
import { useSettingsStore } from '@/stores/settings-store';
import type { DebugCategory } from '@/stores/settings-store';
/**
* Debug logger that respects the debugMode setting.
* Check if debug logging is enabled, optionally for a specific category.
* When a category is provided, both debugMode AND that category must be enabled.
*/
function isEnabled(category?: DebugCategory): boolean {
const state = useSettingsStore.getState();
if (!state.debugMode) return false;
if (!category) return true;
return state.debugCategories?.[category] !== false;
}
/**
* Debug logger that respects the debugMode setting and category filters.
* Use this instead of console.log for conditional debug output.
*
* Each method accepts an optional category as the first argument.
* When a category is provided, the message only logs if that category is enabled
* in Settings > Advanced > Debug Categories.
*
* Usage:
* debug.log('calendar', 'Event created', event); // Only logs when 'calendar' category is on
* debug.log('Uncategorized message'); // Logs whenever debugMode is on
*/
export const debug = {
/**
* Log a debug message (only when debugMode is enabled)
* Log a debug message (only when debugMode is enabled and category is active)
*/
log: (...args: unknown[]) => {
if (useSettingsStore.getState().debugMode) {
console.log('[DEBUG]', ...args);
log: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
if (isEnabled(categoryOrMsg)) {
console.log(`[DEBUG:${categoryOrMsg}]`, ...args);
}
} else if (isEnabled()) {
console.log('[DEBUG]', categoryOrMsg, ...args);
}
},
/**
* Log a warning message (only when debugMode is enabled)
* Log a warning message (only when debugMode is enabled and category is active)
*/
warn: (...args: unknown[]) => {
if (useSettingsStore.getState().debugMode) {
console.warn('[DEBUG]', ...args);
warn: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
if (isEnabled(categoryOrMsg)) {
console.warn(`[DEBUG:${categoryOrMsg}]`, ...args);
}
} else if (isEnabled()) {
console.warn('[DEBUG]', categoryOrMsg, ...args);
}
},
@@ -31,11 +59,11 @@ export const debug = {
},
/**
* Start a collapsed console group (only when debugMode is enabled)
* Start a collapsed console group (only when debugMode is enabled and category is active)
*/
group: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.group(`[DEBUG] ${label}`);
group: (label: string, category?: DebugCategory) => {
if (isEnabled(category)) {
console.group(`[DEBUG${category ? ':' + category : ''}] ${label}`);
}
},
@@ -43,35 +71,40 @@ export const debug = {
* End a console group (only when debugMode is enabled)
*/
groupEnd: () => {
if (useSettingsStore.getState().debugMode) {
if (isEnabled()) {
console.groupEnd();
}
},
/**
* Start a performance timer (only when debugMode is enabled)
* Start a performance timer (only when debugMode is enabled and category is active)
*/
time: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.time(`[DEBUG] ${label}`);
time: (label: string, category?: DebugCategory) => {
if (isEnabled(category)) {
console.time(`[DEBUG${category ? ':' + category : ''}] ${label}`);
}
},
/**
* End a performance timer (only when debugMode is enabled)
*/
timeEnd: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.timeEnd(`[DEBUG] ${label}`);
timeEnd: (label: string, category?: DebugCategory) => {
if (isEnabled(category)) {
console.timeEnd(`[DEBUG${category ? ':' + category : ''}] ${label}`);
}
},
/**
* Log a table (only when debugMode is enabled)
* Log a table (only when debugMode is enabled and category is active)
*/
table: (data: unknown) => {
if (useSettingsStore.getState().debugMode) {
table: (data: unknown, category?: DebugCategory) => {
if (isEnabled(category)) {
console.table(data);
}
}
};
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']);
function isCategoryKey(value: string): value is DebugCategory {
return CATEGORY_KEYS.has(value);
}
+3 -2
View File
@@ -11,9 +11,10 @@ export const EMAIL_SANITIZE_CONFIG = {
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: true,
// Allow blob: URIs so authenticated inline images (CID) are not stripped
// Allow blob: URIs so authenticated inline images (CID) are not stripped.
// data: is restricted to image/* MIME types to prevent SVG script injection.
// eslint-disable-next-line no-useless-escape
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
FORBID_TAGS: [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'meta', 'link', 'base',
+64 -64
View File
@@ -670,12 +670,12 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
debug.log(`[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`);
debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`);
// Warn if response might be truncated
const maxObjects = this.getMaxObjectsInGet();
if (rawMailboxes.length >= maxObjects) {
debug.warn(
debug.warn('jmap',
`[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
`Some mailboxes may be missing — nested folders could appear orphaned at root level.`
);
@@ -685,7 +685,7 @@ export class JMAPClient implements IJMAPClient {
const returnedIds = new Set(rawMailboxes.map(mb => mb.id));
const missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId));
if (missingParents.length > 0) {
debug.warn(
debug.warn('jmap',
`[JMAP Mailbox] ${missingParents.length} mailbox(es) reference parentId not in response (will be orphaned):`,
missingParents.map(mb => ({ id: mb.id, name: mb.name, parentId: mb.parentId }))
);
@@ -755,12 +755,12 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
debug.log(`[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`);
debug.log('jmap', `[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`);
// Warn if response might be truncated
const maxObjects = this.getMaxObjectsInGet();
if (rawMailboxes.length >= maxObjects) {
debug.warn(
debug.warn('jmap',
`[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
`Some mailboxes may be missing.`
);
@@ -1995,7 +1995,7 @@ export class JMAPClient implements IJMAPClient {
lines.push('END:VCALENDAR');
const icsContent = lines.join('\r\n') + '\r\n';
debug.log('[iMIP] Generated ICS:\n' + icsContent);
debug.log('calendar', '[iMIP] Generated ICS:\n' + icsContent);
const statusLabels: Record<string, string> = {
ACCEPTED: 'Accepted',
@@ -2005,7 +2005,7 @@ export class JMAPClient implements IJMAPClient {
const statusLabel = statusLabels[opts.status] || opts.status;
const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
debug.log('[iMIP] identityId:', finalIdentityId);
debug.log('calendar', '[iMIP] identityId:', finalIdentityId);
const emailId = `imip-reply-${Date.now()}`;
const emailCreate: Record<string, unknown> = {
@@ -2038,12 +2038,12 @@ export class JMAPClient implements IJMAPClient {
}, "1"],
];
debug.log('[iMIP] Sending JMAP request with', methodCalls.length, 'method calls');
debug.log('[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2));
debug.log('calendar', '[iMIP] Sending JMAP request with', methodCalls.length, 'method calls');
debug.log('calendar', '[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2));
const response = await this.request(methodCalls);
debug.log('[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
debug.log('calendar', '[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) {
@@ -2058,7 +2058,7 @@ export class JMAPClient implements IJMAPClient {
}
}
}
debug.log('[iMIP] sendImipReply completed successfully');
debug.log('calendar', '[iMIP] sendImipReply completed successfully');
}
/**
@@ -2222,7 +2222,7 @@ export class JMAPClient implements IJMAPClient {
async sendImipCancellation(event: CalendarEvent): Promise<void> {
if (!event.participants) return;
if (event.status && event.status !== 'cancelled') {
debug.warn('sendImipCancellation called on non-cancelled event, status:', event.status);
debug.warn('calendar', 'sendImipCancellation called on non-cancelled event, status:', event.status);
}
const mailboxes = await this.getMailboxes();
@@ -3361,8 +3361,8 @@ export class JMAPClient implements IJMAPClient {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
debug.group('CalendarEvent/create');
debug.log('CalendarEvent/create outgoing payload', {
debug.group('CalendarEvent/create', 'calendar');
debug.log('calendar', 'CalendarEvent/create outgoing payload', {
accountId,
sendSchedulingMessages,
eventKeys: Object.keys(cleanEvent),
@@ -3382,40 +3382,40 @@ export class JMAPClient implements IJMAPClient {
["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing());
debug.log('CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null);
debug.log('calendar', 'CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null);
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
if (result.notCreated?.["new-event"]) {
const error = result.notCreated["new-event"];
debug.warn('CalendarEvent/create notCreated', error);
debug.warn('CalendarEvent/create invalid properties', error.properties);
debug.warn('CalendarEvent/create sent keys', Object.keys(cleanEvent));
debug.warn('calendar', 'CalendarEvent/create notCreated', error);
debug.warn('calendar', 'CalendarEvent/create invalid properties', error.properties);
debug.warn('calendar', 'CalendarEvent/create sent keys', Object.keys(cleanEvent));
debug.groupEnd();
throw new Error(error.description || "Failed to create calendar event");
}
const createdId = result.created?.["new-event"]?.id;
debug.log('CalendarEvent/create server acknowledged created id', {
debug.log('calendar', 'CalendarEvent/create server acknowledged created id', {
createdId,
created: result.created?.['new-event'] || null,
});
if (createdId) {
const created = await this.getCalendarEvent(createdId, targetAccountId);
debug.log('CalendarEvent/create fetched created event', getCalendarEventDebugSnapshot(created));
debug.log('calendar', 'CalendarEvent/create fetched created event', getCalendarEventDebugSnapshot(created));
if (created?.uid) {
try {
const verificationMatches = await this.queryCalendarEvents({ uid: created.uid }, undefined, undefined, targetAccountId);
debug.log('CalendarEvent/create verification query by uid', {
debug.log('calendar', 'CalendarEvent/create verification query by uid', {
uid: created.uid,
matchCount: verificationMatches.length,
matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)),
});
} catch (verificationError) {
debug.warn('CalendarEvent/create verification query failed', verificationError);
debug.warn('calendar', 'CalendarEvent/create verification query failed', verificationError);
}
}
@@ -3424,7 +3424,7 @@ export class JMAPClient implements IJMAPClient {
return created;
}
debug.warn('CalendarEvent/create server returned created id but CalendarEvent/get returned null', {
debug.warn('calendar', 'CalendarEvent/create server returned created id but CalendarEvent/get returned null', {
createdId,
targetAccountId,
});
@@ -3455,7 +3455,7 @@ export class JMAPClient implements IJMAPClient {
createMap[`new-${i}`] = clean;
}
debug.log('CalendarEvent/batchCreate', { count: events.length, accountId });
debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
const response = await this.request([
["CalendarEvent/set", { accountId, create: createMap }, "0"]
@@ -3471,7 +3471,7 @@ export class JMAPClient implements IJMAPClient {
if (result.created?.[key]?.id) {
createdIds.push(result.created[key].id);
} else if (result.notCreated?.[key]) {
debug.warn(`CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
failed.push(key);
}
}
@@ -3496,7 +3496,7 @@ export class JMAPClient implements IJMAPClient {
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
}
debug.log('CalendarEvent/batchCreate result', {
debug.log('calendar', 'CalendarEvent/batchCreate result', {
requested: events.length,
created: createdEvents.length,
failed: failed.length,
@@ -3513,8 +3513,8 @@ export class JMAPClient implements IJMAPClient {
): Promise<void> {
const accountId = targetAccountId || this.getCalendarsAccountId();
// Strip client-only shared fields before sending to JMAP
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
// Strip client-only and server-immutable fields before sending to JMAP
const { id: _id, uid: _uid, '@type': _typ, created: _cr, updated: _up, sequence: _sq, isOrigin: _io, isDraft: _idr, originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
cleanRecurrenceRules(cleanUpdates as unknown as Record<string, unknown>);
const setArgs: Record<string, unknown> = {
@@ -3527,7 +3527,7 @@ export class JMAPClient implements IJMAPClient {
setArgs.sendSchedulingMessages = sendSchedulingMessages;
}
debug.log('CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages });
debug.log('calendar', 'CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages });
const response = await this.request([
["CalendarEvent/set", setArgs, "0"]
@@ -3549,7 +3549,7 @@ export class JMAPClient implements IJMAPClient {
debug.error('CalendarEvent/set notUpdated', { eventId, error });
throw new Error(error.description || "Failed to update calendar event");
}
debug.log('CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null });
debug.log('calendar', 'CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null });
return;
}
@@ -3600,7 +3600,7 @@ export class JMAPClient implements IJMAPClient {
setArgs.sendSchedulingMessages = sendSchedulingMessages;
}
debug.log('CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages });
debug.log('calendar', 'CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages });
const response = await this.request([
["CalendarEvent/set", setArgs, "0"]
@@ -3622,7 +3622,7 @@ export class JMAPClient implements IJMAPClient {
debug.error('CalendarEvent/set notDestroyed', { eventId, error });
throw new Error(error.description || "Failed to delete calendar event");
}
debug.log('CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed });
debug.log('calendar', 'CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed });
return;
}
@@ -3654,8 +3654,8 @@ export class JMAPClient implements IJMAPClient {
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
debug.group('CalendarTask/fetch');
debug.log('CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
debug.group('CalendarTask/fetch', 'tasks');
debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
try {
// Strategy 1: query with types filter (JMAP spec compliant)
@@ -3664,7 +3664,7 @@ export class JMAPClient implements IJMAPClient {
filter.inCalendars = calendarIds;
}
debug.log('CalendarTask/fetch query filter', filter);
debug.log('tasks', 'CalendarTask/fetch query filter', filter);
const response = await this.request([
["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"],
@@ -3678,13 +3678,13 @@ export class JMAPClient implements IJMAPClient {
const queryResponse = response.methodResponses?.[0];
const getResponse = response.methodResponses?.[1];
debug.log('CalendarTask/fetch query method', queryResponse?.[0]);
debug.log('CalendarTask/fetch query result', queryResponse?.[1]);
debug.log('tasks', 'CalendarTask/fetch query method', queryResponse?.[0]);
debug.log('tasks', 'CalendarTask/fetch query result', queryResponse?.[1]);
if (queryResponse?.[0] === "error") {
debug.warn('CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]);
debug.warn('tasks', 'CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]);
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.groupEnd();
return tasks;
}
@@ -3692,22 +3692,22 @@ export class JMAPClient implements IJMAPClient {
if (getResponse?.[0] === "CalendarEvent/get") {
const list = (getResponse[1].list || []) as CalendarTask[];
const queryIds = queryResponse?.[1]?.ids || [];
debug.log('CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds);
debug.log('CalendarTask/fetch get returned', list.length, 'objects');
debug.log('calendar', 'CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds);
debug.log('calendar', 'CalendarTask/fetch get returned', list.length, 'objects');
// If the types filter returned 0 results, the server may have silently
// ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to
// a full scan so we can detect tasks by their properties.
if (queryIds.length === 0) {
debug.warn('CalendarTask/fetch types filter returned 0 results, falling back to full scan');
debug.warn('tasks', 'CalendarTask/fetch types filter returned 0 results, falling back to full scan');
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.groupEnd();
return tasks;
}
list.forEach((task, i) => {
debug.log(`CalendarTask/fetch [${i}]`, {
debug.log('tasks', `CalendarTask/fetch [${i}]`, {
id: task.id,
uid: task.uid,
'@type': task['@type'],
@@ -3724,12 +3724,12 @@ export class JMAPClient implements IJMAPClient {
...task,
'@type': 'Task' as const,
}));
debug.log('CalendarTask/fetch complete,', results.length, 'tasks');
debug.log('tasks', 'CalendarTask/fetch complete,', results.length, 'tasks');
debug.groupEnd();
return results;
}
debug.warn('CalendarTask/fetch unexpected response shape', response.methodResponses);
debug.warn('tasks', 'CalendarTask/fetch unexpected response shape', response.methodResponses);
debug.groupEnd();
return [];
} catch (error) {
@@ -3746,7 +3746,7 @@ export class JMAPClient implements IJMAPClient {
*/
private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
debug.log('CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects');
debug.log('calendar', 'CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects');
// CalendarEvent/get with ids:null returns ALL calendar objects regardless of @type
const response = await this.request([
@@ -3758,12 +3758,12 @@ export class JMAPClient implements IJMAPClient {
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") {
debug.warn('CalendarTask/fallback unexpected response', response.methodResponses?.[0]);
debug.warn('calendar', 'CalendarTask/fallback unexpected response', response.methodResponses?.[0]);
return [];
}
const allObjects = (response.methodResponses[0][1].list || []) as Record<string, unknown>[];
debug.log('CalendarTask/fallback total calendar objects returned:', allObjects.length);
debug.log('tasks', 'CalendarTask/fallback total calendar objects returned:', allObjects.length);
const tasks: CalendarTask[] = [];
const calendarIdSet = calendarIds ? new Set(calendarIds) : null;
@@ -3779,7 +3779,7 @@ export class JMAPClient implements IJMAPClient {
|| ('percentComplete' in obj);
const isCalDavTask = type !== 'Event' && hasTaskFields;
debug.log('CalendarTask/fallback scan', {
debug.log('tasks', 'CalendarTask/fallback scan', {
id: obj.id,
'@type': type,
title: obj.title,
@@ -3796,7 +3796,7 @@ export class JMAPClient implements IJMAPClient {
if (calendarIdSet) {
const objCalendarIds = obj.calendarIds as Record<string, boolean> | undefined;
if (objCalendarIds && !Object.keys(objCalendarIds).some(id => calendarIdSet.has(id))) {
debug.log('CalendarTask/fallback skipping task (not in requested calendars)', obj.id);
debug.log('tasks', 'CalendarTask/fallback skipping task (not in requested calendars)', obj.id);
return;
}
}
@@ -3804,9 +3804,9 @@ export class JMAPClient implements IJMAPClient {
tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask);
});
debug.log('CalendarTask/fallback detected', tasks.length, 'tasks');
debug.log('tasks', 'CalendarTask/fallback detected', tasks.length, 'tasks');
tasks.forEach((t, i) => {
debug.log(`CalendarTask/fallback [${i}]`, {
debug.log('tasks', `CalendarTask/fallback [${i}]`, {
id: t.id,
uid: t.uid,
title: t.title,
@@ -3825,9 +3825,9 @@ export class JMAPClient implements IJMAPClient {
const { '@type': _type, ...taskData } = task;
const cleanTask = { ...taskData, '@type': 'Task' };
debug.group('CalendarTask/create');
debug.log('CalendarTask/create accountId', accountId);
debug.log('CalendarTask/create outgoing payload', cleanTask);
debug.group('CalendarTask/create', 'tasks');
debug.log('tasks', 'CalendarTask/create accountId', accountId);
debug.log('tasks', 'CalendarTask/create outgoing payload', cleanTask);
const response = await this.request([
["CalendarEvent/set", {
@@ -3838,27 +3838,27 @@ export class JMAPClient implements IJMAPClient {
], this.calendarUsing());
const result = response.methodResponses?.[0]?.[1];
debug.log('CalendarTask/create raw set response', result);
debug.log('tasks', 'CalendarTask/create raw set response', result);
if (result?.notCreated?.["new-task"]) {
const error = result.notCreated["new-task"];
debug.warn('CalendarTask/create REJECTED by server', error);
debug.warn('tasks', 'CalendarTask/create REJECTED by server', error);
debug.groupEnd();
throw new Error(error.description || "Failed to create task");
}
const createdId = result?.created?.["new-task"]?.id;
const serverCreated = result?.created?.["new-task"];
debug.log('CalendarTask/create server acknowledged', { createdId, serverCreated });
debug.log('tasks', 'CalendarTask/create server acknowledged', { createdId, serverCreated });
if (!createdId) {
debug.warn('CalendarTask/create no id in server response');
debug.warn('tasks', 'CalendarTask/create no id in server response');
debug.groupEnd();
throw new Error("Failed to create task — no id returned");
}
// Fetch back with task-specific properties
debug.log('CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] });
debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] });
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
@@ -3870,10 +3870,10 @@ export class JMAPClient implements IJMAPClient {
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const list = getResponse.methodResponses[0][1].list || [];
const notFound = getResponse.methodResponses[0][1].notFound || [];
debug.log('CalendarTask/create get response', { found: list.length, notFound });
debug.log('calendar', 'CalendarTask/create get response', { found: list.length, notFound });
if (list[0]) {
const created = { ...list[0], '@type': 'Task' as const } as CalendarTask;
debug.log('CalendarTask/create final task object', {
debug.log('tasks', 'CalendarTask/create final task object', {
id: created.id,
uid: created.uid,
'@type': created['@type'],
@@ -3889,7 +3889,7 @@ export class JMAPClient implements IJMAPClient {
}
}
debug.warn('CalendarTask/create re-fetch returned nothing for id', createdId);
debug.warn('tasks', 'CalendarTask/create re-fetch returned nothing for id', createdId);
debug.groupEnd();
throw new Error("Failed to fetch created task");
}
+2 -2
View File
@@ -31,7 +31,7 @@ function playFile(file: string) {
const audio = new Audio(file);
audio.volume = 0.3;
audio.play().catch((e) => {
debug.log('Could not play audio file, falling back to beep:', e);
debug.log('push', 'Could not play audio file, falling back to beep:', e);
playBeep();
});
}
@@ -47,6 +47,6 @@ export function playNotificationSound(sound?: NotificationSoundChoice) {
playBeep();
}
} catch (e) {
debug.log('Could not play notification sound:', e);
debug.log('push', 'Could not play notification sound:', e);
}
}
+1
View File
@@ -427,6 +427,7 @@ export const ALLOWED_PLUGIN_FILES = new Set([
export const DISALLOWED_CSS_PATTERNS = [
/@import\b/i,
/url\s*\(\s*['"]?https?:/i,
/url\s*\(\s*['"]?data:/i,
/expression\s*\(/i,
/javascript\s*:/i,
/-moz-binding/i,
+1 -1
View File
@@ -147,7 +147,7 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
for (const rule of enabledRules) {
if (rule.conditions.length === 0 || rule.actions.length === 0) {
debug.warn(`Skipping rule "${rule.name}": empty conditions or actions`);
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
continue;
}
+1 -1
View File
@@ -109,7 +109,7 @@ export function parseScript(content: string): ParseResult {
try {
metadata = JSON.parse(jsonStr);
} catch (e) {
debug.warn('Failed to parse Sieve metadata JSON:', e);
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
return OPAQUE;
}
+9 -3
View File
@@ -1,8 +1,8 @@
/**
* Verify CMS SignedData (opaque signed) and extract the inner content.
*
* v1 performs cryptographic signature validation and cert validity checks
* but does NOT implement full trust-chain or revocation validation.
* Performs cryptographic signature validation, cert validity checks,
* and trust-chain verification.
*/
import * as pkijs from 'pkijs';
@@ -61,7 +61,7 @@ export async function smimeVerify(
const verifyResult = await signedData.verify(
{
signer: 0,
checkChain: false, // v1: no trust-chain validation
checkChain: true,
},
cryptoEngine,
);
@@ -108,6 +108,11 @@ export async function smimeVerify(
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
}
// Detect self-signed certificates (issuer === subject)
const issuerDer = new Uint8Array(signerCert.issuer.toSchema().toBER(false));
const subjectDer = new Uint8Array(signerCert.subject.toSchema().toBER(false));
const selfSigned = arraysEqual(issuerDer, subjectDer);
return {
mimeBytes: innerContent,
status: {
@@ -117,6 +122,7 @@ export async function smimeVerify(
signatureError,
signerCert: signerPublicCert,
signerEmailMatch,
selfSigned,
},
};
}
+2
View File
@@ -55,6 +55,8 @@ export interface SmimeStatus {
signatureError?: string;
signerCert?: SmimePublicCert;
signerEmailMatch?: boolean;
/** True when the signer certificate is self-signed (not chained to a trusted CA). */
selfSigned?: boolean;
decryptionSuccess?: boolean;
decryptionError?: string;
unsupportedReason?: string;
+75
View File
@@ -0,0 +1,75 @@
import { cookies } from 'next/headers';
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
const STALWART_AUTH_CONTEXT_COOKIE = 'jmap_stalwart_ctx';
export interface StalwartAuthContext {
serverUrl: string;
username: string;
authHeader: string;
}
type CookieStore = Awaited<ReturnType<typeof cookies>>;
export function stalwartAuthContextCookieName(slot: number): string {
return slot === 0 ? STALWART_AUTH_CONTEXT_COOKIE : `${STALWART_AUTH_CONTEXT_COOKIE}_${slot}`;
}
function isValidContext(payload: unknown): payload is StalwartAuthContext {
if (!payload || typeof payload !== 'object') {
return false;
}
const candidate = payload as Record<string, unknown>;
return typeof candidate.serverUrl === 'string'
&& typeof candidate.username === 'string'
&& typeof candidate.authHeader === 'string';
}
function getSessionCookieOptions() {
const { maxAge: _maxAge, ...cookieOptions } = getCookieOptions();
return cookieOptions;
}
export function readStalwartAuthContextFromStore(
cookieStore: CookieStore,
slot: number,
): StalwartAuthContext | null {
const token = cookieStore.get(stalwartAuthContextCookieName(slot))?.value;
if (!token) return null;
const payload = decryptPayload(token);
return isValidContext(payload) ? payload : null;
}
export async function readStalwartAuthContext(slot: number): Promise<StalwartAuthContext | null> {
const cookieStore = await cookies();
return readStalwartAuthContextFromStore(cookieStore, slot);
}
export function setStalwartAuthContextInStore(
cookieStore: CookieStore,
slot: number,
context: StalwartAuthContext,
): void {
cookieStore.set(
stalwartAuthContextCookieName(slot),
encryptPayload(context as unknown as Record<string, unknown>),
getSessionCookieOptions(),
);
}
export async function setStalwartAuthContext(slot: number, context: StalwartAuthContext): Promise<void> {
const cookieStore = await cookies();
setStalwartAuthContextInStore(cookieStore, slot, context);
}
export function clearStalwartAuthContextInStore(cookieStore: CookieStore, slot: number): void {
cookieStore.delete(stalwartAuthContextCookieName(slot));
}
export async function clearStalwartAuthContext(slot: number): Promise<void> {
const cookieStore = await cookies();
clearStalwartAuthContextInStore(cookieStore, slot);
}
+30 -30
View File
@@ -1,7 +1,7 @@
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
export interface StalwartCredentials {
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
@@ -11,6 +11,7 @@ export interface StalwartCredentials {
authHeader: string;
username: string;
hasSessionCookie: boolean;
slot: number;
}
/**
@@ -30,39 +31,38 @@ function getStalwartApiUrl(jmapServerUrl: string): string {
/**
* Extract credentials from the incoming request.
*
* Tries the explicit headers first (`Authorization`, `X-JMAP-Server-URL`,
* `X-JMAP-Username`), then falls back to the encrypted session cookie.
* Credentials are read from a verified, httpOnly auth-context cookie that is
* populated after a successful JMAP login or token refresh.
*/
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? null : slot;
}
function getCandidateSlots(request: NextRequest): number[] {
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
?? parseSlot(request.nextUrl.searchParams.get('slot'));
return requestedSlot === null ? [0, 1, 2, 3, 4] : [requestedSlot];
}
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
const authHeader = request.headers.get('Authorization');
const serverUrl = request.headers.get('X-JMAP-Server-URL');
const username = request.headers.get('X-JMAP-Username');
const cookieStore = await cookies();
for (const slot of getCandidateSlots(request)) {
const context = readStalwartAuthContextFromStore(cookieStore, slot);
if (!context) continue;
if (authHeader && serverUrl && username) {
const cookieStore = await cookies();
const hasSessionCookie = !!cookieStore.get(SESSION_COOKIE)?.value;
return {
apiUrl: getStalwartApiUrl(serverUrl),
serverUrl,
authHeader,
username,
hasSessionCookie,
apiUrl: getStalwartApiUrl(context.serverUrl),
serverUrl: context.serverUrl,
authHeader: context.authHeader,
username: context.username,
hasSessionCookie: !!cookieStore.get(sessionCookieName(slot))?.value,
slot,
};
}
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
const credentials = decryptSession(token);
if (!credentials) return null;
const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
return {
apiUrl: getStalwartApiUrl(credentials.serverUrl),
serverUrl: credentials.serverUrl,
authHeader: basic,
username: credentials.username,
hasSessionCookie: true,
};
return null;
}
+27 -27
View File
@@ -236,11 +236,11 @@ export function parseTnef(data: Uint8Array): TnefResult {
attachments: [],
};
debug.group('TNEF Parser');
debug.log('Input data size:', data.byteLength, 'bytes');
debug.group('TNEF Parser', 'email');
debug.log('email', 'Input data size:', data.byteLength, 'bytes');
if (data.byteLength < 6) {
debug.warn('TNEF data too small (< 6 bytes), skipping');
debug.warn('email', 'TNEF data too small (< 6 bytes), skipping');
debug.groupEnd();
return result;
}
@@ -249,11 +249,11 @@ export function parseTnef(data: Uint8Array): TnefResult {
const signature = r.readUint32LE();
if (signature !== TNEF_SIGNATURE) {
debug.warn('Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
debug.warn('email', 'Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
debug.groupEnd();
return result;
}
debug.log('TNEF signature valid');
debug.log('email', 'TNEF signature valid');
r.skip(2); // legacy key
@@ -268,7 +268,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
attrCount++;
if (attrLen > r.remaining - 2) {
debug.warn('Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
debug.warn('email', 'Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
break;
}
@@ -276,17 +276,17 @@ export function parseTnef(data: Uint8Array): TnefResult {
r.skip(2); // checksum
const levelName = level === LVL_MESSAGE ? 'MESSAGE' : level === LVL_ATTACHMENT ? 'ATTACHMENT' : 'UNKNOWN(' + level + ')';
debug.log('Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
debug.log('email', 'Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
if (level === LVL_MESSAGE) {
if (attrID === attBody) {
result.body = new TextDecoder('utf-8').decode(attrData);
debug.log(' → Extracted plain text body (' + result.body.length + ' chars)');
debug.log('email', ' → Extracted plain text body (' + result.body.length + ' chars)');
} else if (attrID === attMAPIProps) {
const props = parseMAPIProps(attrData);
debug.log(' → Parsed', props.size, 'MAPI properties from message');
debug.log('email', ' → Parsed', props.size, 'MAPI properties from message');
props.forEach((val, propID) => {
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
});
// HTML body
@@ -298,9 +298,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
} else {
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
}
debug.log(' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
debug.log('email', ' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
} else {
debug.log(' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
debug.log('email', ' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
}
// Plain text body from MAPI props (fallback)
@@ -308,9 +308,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
const bodyProp = props.get(PR_BODY);
if (bodyProp?.value instanceof Uint8Array) {
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
debug.log(' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
debug.log('email', ' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
} else {
debug.log(' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
debug.log('email', ' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
}
}
}
@@ -318,7 +318,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
if (attrID === attAttachRenddata) {
// Start of a new attachment — flush previous
if (curAttach?.data) {
debug.log(' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
debug.log('email', ' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
result.attachments.push({
name: curAttach.name,
mimeType: curAttach.mimeType,
@@ -326,40 +326,40 @@ export function parseTnef(data: Uint8Array): TnefResult {
});
}
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
debug.log(' → New attachment started');
debug.log('email', ' → New attachment started');
} else if (attrID === attAttachTitle && curAttach) {
let len = attrData.byteLength;
if (len > 0 && attrData[len - 1] === 0) len--;
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
debug.log(' → Attachment short name:', curAttach.name);
debug.log('email', ' → Attachment short name:', curAttach.name);
} else if (attrID === attAttachData && curAttach) {
curAttach.data = attrData;
debug.log(' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
debug.log('email', ' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
} else if (attrID === attAttachment && curAttach) {
const props = parseMAPIProps(attrData);
debug.log(' → Parsed', props.size, 'MAPI properties from attachment');
debug.log('email', ' → Parsed', props.size, 'MAPI properties from attachment');
props.forEach((val, propID) => {
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
});
const longName = props.get(PR_ATTACH_LONG_FILENAME);
if (longName?.value instanceof Uint8Array) {
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
debug.log(' → Attachment long filename:', curAttach.name);
debug.log('email', ' → Attachment long filename:', curAttach.name);
}
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
if (mimeTag?.value instanceof Uint8Array) {
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
debug.log(' → Attachment MIME type:', curAttach.mimeType);
debug.log('email', ' → Attachment MIME type:', curAttach.mimeType);
}
const attachData = props.get(PR_ATTACH_DATA_BIN);
if (attachData?.value instanceof Uint8Array) {
curAttach.data = attachData.value;
debug.log(' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
debug.log('email', ' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
} else {
debug.log(' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
debug.log('email', ' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
}
}
}
@@ -367,7 +367,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
// Flush last attachment
if (curAttach?.data) {
debug.log('Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
debug.log('email', 'Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
result.attachments.push({
name: curAttach.name,
mimeType: curAttach.mimeType,
@@ -375,9 +375,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
});
}
debug.log('TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
debug.log('email', 'TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
if (result.attachments.length > 0) {
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })));
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })), 'email');
}
debug.groupEnd();
+16 -10
View File
@@ -125,7 +125,16 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
return;
}
// Check if this is a duplicate of a role-based mailbox in the SAME account
// Never deduplicate nested mailboxes — only root-level folders can be
// duplicates of role-based mailboxes. Removing a nested folder that happens
// to share a name with a role folder (e.g. a subfolder named "Sent") would
// orphan its children to root level. (GitHub #118)
if (mb.parentId) {
result.push(mb);
return;
}
// Check if this root-level mailbox is a duplicate of a role-based mailbox in the SAME account
const accountKey = mb.accountId || '';
const accountRoles = rolesByAccount.get(accountKey) || [];
const lowerName = mb.name.toLowerCase();
@@ -143,8 +152,7 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
removed.push({ id: mb.id, name: mb.name, matchedRole: matchedRole!.name, parentId: mb.parentId });
// Warn if this removed mailbox is a parent of other mailboxes (orphan risk)
if (referencedParentIds.has(mb.id)) {
debug.warn(
`[Mailbox Tree] Deduplication removed mailbox "${mb.name}" (id: ${mb.id}) which is a parent of other mailboxes. ` +
debug.warn('jmap', `[Mailbox Tree] Deduplication removed mailbox "${mb.name}" (id: ${mb.id}) which is a parent of other mailboxes. ` +
`Matched role mailbox: "${matchedRole!.name}" (role: ${matchedRole!.role}). ` +
`Children referencing parentId "${mb.id}" will be orphaned to root level.`
);
@@ -153,7 +161,7 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
});
if (removed.length > 0) {
debug.log(`[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed);
debug.log('jmap', `[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed);
}
return result;
@@ -161,13 +169,13 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
// Build a hierarchical tree structure from flat mailbox array
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
debug.log(`[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`);
debug.log('jmap', `[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`);
// Deduplicate mailboxes first
const deduplicated = deduplicateMailboxes(mailboxes);
if (deduplicated.length !== mailboxes.length) {
debug.log(`[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`);
debug.log('jmap', `[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`);
}
// Separate own and shared mailboxes
@@ -214,8 +222,7 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
});
if (orphanedMailboxes.length > 0) {
debug.warn(
`[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`,
debug.warn('jmap', `[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`,
orphanedMailboxes
);
}
@@ -232,8 +239,7 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
}
return max;
};
debug.log(
`[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` +
debug.log('jmap', `[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` +
`max depth: ${maxDepth(rootMailboxes)}, ` +
`total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}`
);
+7
View File
@@ -3,6 +3,8 @@
* The server-side proxy handles auth and forwards requests to Stalwart's /dav/file/ endpoint.
*/
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
export interface WebDAVResource {
href: string;
name: string;
@@ -26,6 +28,7 @@ export class WebDAVClient {
const headers: Record<string, string> = {
'X-WebDAV-Method': method,
'X-WebDAV-Path': path,
...getActiveAccountSlotHeaders(),
...options?.headers,
};
@@ -118,6 +121,10 @@ export class WebDAVClient {
xhr.open('POST', this.proxyUrl);
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
xhr.setRequestHeader('X-WebDAV-Path', path);
const slotHeaders = getActiveAccountSlotHeaders();
if (slotHeaders['X-JMAP-Cookie-Slot']) {
xhr.setRequestHeader('X-JMAP-Cookie-Slot', slotHeaders['X-JMAP-Cookie-Slot']);
}
xhr.setRequestHeader('Content-Type',
contentType || (file instanceof File ? file.type : 'application/octet-stream'));
+29 -1
View File
@@ -18,6 +18,7 @@
"cors_blocked": "Der Server ist erreichbar, blockiert aber Cross-Origin-Anfragen. Überprüfen Sie die CORS-Einstellungen Ihres JMAP-Servers und erlauben Sie diese Domain.",
"server_error": "Der Server ist vorübergehend nicht erreichbar. Bitte versuchen Sie es später erneut.",
"generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
"totp_required": "Ein Zwei-Faktor-Authentifizierungscode ist erforderlich. Bitte geben Sie Ihren Code unten ein.",
"totp_invalid": "Ungültiger Authentifizierungscode. Überprüfen Sie Ihre Authenticator-App.",
"oauth_discovery_failed": "SSO ist aktiviert, aber der Identitätsanbieter ist nicht erreichbar. Überprüfen Sie Ihre OAuth-Konfiguration."
},
@@ -1163,6 +1164,23 @@
"label": "Debug-Modus",
"description": "Detaillierte Protokollierung zur Fehlerbehebung aktivieren"
},
"debug_categories": {
"description": "Wählen Sie aus, welche Kategorien protokolliert werden sollen. Deaktivieren Sie nicht benötigte Kategorien, um die Konsolenausgabe zu reduzieren.",
"jmap": "JMAP-Client",
"jmap_description": "Postfachvorgänge, E-Mail-Abruf und JMAP-Protokollanfragen",
"calendar": "Kalender",
"calendar_description": "Kalenderereignisse, Importe und Terminplanungsnachrichten",
"tasks": "Aufgaben",
"tasks_description": "Erstellung, Abruf und Aktualisierung von Kalenderaufgaben",
"auth": "Authentifizierung",
"auth_description": "Anmeldung, TOTP, Token-Austausch und Sitzungsverwaltung",
"filters": "Filter",
"filters_description": "Sieve-Filterregeln und Urlaubsskripte",
"email": "E-Mail-Anzeige",
"email_description": "E-Mail-Darstellung, TNEF-Verarbeitung und Als-gelesen-Markierung",
"push": "Push-Benachrichtigungen",
"push_description": "Einrichtung und Zustellung von Push-Benachrichtigungen"
},
"settings_sync": {
"label": "Einstellungen synchronisieren",
"description": "Synchronisieren Sie Ihre Einstellungen über Browser und Geräte hinweg"
@@ -2108,6 +2126,12 @@
"interval_1440": "Jeden Tag",
"subscribe": "Abonnieren",
"subscribing": "Abonniere...",
"save": "Änderungen speichern",
"saving": "Speichert...",
"edit": "Bearbeiten",
"edit_title": "Abonnement bearbeiten",
"updated": "\"{name}\" aktualisiert",
"update_error": "Abonnement konnte nicht aktualisiert werden",
"invalid_url": "Bitte geben Sie eine gültige URL ein",
"success": "\"{name}\" abonniert",
"error": "Abonnement konnte nicht hinzugefügt werden",
@@ -2315,7 +2339,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
"settings_folder_layout_sidebar": "Sidebar"
"settings_folder_layout_sidebar": "Sidebar",
"disabled_title": "Die Dateifunktion wurde von Ihrem Administrator deaktiviert",
"disabled_description": "Große Datei-Uploads über WebDAV können Stalwart/RocksDB-Instabilität verursachen, einschließlich Out-of-Memory-Abstürzen und nicht wiederherstellbarer Festplattennutzung. Gelöschte Dateien werden möglicherweise nicht sofort aus dem Blob-Speicher entfernt. Diese Funktion wird für Produktionsumgebungen nicht empfohlen.",
"stability_warning": "Große Datei-Uploads können zu Serverinstabilität führen. Gelöschte Dateien werden möglicherweise nicht sofort aus dem Speicher entfernt. Mit Vorsicht verwenden."
},
"smime": {
"your_certificates": "Ihre Zertifikate",
@@ -2384,6 +2411,7 @@
"status_signed_valid": "Signatur überprüft",
"status_signed_invalid": "Signaturprüfung fehlgeschlagen",
"status_signed_expired_cert": "Mit einem abgelaufenen Zertifikat signiert",
"status_signed_self_signed": "Signatur gültig, aber das Zertifikat ist selbstsigniert (nicht vertrauenswürdig)",
"status_signed_mismatch": "Signatur ist gültig, aber der Unterzeichner stimmt nicht mit dem Absender überein",
"status_unsupported": "Nicht unterstütztes S/MIME-Format",
"auto_import_signer_certs": "Unterzeichnerzertifikate automatisch importieren",
+22 -1
View File
@@ -1164,6 +1164,23 @@
"label": "Debug Mode",
"description": "Enable detailed logging for troubleshooting"
},
"debug_categories": {
"description": "Select which categories to log. Disable categories you don't need to reduce console noise.",
"jmap": "JMAP Client",
"jmap_description": "Mailbox operations, email fetching, and JMAP protocol requests",
"calendar": "Calendar",
"calendar_description": "Calendar events, imports, and scheduling messages",
"tasks": "Tasks",
"tasks_description": "Calendar task creation, fetching, and updates",
"auth": "Authentication",
"auth_description": "Login, TOTP, token exchange, and session management",
"filters": "Filters",
"filters_description": "Sieve filter rules and vacation scripts",
"email": "Email Viewing",
"email_description": "Email rendering, TNEF processing, and mark-as-read",
"push": "Push Notifications",
"push_description": "Push notification setup and delivery"
},
"settings_sync": {
"label": "Settings Sync",
"description": "Sync your settings across browsers and devices"
@@ -2322,7 +2339,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
"settings_folder_layout_sidebar": "Sidebar"
"settings_folder_layout_sidebar": "Sidebar",
"disabled_title": "Files feature is disabled by your administrator",
"disabled_description": "Large file uploads via WebDAV can cause Stalwart/RocksDB instability, including out-of-memory crashes and unrecoverable disk usage. Deleted files may not be immediately purged from blob storage. This feature is not recommended for production environments.",
"stability_warning": "Large file uploads can cause server instability. Deleted files may not be immediately purged from storage. Use with caution."
},
"smime": {
"your_certificates": "Your Certificates",
@@ -2391,6 +2411,7 @@
"status_signed_valid": "Signature verified",
"status_signed_invalid": "Signature verification failed",
"status_signed_expired_cert": "Signed with an expired certificate",
"status_signed_self_signed": "Signature valid, but certificate is self-signed (not trusted)",
"status_signed_mismatch": "Signature valid, but signer does not match sender",
"status_unsupported": "Unsupported S/MIME format",
"auto_import_signer_certs": "Auto-import signer certificates",
+29 -1
View File
@@ -18,6 +18,7 @@
"cors_blocked": "El servidor es accesible pero está bloqueando las solicitudes de origen cruzado. Verifique la configuración CORS de su servidor JMAP y permita este dominio.",
"server_error": "El servidor no está disponible temporalmente. Inténtalo más tarde.",
"generic": "Ocurrió un error. Por favor, inténtelo de nuevo.",
"totp_required": "Se requiere un código de autenticación de dos factores. Introduce tu código a continuación.",
"totp_invalid": "Código de autenticación inválido. Verifica tu aplicación de autenticación.",
"oauth_discovery_failed": "SSO está habilitado pero no se pudo contactar al proveedor de identidad. Verifica tu configuración OAuth."
},
@@ -1163,6 +1164,23 @@
"label": "Modo de Depuración",
"description": "Habilitar registro detallado para solución de problemas"
},
"debug_categories": {
"description": "Selecciona qué categorías registrar. Desactiva las categorías que no necesites para reducir el ruido en la consola.",
"jmap": "Cliente JMAP",
"jmap_description": "Operaciones de buzón, obtención de correo y solicitudes del protocolo JMAP",
"calendar": "Calendario",
"calendar_description": "Eventos del calendario, importaciones y mensajes de programación",
"tasks": "Tareas",
"tasks_description": "Creación, obtención y actualizaciones de tareas del calendario",
"auth": "Autenticación",
"auth_description": "Inicio de sesión, TOTP, intercambio de tokens y gestión de sesiones",
"filters": "Filtros",
"filters_description": "Reglas de filtro Sieve y scripts de vacaciones",
"email": "Visualización de correo",
"email_description": "Renderizado de correos, procesamiento TNEF y marcado como leído",
"push": "Notificaciones push",
"push_description": "Configuración y entrega de notificaciones push"
},
"settings_sync": {
"label": "Sincronización de ajustes",
"description": "Sincronice sus ajustes entre navegadores y dispositivos"
@@ -2108,6 +2126,12 @@
"interval_1440": "Cada día",
"subscribe": "Suscribirse",
"subscribing": "Suscribiendo...",
"save": "Guardar cambios",
"saving": "Guardando...",
"edit": "Editar",
"edit_title": "Editar suscripción",
"updated": "\"{name}\" actualizado",
"update_error": "No se pudo actualizar la suscripción",
"invalid_url": "Por favor, introduce una URL válida",
"success": "Suscrito a \"{name}\"",
"error": "No se pudo añadir la suscripción",
@@ -2315,7 +2339,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
"settings_folder_layout_sidebar": "Sidebar"
"settings_folder_layout_sidebar": "Sidebar",
"disabled_title": "La función de archivos ha sido desactivada por su administrador",
"disabled_description": "Las cargas de archivos grandes a través de WebDAV pueden causar inestabilidad en Stalwart/RocksDB, incluyendo errores de memoria y uso irrecuperable del disco. Los archivos eliminados pueden no eliminarse inmediatamente del almacenamiento. Esta función no se recomienda para entornos de producción.",
"stability_warning": "Las cargas de archivos grandes pueden causar inestabilidad del servidor. Los archivos eliminados pueden no eliminarse inmediatamente del almacenamiento. Usar con precaución."
},
"smime": {
"your_certificates": "Tus certificados",
@@ -2384,6 +2411,7 @@
"status_signed_valid": "Firma verificada",
"status_signed_invalid": "La verificación de la firma falló",
"status_signed_expired_cert": "Firmado con un certificado caducado",
"status_signed_self_signed": "Firma válida, pero el certificado es autofirmado (no confiable)",
"status_signed_mismatch": "La firma es válida, pero el firmante no coincide con el remitente",
"status_unsupported": "Formato S/MIME no compatible",
"auto_import_signer_certs": "Importar automáticamente certificados de firmantes",
+29 -1
View File
@@ -18,6 +18,7 @@
"cors_blocked": "Le serveur est joignable mais bloque les requêtes cross-origin. Vérifiez la configuration CORS de votre serveur JMAP et autorisez ce domaine.",
"server_error": "Le serveur est temporairement indisponible. Veuillez réessayer plus tard.",
"generic": "Une erreur s'est produite. Veuillez réessayer.",
"totp_required": "Un code d'authentification à deux facteurs est requis. Veuillez saisir votre code ci-dessous.",
"totp_invalid": "Code d'authentification invalide. Vérifiez votre application d'authentification.",
"oauth_discovery_failed": "Le SSO est activé mais le fournisseur d'identité est injoignable. Vérifiez votre configuration OAuth."
},
@@ -1163,6 +1164,23 @@
"label": "Mode débogage",
"description": "Activer la journalisation détaillée pour le dépannage"
},
"debug_categories": {
"description": "Sélectionnez les catégories à journaliser. Désactivez celles dont vous n'avez pas besoin pour réduire le bruit dans la console.",
"jmap": "Client JMAP",
"jmap_description": "Opérations de boîte aux lettres, récupération des e-mails et requêtes du protocole JMAP",
"calendar": "Calendrier",
"calendar_description": "Événements du calendrier, importations et messages de planification",
"tasks": "Tâches",
"tasks_description": "Création, récupération et mise à jour des tâches du calendrier",
"auth": "Authentification",
"auth_description": "Connexion, TOTP, échange de jetons et gestion de session",
"filters": "Filtres",
"filters_description": "Règles de filtre Sieve et scripts d'absence",
"email": "Affichage des e-mails",
"email_description": "Rendu des e-mails, traitement TNEF et marquage comme lu",
"push": "Notifications push",
"push_description": "Configuration et distribution des notifications push"
},
"settings_sync": {
"label": "Synchronisation des paramètres",
"description": "Synchronisez vos paramètres entre navigateurs et appareils"
@@ -2108,6 +2126,12 @@
"interval_1440": "Tous les jours",
"subscribe": "S'abonner",
"subscribing": "Abonnement en cours...",
"save": "Enregistrer les modifications",
"saving": "Enregistrement...",
"edit": "Modifier",
"edit_title": "Modifier l'abonnement",
"updated": "\"{name}\" mis à jour",
"update_error": "Échec de la mise à jour de l'abonnement",
"invalid_url": "Veuillez entrer une URL valide",
"success": "Abonné à \"{name}\"",
"error": "Impossible d'ajouter l'abonnement",
@@ -2315,7 +2339,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
"settings_folder_layout_sidebar": "Sidebar"
"settings_folder_layout_sidebar": "Sidebar",
"disabled_title": "La fonctionnalité Fichiers a été désactivée par votre administrateur",
"disabled_description": "Les téléchargements de fichiers volumineux via WebDAV peuvent provoquer une instabilité de Stalwart/RocksDB, y compris des crashs de mémoire et une utilisation irrécupérable du disque. Les fichiers supprimés peuvent ne pas être immédiatement purgés du stockage. Cette fonctionnalité n'est pas recommandée pour les environnements de production.",
"stability_warning": "Les téléchargements de fichiers volumineux peuvent provoquer une instabilité du serveur. Les fichiers supprimés peuvent ne pas être immédiatement purgés du stockage. À utiliser avec prudence."
},
"smime": {
"your_certificates": "Vos certificats",
@@ -2384,6 +2411,7 @@
"status_signed_valid": "Signature vérifiée",
"status_signed_invalid": "Échec de la vérification de la signature",
"status_signed_expired_cert": "Signé avec un certificat expiré",
"status_signed_self_signed": "Signature valide, mais le certificat est auto-signé (non fiable)",
"status_signed_mismatch": "La signature est valide, mais le signataire ne correspond pas à l'expéditeur",
"status_unsupported": "Format S/MIME non pris en charge",
"auto_import_signer_certs": "Importer automatiquement les certificats des signataires",
+29 -1
View File
@@ -18,6 +18,7 @@
"cors_blocked": "Il server è raggiungibile ma sta bloccando le richieste cross-origin. Controlla le impostazioni CORS del tuo server JMAP e consenti questo dominio.",
"server_error": "Il server non è temporaneamente disponibile. Riprova più tardi.",
"generic": "Si è verificato un errore. Riprova.",
"totp_required": "È richiesto un codice di autenticazione a due fattori. Inserisci il codice qui sotto.",
"totp_invalid": "Codice di autenticazione non valido. Controlla la tua app di autenticazione.",
"oauth_discovery_failed": "SSO è abilitato ma il provider di identità non è raggiungibile. Controlla la configurazione OAuth."
},
@@ -1163,6 +1164,23 @@
"label": "Modalità debug",
"description": "Abilita registrazione dettagliata per la risoluzione dei problemi"
},
"debug_categories": {
"description": "Seleziona quali categorie registrare. Disattiva quelle che non ti servono per ridurre il rumore nella console.",
"jmap": "Client JMAP",
"jmap_description": "Operazioni sulle caselle di posta, recupero email e richieste del protocollo JMAP",
"calendar": "Calendario",
"calendar_description": "Eventi del calendario, importazioni e messaggi di pianificazione",
"tasks": "Attività",
"tasks_description": "Creazione, recupero e aggiornamento delle attività del calendario",
"auth": "Autenticazione",
"auth_description": "Accesso, TOTP, scambio di token e gestione della sessione",
"filters": "Filtri",
"filters_description": "Regole di filtro Sieve e script di risposta automatica",
"email": "Visualizzazione email",
"email_description": "Rendering delle email, elaborazione TNEF e segna come letto",
"push": "Notifiche push",
"push_description": "Configurazione e recapito delle notifiche push"
},
"settings_sync": {
"label": "Sincronizzazione impostazioni",
"description": "Sincronizza le impostazioni tra browser e dispositivi"
@@ -2108,6 +2126,12 @@
"interval_1440": "Ogni giorno",
"subscribe": "Abbonati",
"subscribing": "Abbonamento in corso...",
"save": "Salva modifiche",
"saving": "Salvataggio...",
"edit": "Modifica",
"edit_title": "Modifica abbonamento",
"updated": "\"{name}\" aggiornato",
"update_error": "Impossibile aggiornare l'abbonamento",
"invalid_url": "Inserisci un URL valido",
"success": "Abbonato a \"{name}\"",
"error": "Impossibile aggiungere l'abbonamento",
@@ -2315,7 +2339,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
"settings_folder_layout_sidebar": "Sidebar"
"settings_folder_layout_sidebar": "Sidebar",
"disabled_title": "La funzionalità File è stata disabilitata dal tuo amministratore",
"disabled_description": "I caricamenti di file di grandi dimensioni tramite WebDAV possono causare instabilità di Stalwart/RocksDB, inclusi crash di memoria e utilizzo irrecuperabile del disco. I file eliminati potrebbero non essere immediatamente rimossi dallo storage. Questa funzionalità non è consigliata per ambienti di produzione.",
"stability_warning": "I caricamenti di file di grandi dimensioni possono causare instabilità del server. I file eliminati potrebbero non essere immediatamente rimossi dallo storage. Usare con cautela."
},
"smime": {
"your_certificates": "I tuoi certificati",
@@ -2384,6 +2411,7 @@
"status_signed_valid": "Firma verificata",
"status_signed_invalid": "Verifica della firma non riuscita",
"status_signed_expired_cert": "Firmato con un certificato scaduto",
"status_signed_self_signed": "Firma valida, ma il certificato è autofirmato (non attendibile)",
"status_signed_mismatch": "La firma è valida, ma il firmatario non corrisponde al mittente",
"status_unsupported": "Formato S/MIME non supportato",
"auto_import_signer_certs": "Importa automaticamente i certificati dei firmatari",
+29 -1
View File
@@ -18,6 +18,7 @@
"cors_blocked": "サーバーには到達できますが、クロスオリジンリクエストがブロックされています。JMAPサーバーのCORS設定を確認し、このドメインを許可してください。",
"server_error": "サーバーが一時的に利用できません。後でもう一度お試しください。",
"generic": "エラーが発生しました。もう一度お試しください。",
"totp_required": "二要素認証コードが必要です。以下にコードを入力してください。",
"totp_invalid": "認証コードが無効です。認証アプリを確認してください。",
"oauth_discovery_failed": "SSOは有効ですが、IDプロバイダーに接続できません。OAuth設定を確認してください。"
},
@@ -1163,6 +1164,23 @@
"label": "デバッグモード",
"description": "トラブルシューティング用の詳細ログを有効化"
},
"debug_categories": {
"description": "記録するカテゴリを選択してください。不要なカテゴリを無効にすると、コンソールのノイズを減らせます。",
"jmap": "JMAPクライアント",
"jmap_description": "メールボックス操作、メール取得、JMAPプロトコル要求",
"calendar": "カレンダー",
"calendar_description": "カレンダーイベント、インポート、スケジュールメッセージ",
"tasks": "タスク",
"tasks_description": "カレンダータスクの作成、取得、更新",
"auth": "認証",
"auth_description": "ログイン、TOTP、トークン交換、セッション管理",
"filters": "フィルター",
"filters_description": "Sieveフィルタールールと休暇スクリプト",
"email": "メール表示",
"email_description": "メールのレンダリング、TNEF処理、既読化",
"push": "プッシュ通知",
"push_description": "プッシュ通知の設定と配信"
},
"settings_sync": {
"label": "設定の同期",
"description": "ブラウザーやデバイス間で設定を同期します"
@@ -2108,6 +2126,12 @@
"interval_1440": "毎日",
"subscribe": "購読する",
"subscribing": "購読中...",
"save": "変更を保存",
"saving": "保存中...",
"edit": "編集",
"edit_title": "購読を編集",
"updated": "\"{name}\" を更新しました",
"update_error": "購読を更新できませんでした",
"invalid_url": "有効なURLを入力してください",
"success": "\"{name}\"を購読しました",
"error": "購読の追加に失敗しました",
@@ -2315,7 +2339,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
"settings_folder_layout_sidebar": "Sidebar"
"settings_folder_layout_sidebar": "Sidebar",
"disabled_title": "ファイル機能は管理者によって無効にされています",
"disabled_description": "WebDAV経由の大容量ファイルアップロードは、メモリ不足クラッシュや回復不能なディスク使用量など、Stalwart/RocksDBの不安定性を引き起こす可能性があります。削除されたファイルはストレージからすぐに削除されない場合があります。この機能は本番環境では推奨されません。",
"stability_warning": "大容量ファイルのアップロードはサーバーの不安定性を引き起こす可能性があります。削除されたファイルはストレージからすぐに削除されない場合があります。注意して使用してください。"
},
"smime": {
"your_certificates": "あなたの証明書",
@@ -2384,6 +2411,7 @@
"status_signed_valid": "署名を確認しました",
"status_signed_invalid": "署名の検証に失敗しました",
"status_signed_expired_cert": "期限切れの証明書で署名されています",
"status_signed_self_signed": "署名は有効ですが、証明書は自己署名です(信頼されていません)",
"status_signed_mismatch": "署名は有効ですが、署名者が送信者と一致しません",
"status_unsupported": "未対応の S/MIME 形式です",
"auto_import_signer_certs": "署名者証明書を自動インポート",
+29 -1
View File
@@ -18,6 +18,7 @@
"cors_blocked": "De server is bereikbaar maar blokkeert cross-origin verzoeken. Controleer de CORS-instellingen van uw JMAP-server en sta dit domein toe.",
"server_error": "De server is tijdelijk niet beschikbaar. Probeer het later opnieuw.",
"generic": "Er is een fout opgetreden. Probeer het opnieuw.",
"totp_required": "Een tweefactorauthenticatiecode is vereist. Voer uw code hieronder in.",
"totp_invalid": "Ongeldige authenticatiecode. Controleer uw authenticator-app.",
"oauth_discovery_failed": "SSO is ingeschakeld maar de identiteitsprovider is niet bereikbaar. Controleer uw OAuth-configuratie."
},
@@ -1163,6 +1164,23 @@
"label": "Debugmodus",
"description": "Schakel gedetailleerde logging in voor probleemoplossing"
},
"debug_categories": {
"description": "Selecteer welke categorieën moeten worden gelogd. Schakel categorieën die u niet nodig hebt uit om ruis in de console te verminderen.",
"jmap": "JMAP-client",
"jmap_description": "Mailboxbewerkingen, e-mail ophalen en JMAP-protocolverzoeken",
"calendar": "Agenda",
"calendar_description": "Agenda-afspraken, importen en planningsberichten",
"tasks": "Taken",
"tasks_description": "Aanmaken, ophalen en bijwerken van agendataken",
"auth": "Authenticatie",
"auth_description": "Inloggen, TOTP, tokenuitwisseling en sessiebeheer",
"filters": "Filters",
"filters_description": "Sieve-filterregels en vakantiescripts",
"email": "E-mailweergave",
"email_description": "E-mailrendering, TNEF-verwerking en markeren als gelezen",
"push": "Pushmeldingen",
"push_description": "Instellen en afleveren van pushmeldingen"
},
"settings_sync": {
"label": "Instellingen synchroniseren",
"description": "Synchroniseer uw instellingen tussen browsers en apparaten"
@@ -2108,6 +2126,12 @@
"interval_1440": "Elke dag",
"subscribe": "Abonneren",
"subscribing": "Bezig met abonneren...",
"save": "Wijzigingen opslaan",
"saving": "Opslaan...",
"edit": "Bewerken",
"edit_title": "Abonnement bewerken",
"updated": "\"{name}\" bijgewerkt",
"update_error": "Abonnement kon niet worden bijgewerkt",
"invalid_url": "Voer een geldige URL in",
"success": "Geabonneerd op \"{name}\"",
"error": "Kan abonnement niet toevoegen",
@@ -2315,7 +2339,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
"settings_folder_layout_sidebar": "Sidebar"
"settings_folder_layout_sidebar": "Sidebar",
"disabled_title": "De bestandsfunctie is uitgeschakeld door uw beheerder",
"disabled_description": "Grote bestandsuploads via WebDAV kunnen Stalwart/RocksDB-instabiliteit veroorzaken, waaronder geheugenfouten en onherstelbaar schijfgebruik. Verwijderde bestanden worden mogelijk niet onmiddellijk uit de opslag verwijderd. Deze functie wordt niet aanbevolen voor productieomgevingen.",
"stability_warning": "Grote bestandsuploads kunnen serverinstabiliteit veroorzaken. Verwijderde bestanden worden mogelijk niet onmiddellijk uit de opslag verwijderd. Gebruik met voorzichtigheid."
},
"smime": {
"your_certificates": "Uw certificaten",
@@ -2384,6 +2411,7 @@
"status_signed_valid": "Handtekening geverifieerd",
"status_signed_invalid": "Verificatie van de handtekening is mislukt",
"status_signed_expired_cert": "Ondertekend met een verlopen certificaat",
"status_signed_self_signed": "Handtekening geldig, maar het certificaat is zelfondertekend (niet vertrouwd)",
"status_signed_mismatch": "Handtekening is geldig, maar de ondertekenaar komt niet overeen met de afzender",
"status_unsupported": "Niet-ondersteund S/MIME-formaat",
"auto_import_signer_certs": "Ondertekenaarcertificaten automatisch importeren",
+29 -1
View File
@@ -18,6 +18,7 @@
"cors_blocked": "O servidor está acessível mas está bloqueando requisições de origem cruzada. Verifique as configurações de CORS do seu servidor JMAP e permita este domínio.",
"server_error": "O servidor está temporariamente indisponível. Tente novamente mais tarde.",
"generic": "Ocorreu um erro. Por favor, tente novamente.",
"totp_required": "É necessário um código de autenticação de dois fatores. Insira seu código abaixo.",
"totp_invalid": "Código de autenticação inválido. Verifique seu aplicativo de autenticação.",
"oauth_discovery_failed": "SSO está ativado mas o provedor de identidade não pôde ser contactado. Verifique sua configuração OAuth."
},
@@ -1163,6 +1164,23 @@
"label": "Modo de Depuração",
"description": "Habilitar registro detalhado para solução de problemas"
},
"debug_categories": {
"description": "Selecione quais categorias registrar. Desative as categorias de que você não precisa para reduzir o ruído no console.",
"jmap": "Cliente JMAP",
"jmap_description": "Operações de caixa de correio, busca de e-mails e solicitações do protocolo JMAP",
"calendar": "Calendário",
"calendar_description": "Eventos do calendário, importações e mensagens de agendamento",
"tasks": "Tarefas",
"tasks_description": "Criação, busca e atualização de tarefas do calendário",
"auth": "Autenticação",
"auth_description": "Login, TOTP, troca de tokens e gerenciamento de sessão",
"filters": "Filtros",
"filters_description": "Regras de filtro Sieve e scripts de férias",
"email": "Visualização de email",
"email_description": "Renderização de emails, processamento TNEF e marcação como lido",
"push": "Notificações push",
"push_description": "Configuração e entrega de notificações push"
},
"settings_sync": {
"label": "Sincronização de configurações",
"description": "Sincronize suas configurações entre navegadores e dispositivos"
@@ -2108,6 +2126,12 @@
"interval_1440": "Diariamente",
"subscribe": "Assinar",
"subscribing": "Assinando...",
"save": "Salvar alterações",
"saving": "Salvando...",
"edit": "Editar",
"edit_title": "Editar assinatura",
"updated": "\"{name}\" atualizada",
"update_error": "Falha ao atualizar assinatura",
"invalid_url": "Por favor, insira uma URL válida",
"success": "Assinado \"{name}\"",
"error": "Falha ao adicionar assinatura",
@@ -2315,7 +2339,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
"settings_folder_layout_sidebar": "Sidebar"
"settings_folder_layout_sidebar": "Sidebar",
"disabled_title": "O recurso de arquivos foi desativado pelo seu administrador",
"disabled_description": "Uploads de arquivos grandes via WebDAV podem causar instabilidade no Stalwart/RocksDB, incluindo falhas de memória e uso irrecuperável de disco. Arquivos excluídos podem não ser removidos imediatamente do armazenamento. Este recurso não é recomendado para ambientes de produção.",
"stability_warning": "Uploads de arquivos grandes podem causar instabilidade no servidor. Arquivos excluídos podem não ser removidos imediatamente do armazenamento. Use com cautela."
},
"smime": {
"your_certificates": "Seus certificados",
@@ -2384,6 +2411,7 @@
"status_signed_valid": "Assinatura verificada",
"status_signed_invalid": "A verificação da assinatura falhou",
"status_signed_expired_cert": "Assinada com um certificado expirado",
"status_signed_self_signed": "Assinatura válida, mas o certificado é autoassinado (não confiável)",
"status_signed_mismatch": "A assinatura é válida, mas o signatário não corresponde ao remetente",
"status_unsupported": "Formato S/MIME não suportado",
"auto_import_signer_certs": "Importar automaticamente certificados de signatários",
+29 -1
View File
@@ -18,6 +18,7 @@
"cors_blocked": "Сервер доступен, но блокирует запросы от других источников. Проверьте настройки CORS вашего JMAP-сервера и разрешите этот домен.",
"server_error": "Сервер временно недоступен. Повторите попытку позже.",
"generic": "Произошла непредвиденная ошибка. Если она повторяется, обратитесь к администратору.",
"totp_required": "Требуется код двухфакторной аутентификации. Пожалуйста, введите его ниже.",
"totp_invalid": "Неверный код аутентификации. Проверьте приложение аутентификатора и повторите попытку.",
"oauth_discovery_failed": "SSO включён, но поставщик удостоверений недоступен. Проверьте настройки OAuth."
},
@@ -1163,6 +1164,23 @@
"label": "Режим отладки",
"description": "Включить подробное журналирование для диагностики"
},
"debug_categories": {
"description": "Выберите, какие категории журналировать. Отключите ненужные категории, чтобы уменьшить шум в консоли.",
"jmap": "Клиент JMAP",
"jmap_description": "Операции с почтовыми ящиками, получение почты и запросы протокола JMAP",
"calendar": "Календарь",
"calendar_description": "События календаря, импорт и сообщения планирования",
"tasks": "Задачи",
"tasks_description": "Создание, получение и обновление задач календаря",
"auth": "Аутентификация",
"auth_description": "Вход, TOTP, обмен токенами и управление сессией",
"filters": "Фильтры",
"filters_description": "Правила фильтрации Sieve и скрипты отпуска",
"email": "Просмотр почты",
"email_description": "Отрисовка писем, обработка TNEF и пометка как прочитано",
"push": "Push-уведомления",
"push_description": "Настройка и доставка push-уведомлений"
},
"settings_sync": {
"label": "Синхронизация настроек",
"description": "Синхронизировать настройки между браузерами и устройствами"
@@ -2108,6 +2126,12 @@
"interval_1440": "Каждый день",
"subscribe": "Подписаться",
"subscribing": "Подписка...",
"save": "Сохранить изменения",
"saving": "Сохранение...",
"edit": "Изменить",
"edit_title": "Изменить подписку",
"updated": "«{name}» обновлена",
"update_error": "Не удалось обновить подписку",
"invalid_url": "Введите корректный URL",
"success": "Подписка на «{name}» оформлена",
"error": "Не удалось добавить подписку",
@@ -2315,7 +2339,10 @@
"settings_folder_layout": "Навигация по папкам",
"settings_folder_layout_desc": "Выберите, как отображать папки: встроенно с файлами или в боковом дереве",
"settings_folder_layout_inline": "Встроенно",
"settings_folder_layout_sidebar": "Боковая панель"
"settings_folder_layout_sidebar": "Боковая панель",
"disabled_title": "Функция файлов отключена вашим администратором",
"disabled_description": "Загрузка больших файлов через WebDAV может вызвать нестабильность Stalwart/RocksDB, включая ошибки нехватки памяти и невосстановимое использование диска. Удалённые файлы могут не быть немедленно удалены из хранилища. Эта функция не рекомендуется для рабочих сред.",
"stability_warning": "Загрузка больших файлов может вызвать нестабильность сервера. Удалённые файлы могут не быть немедленно удалены из хранилища. Используйте с осторожностью."
},
"smime": {
"your_certificates": "Ваши сертификаты",
@@ -2384,6 +2411,7 @@
"status_signed_valid": "Подпись проверена",
"status_signed_invalid": "Проверка подписи не удалась",
"status_signed_expired_cert": "Подписано просроченным сертификатом",
"status_signed_self_signed": "Подпись действительна, но сертификат самоподписанный (не доверенный)",
"status_signed_mismatch": "Подпись действительна, но подписант не совпадает с отправителем",
"status_unsupported": "Неподдерживаемый формат S/MIME",
"auto_import_signer_certs": "Автоимпорт сертификатов подписантов",
+12 -98
View File
@@ -603,18 +603,6 @@
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
"integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -625,17 +613,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -1946,19 +1923,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
@@ -2403,18 +2367,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
@@ -3995,17 +3947,6 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -8205,6 +8146,18 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pkijs": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz",
@@ -9433,19 +9386,6 @@
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
@@ -9863,19 +9803,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
@@ -9954,19 +9881,6 @@
}
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.4.10",
"version": "1.4.11",
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
@@ -92,6 +92,7 @@
"webcrypto-liner": "$elliptic"
},
"flatted": "^3.4.2",
"picomatch": "^4.0.4",
"undici": "^7.24.0"
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ export function proxy(request: NextRequest) {
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()"
);
response.headers.set("Content-Security-Policy-Report-Only", csp);
response.headers.set("Content-Security-Policy", csp);
return response;
}
+2 -8
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { debug } from '@/lib/debug';
import { useAuthStore } from './auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
interface AccountSecurityState {
// Detection
@@ -44,13 +44,7 @@ interface AccountSecurityState {
}
function getApiHeaders(): Record<string, string> {
const { client } = useAuthStore.getState();
if (!client) return {};
return {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
return getActiveAccountSlotHeaders();
}
export const useAccountSecurityStore = create<AccountSecurityState>()((set, get) => ({
+53 -4
View File
@@ -87,6 +87,27 @@ function getClientRateLimitState(client: IJMAPClient | null): Pick<AuthState, 'i
};
}
async function syncStalwartAuthContext(
serverUrl: string,
username: string,
authHeader: string,
slot: number,
): Promise<void> {
try {
const response = await fetch('/api/auth/stalwart-context', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, username, authHeader, slot }),
});
if (!response.ok) {
debug.warn('auth', `Failed to sync Stalwart auth context: ${response.status}`);
}
} catch (error) {
debug.warn('auth', 'Failed to sync Stalwart auth context:', error);
}
}
function bindClientStatusHandlers(
client: IJMAPClient,
set: (state: Partial<AuthState>) => void,
@@ -393,13 +414,13 @@ export const useAuthStore = create<AuthState>()(
oauthAccessToken = access_token;
oauthExpiresIn = expires_in;
upgradedToOAuth = true;
debug.log('TOTP login upgraded to token-based auth (has_refresh_token=' + has_refresh_token + ')');
debug.log('auth', 'TOTP login upgraded to token-based auth (has_refresh_token=' + has_refresh_token + ')');
} else {
const errorBody = await tokenRes.json().catch(() => ({ error: 'unknown' }));
debug.warn('TOTP token exchange failed:', tokenRes.status, errorBody);
debug.warn('auth', 'TOTP token exchange failed:', tokenRes.status, errorBody);
}
} catch (err) {
debug.warn('TOTP token exchange error:', err);
debug.warn('auth', 'TOTP token exchange error:', err);
}
// If token exchange failed, enable TOTP re-auth prompt so the
@@ -407,7 +428,7 @@ export const useAuthStore = create<AuthState>()(
if (!upgradedToOAuth) {
const { useTotpReauthStore } = await import('@/stores/totp-reauth-store');
client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp());
debug.log('TOTP re-auth enabled — user will be prompted for fresh codes on session expiry');
debug.log('auth', 'TOTP re-auth enabled — user will be prompted for fresh codes on session expiry');
}
}
@@ -461,6 +482,8 @@ export const useAuthStore = create<AuthState>()(
}
}
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot);
set({
isAuthenticated: true,
isLoading: false,
@@ -636,6 +659,8 @@ export const useAuthStore = create<AuthState>()(
});
accountStore.setActiveAccount(accountId);
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), slot);
set({
isAuthenticated: true,
isLoading: false,
@@ -754,6 +779,9 @@ export const useAuthStore = create<AuthState>()(
});
accountStore.setActiveAccount(accountId);
const cookieSlot = accountStore.getAccountById(accountId)?.cookieSlot ?? 0;
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), cookieSlot);
set({
isAuthenticated: true,
isLoading: false,
@@ -825,6 +853,15 @@ export const useAuthStore = create<AuthState>()(
get().client?.updateAccessToken(access_token);
if (account) {
await syncStalwartAuthContext(
account.serverUrl,
account.username,
`Bearer ${access_token}`,
slot,
);
}
set({
accessToken: access_token,
tokenExpiresAt: Date.now() + expires_in * 1000,
@@ -1012,6 +1049,12 @@ export const useAuthStore = create<AuthState>()(
await targetClient.connect();
clients.set(accountId, targetClient);
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
await syncStalwartAuthContext(
targetAccount.serverUrl,
targetAccount.username,
targetClient.getAuthHeader(),
targetAccount.cookieSlot,
);
}
} else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) {
const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
@@ -1021,6 +1064,7 @@ export const useAuthStore = create<AuthState>()(
bindClientStatusHandlers(targetClient, set, get, accountId);
await targetClient.connect();
clients.set(accountId, targetClient);
await syncStalwartAuthContext(serverUrl, username, targetClient.getAuthHeader(), targetAccount.cookieSlot);
}
}
} catch (err) {
@@ -1174,6 +1218,7 @@ export const useAuthStore = create<AuthState>()(
await client.connect();
clients.set(account.id, client);
scheduleRefresh(expires_in, get().refreshAccessToken, account.id);
await syncStalwartAuthContext(account.serverUrl, account.username, client.getAuthHeader(), account.cookieSlot);
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
} else {
throw new Error(`Token refresh failed: ${res.status}`);
@@ -1186,6 +1231,7 @@ export const useAuthStore = create<AuthState>()(
bindClientStatusHandlers(client, set, get, account.id);
await client.connect();
clients.set(account.id, client);
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), account.cookieSlot);
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
} else {
throw new Error(`Session cookie missing: ${res.status}`);
@@ -1401,6 +1447,9 @@ export const useAuthStore = create<AuthState>()(
});
accountStore.setActiveAccount(accountId);
const cookieSlot = accountStore.getAccountById(accountId)?.cookieSlot ?? 0;
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot);
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);
+17 -17
View File
@@ -197,7 +197,7 @@ export const useCalendarStore = create<CalendarStore>()(
// Expand recurring events client-side (Stalwart doesn't support
// mutations on synthetic IDs from server-side expandRecurrences)
const events = expandRecurringEvents(validEvents, start, end);
debug.log('Calendar fetchEvents completed', {
debug.log('calendar', 'Calendar fetchEvents completed', {
start,
end,
rawCount: rawEvents.length,
@@ -206,7 +206,7 @@ export const useCalendarStore = create<CalendarStore>()(
droppedEvents,
});
if (droppedEvents > 0) {
debug.warn('Calendar fetchEvents dropped malformed events without a start field', { droppedEvents });
debug.warn('calendar', 'Calendar fetchEvents dropped malformed events without a start field', { droppedEvents });
}
set({ events, isLoadingEvents: false, dateRange: { start, end } });
} catch (error) {
@@ -237,7 +237,7 @@ export const useCalendarStore = create<CalendarStore>()(
if (event.originalCalendarIds) {
cleanEvent.calendarIds = event.originalCalendarIds;
}
debug.log('Calendar createEvent request', {
debug.log('calendar', 'Calendar createEvent request', {
event: getStoreEventDebugSnapshot(cleanEvent),
sendSchedulingMessages,
targetAccountId,
@@ -256,7 +256,7 @@ export const useCalendarStore = create<CalendarStore>()(
? mappedCreated.start >= currentDateRange.start && mappedCreated.start <= currentDateRange.end
: null;
debug.log('Calendar createEvent response', {
debug.log('calendar', 'Calendar createEvent response', {
created: getStoreEventDebugSnapshot(created),
mappedCreated: getStoreEventDebugSnapshot(mappedCreated),
isVisible,
@@ -265,21 +265,21 @@ export const useCalendarStore = create<CalendarStore>()(
});
if (!isVisible) {
debug.warn('Created event is hidden by current calendar filters', {
debug.warn('calendar', 'Created event is hidden by current calendar filters', {
selectedCalendarIds,
createdCalendarIds,
});
}
if (inCurrentDateRange === false) {
debug.warn('Created event is outside the currently loaded date range', {
debug.warn('calendar', 'Created event is outside the currently loaded date range', {
currentDateRange,
createdStart: mappedCreated.start,
});
}
if (mappedCreated.showWithoutTime && mappedCreated.timeZone !== null) {
debug.warn('Created all-day event came back with a non-null timeZone', {
debug.warn('calendar', 'Created all-day event came back with a non-null timeZone', {
timeZone: mappedCreated.timeZone,
event: getStoreEventDebugSnapshot(mappedCreated),
});
@@ -301,7 +301,7 @@ export const useCalendarStore = create<CalendarStore>()(
const storeEvent = get().events.find(e => e.id === id);
const realId = storeEvent?.originalId || id;
const targetAccountId = storeEvent?.accountId;
debug.log('Calendar updateEvent', {
debug.log('calendar', 'Calendar updateEvent', {
storeId: id,
realId,
uid: storeEvent?.uid,
@@ -445,20 +445,20 @@ export const useCalendarStore = create<CalendarStore>()(
await client.updateCalendarEvent(eventId, { calendarIds } as Partial<CalendarEvent>, undefined, targetAccountId);
linked++;
} catch (err) {
debug.warn(`Import: failed to link event ${eventId} to target calendar:`, err);
debug.warn('calendar', `Import: failed to link event ${eventId} to target calendar:`, err);
}
}
if (linked > 0) {
debug.log(`Import: linked ${linked} existing events to target calendar`);
debug.log('calendar', `Import: linked ${linked} existing events to target calendar`);
}
const skipped = eventsToProcess.length - newEvents.length - eventsToLink.length;
if (skipped > 0) {
debug.log(`Import: skipped ${skipped} events already in target calendar`);
debug.log('calendar', `Import: skipped ${skipped} events already in target calendar`);
}
eventsToProcess = newEvents;
} catch (error) {
debug.warn('Could not fetch existing events for deduplication, proceeding without:', error);
debug.warn('calendar', 'Could not fetch existing events for deduplication, proceeding without:', error);
}
// Prepare all events for batch creation
@@ -543,7 +543,7 @@ export const useCalendarStore = create<CalendarStore>()(
const { created, failed } = await client.batchCreateCalendarEvents(batch, targetAccountId);
imported += created.length;
if (failed.length > 0) {
debug.warn(`Import batch ${i / BATCH_SIZE + 1}: ${failed.length} events failed`);
debug.warn('calendar', `Import batch ${i / BATCH_SIZE + 1}: ${failed.length} events failed`);
}
} catch (error) {
debug.error(`Import batch ${i / BATCH_SIZE + 1} failed:`, error);
@@ -577,7 +577,7 @@ export const useCalendarStore = create<CalendarStore>()(
debug.error('Failed to send cancellation emails:', e);
}
}
debug.log('Calendar deleteEvent', {
debug.log('calendar', 'Calendar deleteEvent', {
storeId: id,
realId,
uid: storeEvent?.uid,
@@ -675,7 +675,7 @@ export const useCalendarStore = create<CalendarStore>()(
// If we couldn't destroy any events, stop to avoid infinite loop
if (destroyed.length === 0) {
debug.warn('Could not delete any events, stopping clear loop. Not destroyed:', ids.length);
debug.warn('calendar', 'Could not delete any events, stopping clear loop. Not destroyed:', ids.length);
break;
}
@@ -744,7 +744,7 @@ export const useCalendarStore = create<CalendarStore>()(
await get().refreshICalSubscription(client, subscription.id);
} catch {
// Subscription created, initial fetch failed - user can retry
debug.warn('Initial subscription fetch failed for:', name);
debug.warn('calendar', 'Initial subscription fetch failed for:', name);
}
return subscription;
@@ -892,7 +892,7 @@ export const useCalendarStore = create<CalendarStore>()(
try {
await get().refreshICalSubscription(client, sub.id);
} catch {
debug.warn('Failed to refresh subscription:', sub.name);
debug.warn('calendar', 'Failed to refresh subscription:', sub.name);
}
}
}
+5 -5
View File
@@ -53,7 +53,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
set({ sieveCapabilities: capabilities });
const allScripts = await client.getSieveScripts();
debug.log('Sieve scripts fetched:', allScripts.length);
debug.log('filters', 'Sieve scripts fetched:', allScripts.length);
// Skip the server-managed 'vacation' script (RFC 9661 §4) — it can only
// be modified via VacationResponse/set, not SieveScript/set.
@@ -73,10 +73,10 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
const result = parseScript(content);
if (result.isOpaque) {
debug.log('Sieve script is opaque (hand-edited)');
debug.log('filters', 'Sieve script is opaque (hand-edited)');
set({ isLoading: false, isOpaque: true, rules: [], vacationSettings: result.vacation || null });
} else {
debug.log('Parsed', result.rules.length, 'filter rules');
debug.log('filters', 'Parsed', result.rules.length, 'filter rules');
set({ isLoading: false, isOpaque: false, rules: result.rules, vacationSettings: result.vacation || null });
}
} catch (error) {
@@ -108,7 +108,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
}
set({ isSaving: false, rawScript: content });
debug.log('Filters saved successfully');
debug.log('filters', 'Filters saved successfully');
} catch (error) {
debug.error('Failed to save filters:', error);
set({
@@ -212,7 +212,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
});
}
debug.log('Vacation synced to sieve script');
debug.log('filters', 'Vacation synced to sieve script');
} catch (error) {
debug.error('Failed to sync vacation to sieve script:', error);
}
+23
View File
@@ -49,6 +49,18 @@ export const ALL_HOVER_ACTIONS: { id: HoverAction; labelKey: string }[] = [
{ id: 'spam', labelKey: 'spam' },
];
export type DebugCategory = 'jmap' | 'calendar' | 'tasks' | 'auth' | 'filters' | 'email' | 'push';
export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
{ id: 'jmap', labelKey: 'jmap' },
{ id: 'calendar', labelKey: 'calendar' },
{ id: 'tasks', labelKey: 'tasks' },
{ id: 'auth', labelKey: 'auth' },
{ id: 'filters', labelKey: 'filters' },
{ id: 'email', labelKey: 'email' },
{ id: 'push', labelKey: 'push' },
];
export interface KeywordDefinition {
id: string; // Used as JMAP keyword suffix: $label:<id>
label: string; // Display name
@@ -175,6 +187,7 @@ interface SettingsState {
// Advanced
debugMode: boolean;
debugCategories: Record<DebugCategory, boolean>;
settingsSyncDisabled: boolean;
// Actions
@@ -299,6 +312,15 @@ const DEFAULT_SETTINGS = {
// Advanced
debugMode: false,
debugCategories: {
jmap: true,
calendar: true,
tasks: true,
auth: true,
filters: true,
email: true,
push: true,
} as Record<DebugCategory, boolean>,
settingsSyncDisabled: false,
};
@@ -383,6 +405,7 @@ export const useSettingsStore = create<SettingsState>()(
sidebarApps: state.sidebarApps,
keepAppsLoaded: state.keepAppsLoaded,
debugMode: state.debugMode,
debugCategories: state.debugCategories,
settingsSyncDisabled: state.settingsSyncDisabled,
// Cross-store settings
theme: useThemeStore.getState().theme,
+5 -5
View File
@@ -37,13 +37,13 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
setShowCompleted: (show) => set({ showCompleted: show }),
fetchTasks: async (client, calendarIds) => {
debug.log('TaskStore/fetchTasks start', { calendarIds: calendarIds || 'all' });
debug.log('tasks', 'TaskStore/fetchTasks start', { calendarIds: calendarIds || 'all' });
set({ isLoading: true, error: null });
try {
const tasks = await client.getCalendarTasks(calendarIds);
debug.log('TaskStore/fetchTasks received', tasks.length, 'tasks');
debug.log('tasks', 'TaskStore/fetchTasks received', tasks.length, 'tasks');
tasks.forEach((t, i) => {
debug.log(`TaskStore/fetchTasks [${i}]`, {
debug.log('tasks', `TaskStore/fetchTasks [${i}]`, {
id: t.id, uid: t.uid, '@type': t['@type'],
title: t.title, due: t.due, progress: t.progress,
showWithoutTime: t.showWithoutTime, calendarIds: t.calendarIds,
@@ -57,9 +57,9 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
},
createTask: async (client, task) => {
debug.log('TaskStore/createTask', task);
debug.log('tasks', 'TaskStore/createTask', task);
const created = await client.createCalendarTask(task);
debug.log('TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title });
debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title });
set({ tasks: [...get().tasks, created] });
return created;
},