diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index cc3908cb..5ded7c39 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -27,8 +27,11 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan import { EventModal } from "@/components/calendar/event-modal"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { ICalImportModal } from "@/components/calendar/ical-import-modal"; +import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; import { NavigationRail } from "@/components/layout/navigation-rail"; +import { ResizeHandle } from "@/components/layout/resize-handle"; +import { cn } from "@/lib/utils"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import { getUserParticipantId } from "@/lib/calendar-participants"; import { debug } from "@/lib/debug"; @@ -45,13 +48,15 @@ export default function CalendarPage() { const router = useRouter(); const t = useTranslations("calendar"); const isMobile = useIsMobile(); - const { client, isAuthenticated, logout } = useAuthStore(); + const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { calendars, events, selectedDate, viewMode, selectedCalendarIds, isLoading, isLoadingEvents, supportsCalendar, error, fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent, - setSelectedDate, setViewMode, toggleCalendarVisibility, + setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, + refreshAllSubscriptions, } = useCalendarStore(); const { firstDayOfWeek, timeFormat } = useSettingsStore(); const { identities } = useIdentityStore(); @@ -63,6 +68,7 @@ export default function CalendarPage() { const [showEventModal, setShowEventModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); + const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [editEvent, setEditEvent] = useState(null); const [defaultModalDate, setDefaultModalDate] = useState(); const [defaultModalEndDate, setDefaultModalEndDate] = useState(); @@ -72,17 +78,31 @@ export default function CalendarPage() { const [detailAnchorRect, setDetailAnchorRect] = useState(null); const hasFetched = useRef(false); + // Sidebar resize state + const [calSidebarWidth, setCalSidebarWidth] = useState(() => { + try { const v = localStorage.getItem("calendar-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; } + }); + const [isResizing, setIsResizing] = useState(false); + const dragStartWidth = useRef(256); + // Swipe navigation ref (handlers defined after navigatePrev/navigateNext) const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null); + // Check auth on mount useEffect(() => { - if (!isAuthenticated) { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } router.push("/login"); - } else if (!supportsCalendar) { + } else if (client && !supportsCalendar) { router.push("/"); } - }, [isAuthenticated, supportsCalendar, router]); + }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]); useEffect(() => { if (error) { @@ -97,6 +117,16 @@ export default function CalendarPage() { } }, [client, fetchCalendars]); + // Auto-refresh iCal subscriptions + useEffect(() => { + if (!client) return; + // Refresh on mount (respects per-subscription interval) + refreshAllSubscriptions(client); + // Check again every 5 minutes + const interval = setInterval(() => refreshAllSubscriptions(client), 5 * 60 * 1000); + return () => clearInterval(interval); + }, [client, refreshAllSubscriptions]); + const dateRange = useMemo(() => { const d = selectedDate; switch (viewMode) { @@ -687,6 +717,7 @@ export default function CalendarPage() { onViewModeChange={setViewMode} onCreateEvent={() => openCreateModal()} onImport={() => setShowImportModal(true)} + onSubscribe={() => setShowSubscriptionModal(true)} isMobile={isMobile} calendars={calendars} selectedCalendarIds={selectedCalendarIds} @@ -699,21 +730,43 @@ export default function CalendarPage() { onTouchEnd={handleTouchEnd} > {!isMobile && ( -
- +
+ + { + updateCalendar(client, calendarId, { color }); + } : undefined} + onSubscribe={() => setShowSubscriptionModal(true)} + client={client} + /> +
+ { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }} + onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} + onResizeEnd={() => { + setIsResizing(false); + localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth)); + }} + onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }} /> - -
+ )} {renderView()} @@ -798,6 +851,13 @@ export default function CalendarPage() { /> )} + {showSubscriptionModal && client && ( + setShowSubscriptionModal(false)} + /> + )} + useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { contacts, @@ -68,7 +68,6 @@ export default function ContactsPage() { clearSelection, bulkDeleteContacts, bulkAddToGroup, - importContacts, } = useContactStore(); const [view, setView] = useState("list"); @@ -77,12 +76,26 @@ export default function ContactsPage() { const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const isMobile = useIsMobile(); + // Sidebar resize state + const [contactsSidebarWidth, setContactsSidebarWidth] = useState(() => { + try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; } + }); + const [isResizing, setIsResizing] = useState(false); + const dragStartWidth = useRef(256); + + // Check auth on mount useEffect(() => { - if (!isAuthenticated) { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } router.push("/login"); } - }, [isAuthenticated, router]); + }, [initialCheckDone, isAuthenticated, authLoading, router]); useEffect(() => { if (client && supportsSync && !hasFetched.current) { @@ -169,8 +182,6 @@ export default function ContactsPage() { const handleCancel = () => { if (view === "group-create" || view === "group-edit") { setView(selectedGroup ? "group-detail" : "list"); - } else if (view === "import") { - setView("list"); } else if (view === "bulk-add-to-group") { setView("list"); } else { @@ -218,11 +229,10 @@ export default function ContactsPage() { const jmapClient = supportsSync && client ? client : null; if (view === "group-edit" && selectedGroup) { await updateGroup(jmapClient, selectedGroup.id, name); - const currentMemberIds = selectedGroup.members - ? Object.keys(selectedGroup.members).filter(k => selectedGroup.members![k]) - : []; - const toAdd = memberIds.filter(id => !currentMemberIds.includes(id)); - const toRemove = currentMemberIds.filter(id => !memberIds.includes(id)); + // Use resolved member contact IDs for diff, not raw urn:uuid: keys + const currentIds = selectedGroupMembers.map(m => m.id); + const toAdd = memberIds.filter(id => !currentIds.includes(id)); + const toRemove = currentIds.filter(id => !memberIds.includes(id)); if (toAdd.length > 0) await addMembersToGroup(jmapClient, selectedGroup.id, toAdd); if (toRemove.length > 0) await removeMembersFromGroup(jmapClient, selectedGroup.id, toRemove); toast.success(t("toast.updated")); @@ -232,7 +242,7 @@ export default function ContactsPage() { toast.success(t("toast.created")); setView("list"); } - }, [view, selectedGroup, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]); + }, [view, selectedGroup, selectedGroupMembers, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]); const handleRemoveGroupMember = async (memberId: string) => { if (!selectedGroup) return; @@ -306,13 +316,6 @@ export default function ContactsPage() { } }; - const handleImport = useCallback(async (importedContacts: ContactCard[]) => { - return importContacts( - supportsSync && client ? client : null, - importedContacts - ); - }, [supportsSync, client, importContacts]); - if (!isAuthenticated) return null; const renderRightPanel = () => { @@ -369,15 +372,6 @@ export default function ContactsPage() { /> ); - case "import": - return ( - - ); - case "bulk-add-to-group": return (
@@ -456,49 +450,15 @@ export default function ContactsPage() {
{showListPanel && ( -
-
-
- -
- - -
-
-
- + <> +
+ {!isMobile && ( + { dragStartWidth.current = contactsSidebarWidth; setIsResizing(true); }} + onResize={(delta) => setContactsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} + onResizeEnd={() => { + setIsResizing(false); + localStorage.setItem("contacts-sidebar-width", String(contactsSidebarWidth)); + }} + onDoubleClick={() => { setContactsSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} + /> + )} + )} {showRightPanel && ( diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx new file mode 100644 index 00000000..2849b437 --- /dev/null +++ b/app/[locale]/files/page.tsx @@ -0,0 +1,463 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useRouter } from "@/i18n/navigation"; +import { useTranslations } from "next-intl"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { useFileStore } from "@/stores/file-store"; +import { toast } from "@/stores/toast-store"; +import { cn } from "@/lib/utils"; +import { NavigationRail } from "@/components/layout/navigation-rail"; +import { useIsMobile } from "@/hooks/use-media-query"; +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"; + +export default function FilesPage() { + const router = useRouter(); + const t = useTranslations("files"); + const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); + const { quota, isPushConnected } = useEmailStore(); + const { + currentPath, + resources, + isLoading, + error, + supportsFiles, + selectedResources, + uploadProgress, + clipboard, + initClient, + checkSupport, + navigate, + navigateByPath, + refresh, + createDirectory, + uploadFile, + uploadFiles, + uploadFolder, + deleteResource, + deleteResources, + renameResource, + downloadResource, + getImageUrl, + getFileContent, + createTextFile, + duplicateResource, + downloadResources, + moveToFolder, + moveToParent, + cutResources, + copyResources, + pasteResources, + selectResource, + toggleSelect, + selectAll, + clearSelection, + setSelection, + listPath, + listByParentId, + favorites, + recentFiles, + toggleFavorite, + addRecentFile, + cancelUpload, + undoLastAction, + lastAction, + } = useFileStore(); + + const isMobile = useIsMobile(); + const [folderLayout, setFolderLayout] = useState(() => loadFilesSettings().folderLayout); + const hasFetched = useRef(false); + + // Sync folderLayout when settings change + useEffect(() => { + const reload = () => setFolderLayout(loadFilesSettings().folderLayout); + const handleStorage = (e: StorageEvent) => { if (e.key === "files-settings") reload(); }; + window.addEventListener("storage", handleStorage); + window.addEventListener("files-settings-changed", reload); + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener("files-settings-changed", reload); + }; + }, []); + const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); + const [previewImage, setPreviewImage] = useState(null); + const [previewFile, setPreviewFile] = useState(null); + const [showDetails, setShowDetails] = useState(false); + const [detailName, setDetailName] = useState(null); + + const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null; + + // Check auth on mount + useEffect(() => { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + // Redirect if not authenticated + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { + try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } + router.push("/login"); + } + }, [initialCheckDone, isAuthenticated, authLoading, router]); + + // Initialize JMAP files client + useEffect(() => { + if (isAuthenticated && client && !hasFetched.current) { + hasFetched.current = true; + initClient(client); + } + }, [isAuthenticated, client, initClient]); + + // Check support and load root after client is initialized + const storeClient = useFileStore(s => s.client); + useEffect(() => { + if (storeClient && supportsFiles === null) { + checkSupport().then((supported) => { + if (supported) { + navigate(null); + } + }); + } + }, [storeClient, supportsFiles, checkSupport, navigate]); + + const handleNavigate = useCallback((path: string, resourceId?: string | null) => { + if (resourceId !== undefined) { + // Direct ID-based navigation (directory click, breadcrumb dropdown folder) + navigate(resourceId, path.split('/').pop() || ''); + } else { + // Path-based navigation (breadcrumbs, favorites, recent files) + navigateByPath(path); + } + }, [navigate, navigateByPath]); + + const handleCreateFolder = useCallback(async (name: string) => { + try { + await createDirectory(name); + toast.success(t("create_folder_success")); + } catch (err) { + console.error("Failed to create folder:", err); + toast.error(t("create_folder_error")); + } + }, [createDirectory, t]); + + const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB + + const handleUploadFiles = useCallback(async (files: File[]) => { + const oversized = files.filter(f => f.size > MAX_FILE_SIZE); + const valid = files.filter(f => f.size <= MAX_FILE_SIZE); + if (oversized.length > 0) { + toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" })); + } + if (valid.length === 0) return; + try { + await uploadFiles(valid); + toast.success(t("upload_success", { count: valid.length })); + } catch (err) { + console.error("Failed to upload files:", err); + toast.error(t("upload_error")); + } + }, [uploadFiles, t]); + + const handleUploadFolder = useCallback(async (files: File[]) => { + const oversized = files.filter(f => f.size > MAX_FILE_SIZE); + const valid = files.filter(f => f.size <= MAX_FILE_SIZE); + if (oversized.length > 0) { + toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" })); + } + if (valid.length === 0) return; + try { + await uploadFolder(valid); + toast.success(t("upload_success", { count: valid.length })); + } catch (err) { + console.error("Failed to upload folder:", err); + toast.error(t("upload_error")); + } + }, [uploadFolder, t]); + + const handleDelete = useCallback(async (name: string) => { + const confirmed = await confirmDialog({ + title: t("delete_confirm_title"), + message: t("delete_confirm_message", { name }), + confirmText: t("delete"), + variant: "destructive", + }); + if (!confirmed) return; + + try { + await deleteResource(name); + toast.success(t("delete_success")); + } catch (err) { + console.error("Failed to delete:", err); + toast.error(t("delete_error")); + } + }, [deleteResource, confirmDialog, t]); + + const handleBatchDelete = useCallback(async (names: string[]) => { + const confirmed = await confirmDialog({ + title: t("delete_confirm_title"), + message: t("batch_delete_confirm_message", { count: names.length }), + confirmText: t("delete"), + variant: "destructive", + }); + if (!confirmed) return; + + try { + await deleteResources(names); + toast.success(t("batch_delete_success", { count: names.length })); + } catch (err) { + console.error("Failed to batch delete:", err); + toast.error(t("delete_error")); + } + }, [deleteResources, confirmDialog, t]); + + const handleUndo = useCallback(async () => { + try { + await undoLastAction(); + toast.success(t("undo_success")); + } catch (err) { + console.error("Failed to undo:", err); + toast.error(t("undo_error")); + } + }, [undoLastAction, t]); + + const handleRename = useCallback(async (oldName: string, newName: string) => { + try { + await renameResource(oldName, newName); + toast.success(t("rename_success"), { + action: { label: t("undo"), onClick: handleUndo }, + }); + } catch (err) { + console.error("Failed to rename:", err); + toast.error(t("rename_error")); + } + }, [renameResource, t, handleUndo]); + + const findResourceId = useCallback((name: string) => { + const r = resources.find(res => res.name === name); + return r?.id || name; + }, [resources]); + + const handleDownload = useCallback(async (name: string) => { + try { + await downloadResource(name); + addRecentFile(name, findResourceId(name)); + } catch (err) { + console.error("Failed to download:", err); + toast.error(t("download_error")); + } + }, [downloadResource, addRecentFile, findResourceId, t]); + + const handleBatchDownload = useCallback(async (names: string[]) => { + try { + await downloadResources(names); + } catch (err) { + console.error("Failed to batch download:", err); + toast.error(t("download_error")); + } + }, [downloadResources, t]); + + const handleCreateTextFile = useCallback(async (name: string) => { + try { + await createTextFile(name); + toast.success(t("create_file_success")); + } catch (err) { + console.error("Failed to create file:", err); + toast.error(t("create_file_error")); + } + }, [createTextFile, t]); + + const handleDuplicate = useCallback(async (name: string) => { + try { + await duplicateResource(name); + toast.success(t("duplicate_success")); + } catch (err) { + console.error("Failed to duplicate:", err); + toast.error(t("duplicate_error")); + } + }, [duplicateResource, t]); + + const handleMoveToFolder = useCallback(async (names: string[], targetFolder: string) => { + try { + await moveToFolder(names, targetFolder); + toast.success(t("move_success", { count: names.length }), { + action: { label: t("undo"), onClick: handleUndo }, + }); + } catch (err) { + console.error("Failed to move:", err); + toast.error(t("move_error")); + } + }, [moveToFolder, t, handleUndo]); + + const handleMoveToParent = useCallback(async (names: string[]) => { + try { + await moveToParent(names); + toast.success(t("move_success", { count: names.length }), { + action: { label: t("undo"), onClick: handleUndo }, + }); + } catch (err) { + console.error("Failed to move:", err); + toast.error(t("move_error")); + } + }, [moveToParent, t, handleUndo]); + + const handlePaste = useCallback(async () => { + try { + await pasteResources(); + toast.success(t("paste_success"), { + action: lastAction ? { label: t("undo"), onClick: handleUndo } : undefined, + }); + } catch (err) { + console.error("Failed to paste:", err); + toast.error(t("paste_error")); + } + }, [pasteResources, t, lastAction, handleUndo]); + + const handlePreviewImage = useCallback((name: string) => { + setPreviewImage(name); + addRecentFile(name, findResourceId(name)); + }, [addRecentFile, findResourceId]); + + const handlePreviewFile = useCallback((name: string) => { + setPreviewFile(name); + addRecentFile(name, findResourceId(name)); + }, [addRecentFile, findResourceId]); + + const handleShowDetails = useCallback((name: string) => { + setDetailName(name); + setShowDetails(true); + }, []); + + const handleToggleDetails = useCallback(() => { + setShowDetails(v => !v); + }, []); + + if (!isAuthenticated) return null; + + return ( +
+ {!isMobile && ( +
+ { logout(); router.push('/login'); }} + /> +
+ )} + +
+
+
+ {folderLayout !== "sidebar" && ( +
+
+ +
+
+ )} + +
+ {supportsFiles === false ? ( +
+

{t("not_available")}

+
+ ) : ( + + )} +
+
+
+ + {isMobile && ( + + )} +
+ + {/* Image preview modal */} + {previewImage && ( + setPreviewImage(null)} + onDownload={handleDownload} + getImageUrl={getImageUrl} + /> + )} + + {/* File preview modal (text, PDF, audio, video, markdown) */} + {previewFile && ( + setPreviewFile(null)} + onDownload={handleDownload} + getFileContent={getFileContent} + /> + )} + + +
+ ); +} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 8512dbbc..69d57021 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -109,6 +109,7 @@ export default function Home() { selectKeyword, hasMoreEmails, fetchTagCounts, + fetchEmailContent, } = useEmailStore(); // Keyboard shortcuts handlers @@ -336,6 +337,19 @@ export default function Home() { }; }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]); + // Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive) + useEffect(() => { + if (!selectedEmail || !client) return; + // If the email lacks bodyValues, it was auto-selected from the list and needs full content + if (!selectedEmail.bodyValues) { + setLoadingEmail(true); + fetchEmailContent(client, selectedEmail.id).finally(() => { + setLoadingEmail(false); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedEmail?.id]); + // Handle mark-as-read with delay based on settings useEffect(() => { // Clear any existing timeout when email changes @@ -409,6 +423,7 @@ export default function Home() { bcc: string[]; subject: string; body: string; + htmlBody?: string; draftId?: string; fromEmail?: string; fromName?: string; @@ -417,7 +432,7 @@ export default function Home() { if (!client) return; try { - await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName); + await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody); setShowComposer(false); // Refresh the current mailbox to update the UI @@ -459,12 +474,14 @@ export default function Home() { const handleDelete = async () => { if (!client || !selectedEmail) return; - // Check if we're currently in the trash folder + // Check if we're currently in the trash or junk folder const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const isInTrash = currentMailbox?.role === 'trash'; + const isInJunk = currentMailbox?.role === 'junk'; + const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; - if (isInTrash) { - // In trash: confirm before permanently deleting + if (isInTrash || (isInJunk && permanentlyDeleteJunk)) { + // In trash or junk with permanent delete enabled: confirm before permanently deleting const confirmed = await confirmDialog({ title: t('email_list.permanent_delete_confirm_title'), message: t('email_list.permanent_delete_confirm_message'), @@ -643,6 +660,42 @@ export default function Home() { } }; + const handleUnreadFilterClick = async (mailboxId: string) => { + const isTogglingOff = selectedMailbox === mailboxId && searchFilters.isUnread === true; + + // Select the mailbox if not already selected + if (selectedMailbox !== mailboxId) { + selectMailbox(mailboxId); + selectEmail(null); + } + + // On mobile, close sidebar and go to list view + if (isMobile) { + setSidebarOpen(false); + setActiveView("list"); + } + + // On tablet, show the list again + if (isTablet) { + setTabletListVisible(true); + } + + if (isTogglingOff) { + // Disable the unread filter and show all emails + clearSearchFilters(); + if (client) { + await fetchEmails(client, mailboxId); + } + } else { + // Enable unread filter + clearSearchFilters(); + setSearchFilters({ isUnread: true }); + if (client) { + await advancedSearch(client); + } + } + }; + const handleLogout = () => { logout(); router.push('/login'); @@ -709,12 +762,18 @@ export default function Home() { const primaryIdentity = identities[0]; + // Append signature from the primary identity + let finalBody = body; + if (primaryIdentity?.textSignature) { + finalBody = body + '\n\n-- \n' + primaryIdentity.textSignature; + } + // Send reply with just the body text await sendEmail( client, [sender.email], `Re: ${selectedEmail.subject || "(no subject)"}`, - body, + finalBody, undefined, undefined, primaryIdentity?.id, @@ -794,6 +853,17 @@ export default function Home() { setActiveView("list"); }; + // Navigate to next/previous email in the list + const selectedEmailIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1; + + const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < emails.length - 1 + ? () => handleEmailSelect(emails[selectedEmailIndex + 1]) + : undefined; + + const handleNavigatePrev = selectedEmailIndex > 0 + ? () => handleEmailSelect(emails[selectedEmailIndex - 1]) + : undefined; + // Handle opening conversation view on mobile const handleOpenConversation = async (thread: ThreadGroup) => { if (!client) return; @@ -904,6 +974,7 @@ export default function Home() { selectedKeyword={selectedKeyword} onMailboxSelect={handleMailboxSelect} onTagSelect={handleTagSelect} + onUnreadFilterClick={handleUnreadFilterClick} onCompose={() => { setComposerMode('compose'); setShowComposer(true); @@ -1279,6 +1350,7 @@ export default function Home() { cc: selectedEmail.cc, subject: selectedEmail.subject, body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', + htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, receivedAt: selectedEmail.receivedAt } : undefined)} initialDraftText={composerDraftText} @@ -1389,10 +1461,19 @@ export default function Home() { setTabletListVisible(true); selectEmail(null); }} + onNavigateNext={handleNavigateNext} + onNavigatePrev={handleNavigatePrev} onShowShortcuts={() => setShowShortcutsModal(true)} currentUserEmail={client?.["username"]} currentUserName={client?.["username"]?.split("@")[0]} currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} + mailboxes={mailboxes} + selectedMailbox={selectedMailbox} + onMoveToMailbox={async (mailboxId) => { + if (client && selectedEmail) { + await moveToMailbox(client, selectedEmail.id, mailboxId); + } + }} className={isMobile ? "flex-1" : undefined} /> diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 410ed688..11a1dfc5 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -1,9 +1,29 @@ "use client"; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useRouter } from '@/i18n/navigation'; import { useTranslations } from 'next-intl'; -import { ArrowLeft, ChevronRight, LogOut, Settings as SettingsIcon } from 'lucide-react'; +import { + ArrowLeft, + ChevronRight, + LogOut, + Settings as SettingsIcon, + Palette, + Mail, + User, + Shield, + UserPen, + PalmtreeIcon, + Calendar, + Filter, + FileText, + FolderOpen, + Tags, + HardDrive, + Wrench, + BookUser, + type LucideIcon, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { AppearanceSettings } from '@/components/settings/appearance-settings'; import { EmailSettings } from '@/components/settings/email-settings'; @@ -11,38 +31,90 @@ import { AccountSettings } from '@/components/settings/account-settings'; import { IdentitySettings } from '@/components/settings/identity-settings'; import { VacationSettings } from '@/components/settings/vacation-settings'; import { CalendarSettings } from '@/components/settings/calendar-settings'; +import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings'; import { FilterSettings } from '@/components/settings/filter-settings'; import { TemplateSettings } from '@/components/settings/template-settings'; import { AdvancedSettings } from '@/components/settings/advanced-settings'; import { FolderSettings } from '@/components/settings/folder-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings'; import { AccountSecuritySettings } from '@/components/settings/account-security-settings'; +import { FilesSettingsComponent } from '@/components/settings/files-settings'; +import { ContactsSettings } from '@/components/settings/contacts-settings'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { useIsDesktop } from '@/hooks/use-media-query'; import { NavigationRail } from '@/components/layout/navigation-rail'; +import { ResizeHandle } from '@/components/layout/resize-handle'; import { useConfig } from '@/hooks/use-config'; import { cn } from '@/lib/utils'; -type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'advanced'; +type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced'; +type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system'; + +interface TabDef { + id: Tab; + label: string; + icon: LucideIcon; + group: TabGroup; +} + +const tabIcons: Record = { + appearance: Palette, + email: Mail, + account: User, + security: Shield, + identities: UserPen, + vacation: PalmtreeIcon, + calendar: Calendar, + contacts: BookUser, + filters: Filter, + templates: FileText, + folders: FolderOpen, + keywords: Tags, + files: HardDrive, + advanced: Wrench, +}; + +const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system']; export default function SettingsPage() { const router = useRouter(); const t = useTranslations('settings'); const tSidebar = useTranslations('sidebar'); - const { client, isAuthenticated, logout } = useAuthStore(); + const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { stalwartFeaturesEnabled } = useConfig(); - const [activeTab, setActiveTab] = useState('appearance'); + const [activeTab, setActiveTab] = useState(() => { + try { + const saved = localStorage.getItem('settings-active-tab'); + if (saved) return saved as Tab; + } catch { /* ignore */ } + return 'appearance'; + }); const [mobileShowContent, setMobileShowContent] = useState(false); const isDesktop = useIsDesktop(); + // Sidebar resize state + const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => { + try { const v = localStorage.getItem('settings-sidebar-width'); return v ? Number(v) : 256; } catch { return 256; } + }); + const [isResizing, setIsResizing] = useState(false); + const dragStartWidth = useRef(256); + + // Check auth on mount useEffect(() => { - if (!isAuthenticated) { + checkAuth().finally(() => { + setInitialCheckDone(true); + }); + }, [checkAuth]); + + useEffect(() => { + if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } router.push('/login'); } - }, [isAuthenticated, router]); + }, [initialCheckDone, isAuthenticated, authLoading, router]); if (!isAuthenticated) { return null; @@ -51,24 +123,37 @@ export default function SettingsPage() { const supportsVacation = client?.supportsVacationResponse() ?? false; const supportsCalendar = client?.supportsCalendars() ?? false; const supportsSieve = client?.supportsSieve() ?? false; + const supportsFiles = client?.supportsFiles() ?? false; - const tabs: { id: Tab; label: string }[] = [ - { id: 'appearance', label: t('tabs.appearance') }, - { id: 'email', label: t('tabs.email') }, - { id: 'account', label: t('tabs.account') }, - ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security') }] : []), - { id: 'identities', label: t('tabs.identities') }, - ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []), - ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []), - ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters') }] : []), - { id: 'templates', label: t('tabs.templates') }, - { id: 'folders', label: t('tabs.folders') }, - { id: 'keywords', label: t('tabs.keywords') }, - { id: 'advanced', label: t('tabs.advanced') }, + const tabs: TabDef[] = [ + { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' }, + { id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' }, + { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' }, + ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []), + { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' }, + ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []), + ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []), + { id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' }, + { id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' }, + { id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' }, + ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []), + { id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' }, + ...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), + { id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' }, ]; + // Group tabs by category + const groupedTabs = tabGroupOrder + .map((group) => ({ + group, + label: t(`tab_groups.${group}`), + items: tabs.filter((tab) => tab.group === group), + })) + .filter((g) => g.items.length > 0); + const handleTabSelect = (tabId: Tab) => { setActiveTab(tabId); + try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ } if (!isDesktop) { setMobileShowContent(true); } @@ -84,11 +169,13 @@ export default function SettingsPage() { {activeTab === 'security' && } {activeTab === 'identities' && } {activeTab === 'vacation' && } - {activeTab === 'calendar' && } + {activeTab === 'calendar' && <>
} + {activeTab === 'contacts' && } {activeTab === 'filters' && } {activeTab === 'templates' && } {activeTab === 'folders' && } {activeTab === 'keywords' && } + {activeTab === 'files' && } {activeTab === 'advanced' && } ); @@ -147,15 +234,31 @@ export default function SettingsPage() { {/* Tab list */}
- {tabs.map((tab) => ( - + {groupedTabs.map((group, groupIndex) => ( +
+ {groupIndex > 0 &&
} +
+ + {group.label} + +
+ {group.items.map((tab) => { + const Icon = tab.icon; + return ( + + ); + })} +
))}
@@ -191,7 +294,13 @@ export default function SettingsPage() {
{/* Settings Sidebar */} -
+
{/* Header */}
+
+ {groupedTabs.map((group, groupIndex) => ( +
+ {groupIndex > 0 &&
} +
+ + {group.label} + +
+ {group.items.map((tab) => { + const Icon = tab.icon; + return ( + + ); + })} +
))}
+ {/* Sidebar resize handle */} + { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }} + onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} + onResizeEnd={() => { + setIsResizing(false); + localStorage.setItem('settings-sidebar-width', String(settingsSidebarWidth)); + }} + onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }} + /> + {/* Settings Content */}
diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index d44f2c55..4bf44859 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -746,7 +746,7 @@ const addressBooks = [ const contacts = [ // --- Personal address book --- - { id: 'contact-001', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Sophie' }, { kind: 'surname', value: 'Müller' }] }, emails: { e1: { address: 'sophie@eurotech.example' } }, phones: { p1: { number: '+49 30 8844 2200' } }, @@ -754,7 +754,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } }, notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } }, }, - { id: 'contact-002', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] }, emails: { e1: { address: 'pierre@dubois.example' } }, phones: { p1: { number: '+33 1 42 68 53 00' } }, @@ -762,7 +762,7 @@ const contacts = [ addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } }, notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } }, }, - { id: 'contact-003', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] }, emails: { e1: { address: 'chiara@rossi.example' } }, phones: { p1: { number: '+39 02 7634 5678' } }, @@ -770,14 +770,14 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } }, notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } }, }, - { id: 'contact-004', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] }, emails: { e1: { address: 'karel@devries.example' } }, phones: { p1: { number: '+31 20 555 0142' } }, addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } }, notes: { n1: { note: 'Backend developer. Cycles to work rain or shine — true Dutchman.' } }, }, - { id: 'contact-005', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] }, emails: { e1: { address: 'lars.johansson@fjord-systems.example' } }, phones: { p1: { number: '+46 8 123 456 78' } }, @@ -785,7 +785,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } }, notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } }, }, - { id: 'contact-006', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] }, emails: { e1: { address: 'elise.moreau@fjord-systems.example' } }, phones: { p1: { number: '+33 6 12 34 56 78' } }, @@ -793,14 +793,14 @@ const contacts = [ addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } }, notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } }, }, - { id: 'contact-007', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] }, emails: { e1: { address: 'francesco@bianchi.example' } }, phones: { p1: { number: '+39 06 9876 5432' } }, addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } }, notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } }, }, - { id: 'contact-008', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] }, emails: { e1: { address: 'astrid@berglabs.example' } }, phones: { p1: { number: '+31 70 362 4242' } }, @@ -808,7 +808,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } }, notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } }, }, - { id: 'contact-009', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] }, emails: { e1: { address: 'henrik@nielsen-konsult.example' } }, phones: { p1: { number: '+45 33 42 42 42' } }, @@ -816,7 +816,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } }, notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } }, }, - { id: 'contact-010', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] }, emails: { e1: { address: 'isabelle.martin@sorbonne.example' } }, phones: { p1: { number: '+33 1 44 27 42 42' } }, @@ -825,7 +825,7 @@ const contacts = [ notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } }, }, // --- Work address book --- - { id: 'contact-011', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Jacques' }, { kind: 'surname', value: 'Lefèvre' }] }, emails: { e1: { address: 'jacques@lefevre-avocats.example' } }, phones: { p1: { number: '+33 1 53 67 42 00' } }, @@ -833,7 +833,7 @@ const contacts = [ addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } }, notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } }, }, - { id: 'contact-012', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] }, emails: { e1: { address: 'katrin.bauer@charite.example' } }, phones: { p1: { number: '+49 30 450 570 000' } }, @@ -841,7 +841,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } }, notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } }, }, - { id: 'contact-013', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] }, emails: { e1: { address: 'liam.odonaill@finanz.example' } }, phones: { p1: { number: '+353 1 677 4242' } }, @@ -849,7 +849,7 @@ const contacts = [ addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } }, notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } }, }, - { id: 'contact-014', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] }, emails: { e1: { address: 'maria@garcia-design.example' } }, phones: { p1: { number: '+34 91 420 4242' } }, @@ -857,7 +857,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } }, notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } }, }, - { id: 'contact-015', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] }, emails: { e1: { address: 'nils@digitaal.example' } }, phones: { p1: { number: '+31 20 624 1337' } }, @@ -865,7 +865,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } }, notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } }, }, - { id: 'contact-016', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] }, emails: { e1: { address: 'olivia@kowalska-marketing.example' } }, phones: { p1: { number: '+48 22 505 4242' } }, @@ -873,7 +873,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } }, notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } }, }, - { id: 'contact-017', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] }, emails: { e1: { address: 'padraig@murphy-bau.example' } }, phones: { p1: { number: '+353 86 123 4242' } }, @@ -881,7 +881,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } }, notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } }, }, - { id: 'contact-018', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] }, emails: { e1: { address: 'raquel@ferreira-media.example' } }, phones: { p1: { number: '+351 21 342 4242' } }, @@ -889,7 +889,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } }, notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } }, }, - { id: 'contact-019', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] }, emails: { e1: { address: 'sebastien@dumont-conseil.example' } }, phones: { p1: { number: '+32 2 555 4242' } }, @@ -897,7 +897,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } }, notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } }, }, - { id: 'contact-020', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] }, emails: { e1: { address: 'annika@lindgren.example' }, e2: { address: 'annika.personal@proton.example' } }, phones: { p1: { number: '+46 70 123 4242' } }, @@ -906,6 +906,22 @@ const contacts = [ nicknames: { n1: { name: 'Anni' } }, notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } }, }, + // --- Groups --- + { id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const, + uid: 'urn:uuid:g0000001-0000-0000-0000-000000000001', + name: { components: [{ kind: 'given' as const, value: 'Fjord Systems Team' }], isOrdered: true }, + members: { 'urn:uuid:c0000005-0000-0000-0000-000000000005': true, 'urn:uuid:c0000006-0000-0000-0000-000000000006': true }, + }, + { id: 'contact-group-002', addressBookIds: { 'ab-1': true }, kind: 'group' as const, + uid: 'urn:uuid:g0000002-0000-0000-0000-000000000002', + name: { components: [{ kind: 'given' as const, value: 'Design Friends' }], isOrdered: true }, + members: { 'urn:uuid:c0000003-0000-0000-0000-000000000003': true, 'urn:uuid:c0000007-0000-0000-0000-000000000007': true }, + }, + { id: 'contact-group-003', addressBookIds: { 'ab-2': true }, kind: 'group' as const, + uid: 'urn:uuid:g0000003-0000-0000-0000-000000000003', + name: { components: [{ kind: 'given' as const, value: 'Legal & Finance' }], isOrdered: true }, + members: { 'urn:uuid:c0000011-0000-0000-0000-000000000011': true, 'urn:uuid:c0000013-0000-0000-0000-000000000013': true }, + }, ]; // --------------------------------------------------------------------------- @@ -1602,7 +1618,50 @@ const METHOD_HANDLERS: Record Meth 'VacationResponse/get': handleVacationResponseGet, 'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId], 'ContactCard/get': handleContactCardGet, - 'ContactCard/set': (_args, callId) => ['ContactCard/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], + 'ContactCard/set': (args, callId) => { + const created: Record = {}; + const updated: Record = {}; + const destroyed: string[] = []; + + if (args.create) { + for (const [tempId, data] of Object.entries(args.create as Record>)) { + const newId = `contact-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const newUid = `urn:uuid:${crypto.randomUUID()}`; + const newContact = { id: newId, uid: newUid, ...data, addressBookIds: data.addressBookIds || { 'ab-1': true } }; + contacts.push(newContact as typeof contacts[number]); + created[tempId] = { id: newId, uid: newUid }; + } + } + + if (args.update) { + for (const [id, patches] of Object.entries(args.update as Record>)) { + const idx = contacts.findIndex(c => c.id === id); + if (idx !== -1) { + contacts[idx] = { ...contacts[idx], ...patches } as typeof contacts[number]; + updated[id] = null; + } + } + } + + if (args.destroy) { + for (const id of args.destroy as string[]) { + const idx = contacts.findIndex(c => c.id === id); + if (idx !== -1) { + contacts.splice(idx, 1); + destroyed.push(id); + } + } + } + + return ['ContactCard/set', { + accountId: ACCOUNT_ID, + oldState: nextState(), + newState: nextState(), + created: Object.keys(created).length > 0 ? created : null, + updated: Object.keys(updated).length > 0 ? updated : null, + destroyed: destroyed.length > 0 ? destroyed : null, + }, callId]; + }, 'ContactCard/query': (_args, callId) => ['ContactCard/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: contacts.map(c => c.id), total: contacts.length, position: 0 }, callId], 'AddressBook/get': handleAddressBookGet, 'Calendar/get': handleCalendarGet, diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts new file mode 100644 index 00000000..40c1aac9 --- /dev/null +++ b/app/api/fetch-ical/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB +const FETCH_TIMEOUT_MS = 15000; + +function isValidExternalUrl(urlString: string): boolean { + let url: URL; + try { + url = new URL(urlString); + } catch { + return false; + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return false; + } + + const hostname = url.hostname.toLowerCase(); + + // Block private/internal hostnames + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname === '0.0.0.0' || + 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) + ) { + return false; + } + + // Block URLs with credentials + if (url.username || url.password) { + return false; + } + + return true; +} + +export async function POST(request: NextRequest) { + let body: { url?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + const { url } = body; + + if (!url || typeof url !== 'string') { + return NextResponse.json({ error: 'URL is required' }, { status: 400 }); + } + + if (!isValidExternalUrl(url)) { + return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 }); + } + + try { + 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', + }); + + clearTimeout(timeout); + + if (!response.ok) { + return NextResponse.json( + { error: `Remote server returned ${response.status}` }, + { status: 502 } + ); + } + + const contentLength = response.headers.get('content-length'); + if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { + return NextResponse.json({ error: 'File too large' }, { status: 413 }); + } + + const buffer = await response.arrayBuffer(); + if (buffer.byteLength > MAX_RESPONSE_SIZE) { + return NextResponse.json({ error: 'File too large' }, { status: 413 }); + } + + return new NextResponse(buffer, { + status: 200, + headers: { + 'Content-Type': 'text/calendar', + 'Content-Length': buffer.byteLength.toString(), + }, + }); + } catch (error: unknown) { + if (error instanceof Error && error.name === 'AbortError') { + return NextResponse.json({ error: 'Request timed out' }, { status: 504 }); + } + return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 }); + } +} diff --git a/app/api/webdav/route.ts b/app/api/webdav/route.ts new file mode 100644 index 00000000..9bda6c1a --- /dev/null +++ b/app/api/webdav/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; + +const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']); + +/** + * POST /api/webdav + * Proxies WebDAV requests to the Stalwart server. + * + * Headers: + * X-WebDAV-Method: The actual WebDAV method (PROPFIND, MKCOL, GET, PUT, DELETE, MOVE, COPY) + * X-WebDAV-Path: Resource path relative to the user's DAV root (default: /) + * X-WebDAV-Destination: Destination path for MOVE/COPY (relative to user's DAV root) + * Depth: WebDAV Depth header (forwarded as-is) + * Content-Type: Forwarded for PROPFIND (XML) and PUT (file upload) + * Overwrite: WebDAV Overwrite header for MOVE/COPY + */ +export async function POST(request: NextRequest) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const method = request.headers.get('X-WebDAV-Method')?.toUpperCase(); + if (!method || !ALLOWED_METHODS.has(method)) { + return NextResponse.json({ error: 'Invalid WebDAV method' }, { status: 400 }); + } + + 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)}/`; + + // Build headers for the upstream request + const upstreamHeaders: Record = { + 'Authorization': creds.authHeader, + }; + + // Forward relevant WebDAV headers + const depth = request.headers.get('Depth'); + if (depth) upstreamHeaders['Depth'] = depth; + + const contentType = request.headers.get('Content-Type'); + if (contentType) upstreamHeaders['Content-Type'] = contentType; + + // 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)}/`; + } + + const overwrite = request.headers.get('Overwrite'); + if (overwrite) upstreamHeaders['Overwrite'] = overwrite; + + // Forward request body for methods that need it + let body: ArrayBuffer | null = null; + if (method === 'PROPFIND' || method === 'PUT') { + body = await request.arrayBuffer(); + } + + const response = await fetch(targetUrl, { + method, + headers: upstreamHeaders, + body, + redirect: 'follow', + }); + + // For file downloads (GET), stream the response back + if (method === 'GET') { + const headers = new Headers(); + headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream'); + const contentLength = response.headers.get('Content-Length'); + if (contentLength) headers.set('Content-Length', contentLength); + headers.set('X-WebDAV-Request-URI', targetUrl); + + return new NextResponse(response.body, { + status: response.status, + headers, + }); + } + + // For PROPFIND, return XML with the actual request URI for href comparison + if (method === 'PROPFIND') { + const text = await response.text(); + const headers = new Headers(); + headers.set('Content-Type', 'application/xml; charset=utf-8'); + headers.set('X-WebDAV-Request-URI', targetUrl); + + return new NextResponse(text, { + status: response.status, + headers, + }); + } + + // For other methods (MKCOL, DELETE, MOVE, COPY, PUT), return the status + return new NextResponse(null, { + status: response.status, + }); + } catch (error) { + logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/globals.css b/app/globals.css index 59b6d2db..f6649424 100644 --- a/app/globals.css +++ b/app/globals.css @@ -242,6 +242,26 @@ body { list-style-type: decimal; } +/* Reply quoted HTML - preserves original inline styles/colors */ +.email-reply-quote { + overflow-wrap: break-word; + word-wrap: break-word; + max-width: none; +} + +.email-reply-quote p { + margin: 0.5rem 0; +} + +.email-reply-quote img { + max-width: 100%; + height: auto; +} + +.email-reply-quote a { + text-decoration: underline; +} + /* Only style tables that are actual data tables, not layout tables */ .email-content table.data-table, .email-content table[border="1"] { diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 3dd998b1..07ad8479 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -1,23 +1,99 @@ "use client"; +import { useState, useRef, useEffect } from "react"; import { useTranslations } from "next-intl"; +import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react"; import { cn } from "@/lib/utils"; import type { Calendar } from "@/lib/jmap/types"; +import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { toast } from "@/stores/toast-store"; +import type { JMAPClient } from "@/lib/jmap/client"; interface CalendarSidebarPanelProps { calendars: Calendar[]; selectedCalendarIds: string[]; onToggleVisibility: (id: string) => void; + onColorChange?: (calendarId: string, color: string) => void; + onSubscribe?: () => void; + client?: JMAPClient | null; } export function CalendarSidebarPanel({ calendars, selectedCalendarIds, onToggleVisibility, + onColorChange, + onSubscribe, + client, }: CalendarSidebarPanelProps) { const t = useTranslations("calendar"); + const tSub = useTranslations("calendar.subscription"); + const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar); + const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions); + const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); + const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription); - if (calendars.length === 0) return null; + const [colorPickerId, setColorPickerId] = useState(null); + const [contextMenuCalId, setContextMenuCalId] = useState(null); + const [refreshingSubId, setRefreshingSubId] = useState(null); + const colorPickerRef = useRef(null); + const contextMenuRef = useRef(null); + + useEffect(() => { + if (!colorPickerId && !contextMenuCalId) return; + const handleClick = (e: MouseEvent) => { + if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) { + setColorPickerId(null); + } + if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { + setContextMenuCalId(null); + } + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setColorPickerId(null); + setContextMenuCalId(null); + } + }; + document.addEventListener('mousedown', handleClick); + document.addEventListener('keydown', handleKey); + return () => { + document.removeEventListener('mousedown', handleClick); + document.removeEventListener('keydown', handleKey); + }; + }, [colorPickerId, contextMenuCalId]); + + const getSubscriptionForCalendar = (calendarId: string) => { + return icalSubscriptions.find(s => s.calendarId === calendarId); + }; + + const handleRefreshSubscription = async (subId: string) => { + if (!client) return; + setRefreshingSubId(subId); + setContextMenuCalId(null); + try { + await refreshICalSubscription(client, subId); + toast.success(tSub('refresh_success')); + } catch { + toast.error(tSub('refresh_error')); + } finally { + setRefreshingSubId(null); + } + }; + + const handleUnsubscribe = async (subId: string) => { + if (!client) return; + setContextMenuCalId(null); + try { + await removeICalSubscription(client, subId); + toast.success(tSub('deleted')); + } catch { + toast.error(tSub('delete_error')); + } + }; + + if (calendars.length === 0 && !onSubscribe) return null; return (
@@ -30,25 +106,94 @@ export function CalendarSidebarPanel({ const color = cal.color || "#3b82f6"; return ( - + > + + + {cal.name} + + {isSubscriptionCalendar(cal.id) && ( + <> + + {refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && ( + + )} + + )} + + + {/* Subscription context menu on right-click */} + {contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => { + const sub = getSubscriptionForCalendar(cal.id); + if (!sub) return null; + return ( +
+ + + {sub.lastRefreshed && ( +
+ {tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })} +
+ )} +
+ ); + })()} + + {/* Color picker popover on right-click */} + {colorPickerId === cal.id && onColorChange && ( +
+

{t("management.change_color")}

+ { + onColorChange(cal.id, c); + setColorPickerId(null); + }} + allowCustom + /> +
+ )} +
); })}
diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 8872b34f..c199773d 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect } from "react"; import { useTranslations, useFormatter } from "next-intl"; import { Button } from "@/components/ui/button"; -import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown } from "lucide-react"; import { addDays, startOfWeek } from "date-fns"; import { cn } from "@/lib/utils"; import type { CalendarViewMode } from "@/stores/calendar-store"; @@ -18,6 +18,7 @@ interface CalendarToolbarProps { onViewModeChange: (mode: CalendarViewMode) => void; onCreateEvent: () => void; onImport?: () => void; + onSubscribe?: () => void; isMobile?: boolean; firstDayOfWeek?: number; onNavigateBack?: () => void; @@ -35,6 +36,7 @@ export function CalendarToolbar({ onViewModeChange, onCreateEvent, onImport, + onSubscribe, isMobile, firstDayOfWeek = 1, calendars, @@ -87,9 +89,22 @@ export function CalendarToolbar({ } }; + const [showImportDropdown, setShowImportDropdown] = useState(false); + const importDropdownRef = useRef(null); const [showViewDropdown, setShowViewDropdown] = useState(false); const viewDropdownRef = useRef(null); + useEffect(() => { + if (!showImportDropdown) return; + function handleClickOutside(e: MouseEvent) { + if (importDropdownRef.current && !importDropdownRef.current.contains(e.target as Node)) { + setShowImportDropdown(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showImportDropdown]); + useEffect(() => { if (!showViewDropdown) return; function handleClickOutside(e: MouseEvent) { @@ -219,11 +234,36 @@ export function CalendarToolbar({
)} - {onImport && !isMobile && ( - + {(onImport || onSubscribe) && !isMobile && ( +
+ + {showImportDropdown && ( +
+ {onImport && ( + + )} + {onSubscribe && ( + + )} +
+ )} +
)} {!isMobile && ( diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 851b8d87..f0574793 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -30,7 +30,8 @@ function getEventColor(event: CalendarEvent, calendar?: Calendar): string { return sanitizeColor(event.color, sanitizeColor(calendar?.color)); } -function parseDuration(duration: string): number { +function parseDuration(duration: string | undefined): number { + if (!duration) return 0; let totalMinutes = 0; const weekMatch = duration.match(/(\d+)W/); const hourMatch = duration.match(/(\d+)H/); diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index fae028a0..3ec240ed 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; -import { X, Upload, Check, Loader2, RefreshCw } from "lucide-react"; +import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react"; import { format, parseISO } from "date-fns"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { JMAPClient } from "@/lib/jmap/client"; @@ -20,6 +20,7 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; type ImportStep = "select" | "preview" | "importing"; +type ImportMode = "file" | "url"; export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) { const t = useTranslations("calendar.import"); @@ -38,6 +39,9 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP const [isParsing, setIsParsing] = useState(false); const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); + const [importMode, setImportMode] = useState("file"); + const [urlInput, setUrlInput] = useState(""); + const [isFetchingUrl, setIsFetchingUrl] = useState(false); const fileInputRef = useRef(null); const modalRef = useRef(null); @@ -103,6 +107,57 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP if (file) handleFile(file); }, [handleFile]); + const handleUrlFetch = useCallback(async () => { + const trimmed = urlInput.trim(); + if (!trimmed) return; + + try { + new URL(trimmed); + } catch { + setError(t("invalid_url")); + return; + } + + setError(null); + setIsFetchingUrl(true); + setIsParsing(true); + + try { + const response = await fetch("/api/fetch-ical", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: trimmed }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || t("url_fetch_failed")); + } + + const blob = await response.blob(); + const file = new File([blob], "calendar.ics", { type: "text/calendar" }); + const uploaded = await client.uploadBlob(file); + const accountId = client.getCalendarsAccountId(); + const events = await client.parseCalendarEvents(accountId, uploaded.blobId); + + if (events.length === 0) { + setError(t("no_events")); + setIsFetchingUrl(false); + setIsParsing(false); + return; + } + + setParsedEvents(events); + setSelectedIndices(new Set(events.map((_, i) => i))); + setStep("preview"); + } catch (err) { + setError(err instanceof Error ? err.message : t("url_fetch_failed")); + } finally { + setIsFetchingUrl(false); + setIsParsing(false); + } + }, [urlInput, client, t]); + const toggleEvent = useCallback((index: number) => { setSelectedIndices((prev) => { const next = new Set(prev); @@ -202,29 +257,85 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
{step === "select" && !isParsing && ( -
fileInputRef.current?.click()} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${ - isDragging - ? "border-primary bg-primary/5" - : "border-border hover:border-primary/50 hover:bg-muted/50" - }`} - > - -

{t("select_file")}

-

{t("drop_file")}

-

{t("supported_formats")}

- -
+ <> +
+ + +
+ + {importMode === "file" && ( +
fileInputRef.current?.click()} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${ + isDragging + ? "border-primary bg-primary/5" + : "border-border hover:border-primary/50 hover:bg-muted/50" + }`} + > + +

{t("select_file")}

+

{t("drop_file")}

+

{t("supported_formats")}

+ +
+ )} + + {importMode === "url" && ( +
+

{t("url_description")}

+
+ setUrlInput(e.target.value)} + placeholder={t("url_placeholder")} + className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + onKeyDown={(e) => { if (e.key === "Enter") handleUrlFetch(); }} + /> + +
+

{t("url_hint")}

+
+ )} + )} {isParsing && ( diff --git a/components/calendar/ical-subscription-modal.tsx b/components/calendar/ical-subscription-modal.tsx new file mode 100644 index 00000000..23ef58d9 --- /dev/null +++ b/components/calendar/ical-subscription-modal.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { X, Loader2, Globe } from "lucide-react"; +import type { JMAPClient } from "@/lib/jmap/client"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; +import { toast } from "@/stores/toast-store"; + +interface ICalSubscriptionModalProps { + client: JMAPClient; + onClose: () => void; +} + +export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) { + const t = useTranslations("calendar.subscription"); + const tCommon = useTranslations("common"); + const addICalSubscription = useCalendarStore((s) => s.addICalSubscription); + + const [url, setUrl] = useState(""); + const [name, setName] = useState(""); + const [color, setColor] = useState("#3b82f6"); + const [refreshInterval, setRefreshInterval] = useState(60); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const modalRef = useRef(null); + + const isValid = url.trim().length > 0 && name.trim().length > 0; + + const handleSubmit = useCallback(async () => { + let trimmedUrl = url.trim(); + if (!trimmedUrl || !name.trim()) return; + + // Convert webcal:// to https:// + if (trimmedUrl.startsWith("webcal://")) { + trimmedUrl = trimmedUrl.replace(/^webcal:\/\//, "https://"); + } + + try { + new URL(trimmedUrl); + } catch { + setError(t("invalid_url")); + return; + } + + setError(null); + setIsSubmitting(true); + + try { + const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval); + if (subscription) { + toast.success(t("success", { name: name.trim() })); + onClose(); + } else { + setError(t("error")); + } + } catch { + setError(t("error")); + } finally { + setIsSubmitting(false); + } + }, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]); + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onClose]); + + useEffect(() => { + const modal = modalRef.current; + if (!modal) return; + const focusableEls = modal.querySelectorAll( + 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])' + ); + const firstEl = focusableEls[0]; + const lastEl = focusableEls[focusableEls.length - 1]; + + const handler = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + if (e.shiftKey && document.activeElement === firstEl) { + e.preventDefault(); + lastEl?.focus(); + } else if (!e.shiftKey && document.activeElement === lastEl) { + e.preventDefault(); + firstEl?.focus(); + } + }; + modal.addEventListener("keydown", handler); + firstEl?.focus(); + return () => modal.removeEventListener("keydown", handler); + }, []); + + return ( +
+ + ); +} diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 0dfe7739..78920635 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -22,7 +22,20 @@ function formatPhoneFeatures(features?: Record): string { return Object.keys(features).filter(k => features[k]).join(", "); } -function formatDate(dateStr: string): string { +function formatDate(dateInput: string | Record): string { + // Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? } + if (typeof dateInput === 'object' && dateInput !== null) { + const year = dateInput.year as number | undefined; + const month = dateInput.month as number | undefined; + const day = dateInput.day as number | undefined; + const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const parts: string[] = []; + if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]); + if (day) parts.push(String(day)); + if (year) parts.push(String(year)); + return parts.join(' ') || String(dateInput); + } + const dateStr = String(dateInput); // Handle both ISO dates and partial dates like 1990-01-15 or --01-15 if (dateStr.startsWith("--")) { // Partial date without year @@ -216,12 +229,12 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
{onlineServices.map((svc, i) => (
- {svc.uri.startsWith("http") ? ( + {typeof svc.uri === 'string' && svc.uri.startsWith("http") ? ( {svc.user || svc.uri} ) : ( - {svc.user || svc.uri} + {svc.user || String(svc.uri ?? '')} )} {svc.service && ( {svc.service} @@ -320,12 +333,12 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
{cryptoKeys.map((key, i) => (
- {key.uri.startsWith("http") ? ( + {typeof key.uri === 'string' && key.uri.startsWith("http") ? ( {key.uri} ) : ( - {key.uri.substring(0, 80)}{key.uri.length > 80 ? "…" : ""} + {typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')} )}
))} diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index e7d09d82..2ffce517 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useTranslations } from "next-intl"; -import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus, Upload } from "lucide-react"; +import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ContactListItem } from "./contact-list-item"; @@ -17,7 +17,6 @@ interface ContactListProps { onSearchChange: (query: string) => void; onSelectContact: (id: string) => void; onCreateNew: () => void; - onImport?: () => void; supportsSync: boolean; className?: string; selectedContactIds: Set; @@ -36,7 +35,6 @@ export function ContactList({ onSearchChange, onSelectContact, onCreateNew, - onImport, supportsSync, className, selectedContactIds, @@ -185,12 +183,6 @@ export function ContactList({ {t("create_new")} - {onImport && ( - - )}
)} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 4cb42320..da02a189 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -9,7 +9,9 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma import { cn, formatFileSize } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; +import { sanitizeEmailHtml } from "@/lib/email-sanitization"; import { useAuthStore } from "@/stores/auth-store"; +import { useIdentityStore } from "@/stores/identity-store"; import { useContactStore } from "@/stores/contact-store"; import { useTemplateStore } from "@/stores/template-store"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; @@ -41,6 +43,7 @@ interface EmailComposerProps { bcc: string[]; subject: string; body: string; + htmlBody?: string; draftId?: string; fromEmail?: string; fromName?: string; @@ -59,6 +62,7 @@ interface EmailComposerProps { cc?: { email?: string; name?: string }[]; subject?: string; body?: string; + htmlBody?: string; receivedAt?: string; }; } @@ -112,16 +116,22 @@ export function EmailComposer({ const getInitialBody = () => { const prefix = initialDraftText || ""; - if (!replyTo?.body) return prefix; + if (!replyTo?.body && !replyTo?.htmlBody) return prefix; const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ""; const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); + // When HTML body is available, don't include quoted text in the textarea + // The HTML original will be shown separately below the textarea + if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { + return prefix; + } + if (mode === 'forward') { return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`; + return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${(replyTo.body || '').split('\n').join('\n> ')}`; } return prefix; }; @@ -137,6 +147,18 @@ export function EmailComposer({ const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const saveTimeoutRef = useRef(null); const lastSavedDataRef = useRef(""); + const textareaRef = useRef(null); + + const autoResizeTextarea = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = el.scrollHeight + 'px'; + }, []); + + useEffect(() => { + autoResizeTextarea(); + }, [body, autoResizeTextarea]); const [attachments, setAttachments] = useState>([]); const fileInputRef = useRef(null); const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({}); @@ -146,6 +168,7 @@ export function EmailComposer({ const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false); + const [showAllAttachments, setShowAllAttachments] = useState(false); const saveTemplateModalRef = useFocusTrap({ isActive: showSaveAsTemplate, @@ -159,7 +182,9 @@ export function EmailComposer({ restoreFocus: true, }); - const { client, identities, primaryIdentity } = useAuthStore(); + const { client } = useAuthStore(); + const identities = useIdentityStore((s) => s.identities); + const primaryIdentity = identities[0] ?? null; const getAutocomplete = useContactStore((s) => s.getAutocomplete); const addTemplate = useTemplateStore((s) => s.addTemplate); @@ -330,13 +355,9 @@ export function EmailComposer({ return () => window.removeEventListener('keydown', handleTemplateKey); }, []); - const handleFileSelect = async (event: React.ChangeEvent) => { - if (!client || !event.target.files) return; + const addFiles = useCallback(async (files: File[]) => { + if (!client || files.length === 0) return; - const files = Array.from(event.target.files); - - // AbortController tracks cancellation state but uploadBlob doesn't accept a signal, - // so abort only prevents post-upload state updates (cosmetic cancellation) const newAttachments = files.map(file => { const controller = new AbortController(); return { file, uploading: true, abortController: controller }; @@ -372,12 +393,60 @@ export function EmailComposer({ ); } } + }, [client, t]); + const handleFileSelect = async (event: React.ChangeEvent) => { + if (!event.target.files) return; + await addFiles(Array.from(event.target.files)); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; + const [isDraggingOver, setIsDraggingOver] = useState(false); + const dragTimeoutRef = useRef(null); + + const clearDragState = useCallback(() => { + if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current); + dragTimeoutRef.current = null; + setIsDraggingOver(false); + }, []); + + const resetDragTimeout = useCallback(() => { + if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current); + dragTimeoutRef.current = setTimeout(clearDragState, 150); + }, [clearDragState]); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.types.includes('Files')) { + setIsDraggingOver(true); + resetDragTimeout(); + } + }, [resetDragTimeout]); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + resetDragTimeout(); + }, [resetDragTimeout]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + resetDragTimeout(); + }, [resetDragTimeout]); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + clearDragState(); + if (e.dataTransfer.files?.length) { + addFiles(Array.from(e.dataTransfer.files)); + } + }, [addFiles, clearDragState]); + const removeAttachment = (index: number) => { const att = attachments[index]; att?.abortController?.abort(); @@ -549,13 +618,37 @@ export function EmailComposer({ : currentIdentity.email : undefined; + // Append signature from the selected identity + let finalBody = body; + if (currentIdentity?.textSignature) { + finalBody = body + '\n\n-- \n' + currentIdentity.textSignature; + } + + // Build HTML body when replying/forwarding with original HTML content + let finalHtmlBody: string | undefined; + if (replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { + const escapedBody = body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); + const signatureHtml = currentIdentity?.textSignature + ? `

--
${currentIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}` + : ''; + const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ''; + const fromAddr = replyTo.from?.[0]; + const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown'); + const quoteHeader = mode === 'forward' + ? `---------- ${t('prefix.forward')} ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

` + : `On ${date}, ${fromStr} wrote:
`; + + finalHtmlBody = `
${escapedBody}
${signatureHtml}
${quoteHeader}
${replyTo.htmlBody}
`; + } + try { await onSend?.({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, - body, + body: finalBody, + htmlBody: finalHtmlBody, draftId: finalDraftId || undefined, fromEmail, fromName: currentIdentity?.name || undefined, @@ -617,7 +710,22 @@ export function EmailComposer({ }; return ( -
+
+ {/* Drag overlay */} + {isDraggingOver && ( +
+
+ + {t('drop_files')} +
+
+ )} {/* Header - mobile: clean bar with close/send, desktop: title bar */}
@@ -659,7 +767,7 @@ export function EmailComposer({
-
+
{/* Fields section */}
{/* From field */} @@ -825,10 +933,11 @@ export function EmailComposer({
{/* Body */} -
+