diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7953c463..80d3da37 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/VERSION b/VERSION
index ac9f79ca..079d7f69 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.4.10
+1.4.11
diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx
index 0b1e2238..4f56a171 100644
--- a/app/[locale]/calendar/page.tsx
+++ b/app/[locale]/calendar/page.tsx
@@ -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,
diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx
index 330544bd..d785a669 100644
--- a/app/[locale]/files/page.tsx
+++ b/app/[locale]/files/page.tsx
@@ -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() {
)}
- {supportsFiles === false ? (
+ {!filesEnabled ? (
+
+
+
+
{t("disabled_title")}
+
{t("disabled_description")}
+
+
+ ) : supportsFiles === false ? (
) : (
+
+
+
+
{t("stability_warning")}
+
+
)}
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 94af300d..8c047226 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -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]);
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
index 891b1013..9e24470d 100644
--- a/app/admin/layout.tsx
+++ b/app/admin/layout.tsx
@@ -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 {
- 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() {
diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx
index 4fb09b5f..c2d82dec 100644
--- a/app/admin/policy/page.tsx
+++ b/app/admin/policy/page.tsx
@@ -19,6 +19,7 @@ const FEATURE_GATE_LABELS: Partial 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[]);
diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts
index a253fb93..b1137346 100644
--- a/app/api/admin/plugins/route.ts
+++ b/app/api/admin/plugins/route.ts
@@ -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 });
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 21635efd..273afaed 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -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 });
diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts
new file mode 100644
index 00000000..89894e2c
--- /dev/null
+++ b/app/api/auth/stalwart-context/route.ts
@@ -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 });
+ }
+}
\ No newline at end of file
diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts
index 40c1aac9..5cb10c34 100644
--- a/app/api/fetch-ical/route.ts
+++ b/app/api/fetch-ical/route.ts
@@ -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 {
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 }
);
}
diff --git a/app/api/webdav/route.ts b/app/api/webdav/route.ts
index 9bda6c1a..700c201d 100644
--- a/app/api/webdav/route.ts
+++ b/app/api/webdav/route.ts
@@ -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 = {
@@ -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 });
}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index fe07bc4a..1c96cf8e 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -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>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
@@ -339,10 +350,26 @@ export function EmailComposer({
const toInputRef = useRef(null);
const ccInputRef = useRef(null);
const bccInputRef = useRef(null);
+ const subjectInputRef = useRef(null);
+ const bodyRef = useRef(null);
+ const editorContainerRef = useRef(null);
const toDropdownRef = useRef(null);
const ccDropdownRef = useRef(null);
const bccDropdownRef = useRef(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}
/>