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("not_available")}

) : ( +
+
+ +

{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} />