diff --git a/.env.example b/.env.example index 8bbbf2cb..0f678b58 100644 --- a/.env.example +++ b/.env.example @@ -64,7 +64,10 @@ JMAP_SERVER_URL=https://your-jmap-server.com # SETTINGS_SYNC_ENABLED=true # Directory for storing encrypted settings files (default: ./data/settings). -# For Docker, mount a persistent volume at this path. +# For Docker, the working directory is /app, so the default resolves to +# /app/data/settings — mount a persistent volume there: +# volumes: +# - bulwark-settings:/app/data/settings # SETTINGS_DATA_DIR=./data/settings # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index 346cda47..f3a38605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 1.4.7 (2026-03-21) + +### Features + +- **Calendar**: Add task management features with task creation, editing, and status tracking +- **Calendar**: Add option to show week numbers in mini-calendar +- **Email**: Add resizable image component and rich text editor with image upload support +- **Files**: Support uploading folders via drag-and-drop and toolbar button +- **Filters**: Add expanded visual view for filter rules +- **Auth**: Add non-interactive SSO login flow for embedded/iframe deployments (#69) +- **DevOps**: Add separate Docker build workflow for releases and dev branch images + +### Fixes + +- **Calendar**: Handle updates and deletions for synthetic JMAP IDs in calendar events with fallback to destroy and recreate +- **Security**: Extend CryptoEngine to support legacy algorithms and integrate with LinerEngine for decryption +- **Auth**: Refactor logout to use synchronous flow with full page redirect +- **Email**: Update iframe sandbox attributes to allow popups to escape sandbox +- **i18n**: Add missing translation keys across all locales +- **Docker**: Update .env.example to clarify Docker volume mounting for settings data directory + ## 1.4.6 (2026-03-21) ### Features diff --git a/README.md b/README.md index 83eaf954..93497211 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar Built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-1.4.6-green.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.7-green.svg)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue)](https://ghcr.io/bulwarkmail/webmail) diff --git a/VERSION b/VERSION index c514bd85..be05bba9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.6 +1.4.7 diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx index 3bc12379..bb8c2896 100644 --- a/app/[locale]/auth/callback/page.tsx +++ b/app/[locale]/auth/callback/page.tsx @@ -13,7 +13,7 @@ function OAuthCallbackInner() { const params = useParams(); const searchParams = useSearchParams(); const t = useTranslations("login"); - const { loginWithOAuth } = useAuthStore(); + const { loginWithOAuth, loginWithServerSso } = useAuthStore(); const [error, setError] = useState(null); useEffect(() => { @@ -32,44 +32,71 @@ function OAuthCallbackInner() { } const savedState = sessionStorage.getItem("oauth_state"); - if (!state || state !== savedState) { - setError("invalid_state"); - return; - } - const codeVerifier = sessionStorage.getItem("oauth_code_verifier"); - const serverUrl = sessionStorage.getItem("oauth_server_url"); + if (savedState) { + // Classic flow — sessionStorage has the PKCE state (same-tab OAuth) + if (!state || state !== savedState) { + setError("invalid_state"); + return; + } - if (!codeVerifier || !serverUrl) { - setError("missing_params"); - return; - } + const codeVerifier = sessionStorage.getItem("oauth_code_verifier"); + const serverUrl = sessionStorage.getItem("oauth_server_url"); - const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + if (!codeVerifier || !serverUrl) { + setError("missing_params"); + return; + } - loginWithOAuth(serverUrl, code, codeVerifier, redirectUri) - .then((success) => { - if (success) { - sessionStorage.removeItem("oauth_state"); - sessionStorage.removeItem("oauth_code_verifier"); - sessionStorage.removeItem("oauth_server_url"); - sessionStorage.removeItem("oauth_add_account_mode"); - let redirectTo = `/${params.locale}`; - try { - const saved = sessionStorage.getItem('redirect_after_login'); - if (saved) { - sessionStorage.removeItem('redirect_after_login'); - redirectTo = saved; - } - } catch { /* sessionStorage may be unavailable */ } - router.push(redirectTo); - } else { + const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + + loginWithOAuth(serverUrl, code, codeVerifier, redirectUri) + .then((success) => { + if (success) { + sessionStorage.removeItem("oauth_state"); + sessionStorage.removeItem("oauth_code_verifier"); + sessionStorage.removeItem("oauth_server_url"); + sessionStorage.removeItem("oauth_add_account_mode"); + let redirectTo = `/${params.locale}`; + try { + const saved = sessionStorage.getItem('redirect_after_login'); + if (saved) { + sessionStorage.removeItem('redirect_after_login'); + redirectTo = saved; + } + } catch { /* sessionStorage may be unavailable */ } + router.push(redirectTo); + } else { + setError("token_exchange_failed"); + } + }) + .catch(() => { setError("token_exchange_failed"); - } - }) - .catch(() => { - setError("token_exchange_failed"); - }); + }); + } else if (state) { + // Server-side SSO flow — state was stored in encrypted httpOnly cookie + loginWithServerSso(code, state) + .then((success) => { + if (success) { + let redirectTo = `/${params.locale}`; + try { + const saved = sessionStorage.getItem('redirect_after_login'); + if (saved) { + sessionStorage.removeItem('redirect_after_login'); + redirectTo = saved; + } + } catch { /* sessionStorage may be unavailable */ } + router.push(redirectTo); + } else { + setError("token_exchange_failed"); + } + }) + .catch(() => { + setError("token_exchange_failed"); + }); + } else { + setError("invalid_state"); + } }, []); // eslint-disable-line react-hooks/exhaustive-deps if (error) { diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 671b7ffe..8b724919 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -11,7 +11,7 @@ import { } from "date-fns"; import { useCalendarStore } from "@/stores/calendar-store"; import { isCalendarViewMode } from "@/stores/calendar-store"; -import { useAuthStore } from "@/stores/auth-store"; +import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; @@ -23,6 +23,9 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; import { CalendarWeekView } from "@/components/calendar/calendar-week-view"; import { CalendarDayView } from "@/components/calendar/calendar-day-view"; import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; +import { TaskListView } from "@/components/calendar/task-list-view"; +import { TaskToolbar } from "@/components/calendar/task-toolbar"; +import { TaskModal } from "@/components/calendar/task-modal"; import { MiniCalendar } from "@/components/calendar/mini-calendar"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal"; @@ -35,6 +38,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { ResizeHandle } from "@/components/layout/resize-handle"; +import { useTaskStore } from "@/stores/task-store"; import { cn } from "@/lib/utils"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import { getUserParticipantId } from "@/lib/calendar-participants"; @@ -63,7 +67,8 @@ export default function CalendarPage() { setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, refreshAllSubscriptions, } = useCalendarStore(); - const { firstDayOfWeek, timeFormat } = useSettingsStore(); + const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore(); + const taskStore = useTaskStore(); const { identities } = useIdentityStore(); const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month"; @@ -83,6 +88,8 @@ export default function CalendarPage() { const [detailEvent, setDetailEvent] = useState(null); const [detailAnchorRect, setDetailAnchorRect] = useState(null); const [pendingPreview, setPendingPreview] = useState(null); + const [showTaskModal, setShowTaskModal] = useState(false); + const [editTask, setEditTask] = useState(null); const hasFetched = useRef(false); // Sidebar resize state @@ -105,7 +112,7 @@ export default function CalendarPage() { useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } - router.push("/login"); + redirectToLogin(); } else if (client && !supportsCalendar) { router.push("/"); } @@ -166,9 +173,18 @@ export default function CalendarPage() { end: format(addDays(agendaStart, 30), "yyyy-MM-dd'T'23:59:59"), }; } + case "tasks": + return null; } }, [selectedDate, normalizedViewMode, firstDayOfWeek]); + // Fetch tasks when tasks view is active or when tasks are shown on calendar grid + useEffect(() => { + if (client && enableCalendarTasks && (normalizedViewMode === "tasks" || showTasksOnCalendar)) { + taskStore.fetchTasks(client); + } + }, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar]); + useEffect(() => { if (client && calendars.length > 0 && dateRange) { fetchEvents(client, dateRange.start, dateRange.end); @@ -182,6 +198,7 @@ export default function CalendarPage() { case "week": next = subWeeks(selectedDate, 1); break; case "day": next = subDays(selectedDate, 1); break; case "agenda": next = subMonths(selectedDate, 1); break; + case "tasks": return; } setSelectedDate(next); setMiniMonth(next); @@ -194,6 +211,7 @@ export default function CalendarPage() { case "week": next = addWeeks(selectedDate, 1); break; case "day": next = addDays(selectedDate, 1); break; case "agenda": next = addMonths(selectedDate, 1); break; + case "tasks": return; } setSelectedDate(next); setMiniMonth(next); @@ -254,6 +272,34 @@ export default function CalendarPage() { setShowEventModal(true); }, []); + const openCreateTaskModal = useCallback(() => { + setEditTask(null); + setShowTaskModal(true); + }, []); + + const openEditTaskModal = useCallback((task: import("@/lib/jmap/types").CalendarTask) => { + setEditTask(task); + setShowTaskModal(true); + }, []); + + const handleSaveTask = useCallback(async (data: Partial) => { + if (!client) return; + if (editTask) { + await taskStore.updateTask(client, editTask.id, data); + } else { + await taskStore.createTask(client, data); + } + setShowTaskModal(false); + setEditTask(null); + }, [client, editTask, taskStore]); + + const handleDeleteTask = useCallback(async (id: string) => { + if (!client) return; + await taskStore.deleteTask(client, id); + setShowTaskModal(false); + setEditTask(null); + }, [client, taskStore]); + const hoverTimerRef = useRef | null>(null); const closeDetail = useCallback(() => { @@ -424,9 +470,22 @@ export default function CalendarPage() { try { if (type === "edit" && updates) { switch (scope) { - case "this": - await updateEvent(client, event.id, updates, sendScheduling); + case "this": { + // Synthetic IDs (from expandRecurrences) can't be updated directly. + // Patch the master event's recurrenceOverrides instead. + const master = await findMasterEvent(event); + if (master && event.recurrenceId) { + const patchUpdates: Record = {}; + for (const [key, value] of Object.entries(updates)) { + if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue; + patchUpdates[`recurrenceOverrides/${event.recurrenceId}/${key}`] = value; + } + await updateEvent(client, master.id, patchUpdates as Partial, sendScheduling); + } else { + await updateEvent(client, event.id, updates, sendScheduling); + } break; + } case "this_and_future": { const result = await truncateRecurrenceAtEvent(event); if (!result) { @@ -484,9 +543,20 @@ export default function CalendarPage() { toast.success(t("notifications.event_updated")); } else { switch (scope) { - case "this": - await deleteEvent(client, event.id, sendScheduling); + case "this": { + // Synthetic IDs (from expandRecurrences) can't be destroyed directly. + // Exclude the instance via recurrenceOverrides on the master event. + const delMaster = await findMasterEvent(event); + if (delMaster && event.recurrenceId) { + await updateEvent( + client, delMaster.id, + { [`recurrenceOverrides/${event.recurrenceId}`]: { excluded: true } } as Partial, + ); + } else { + await deleteEvent(client, event.id, sendScheduling); + } break; + } case "this_and_future": { const result = await truncateRecurrenceAtEvent(event); if (!result) { @@ -598,6 +668,7 @@ export default function CalendarPage() { const handleKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return; + if (target.getAttribute("contenteditable") === "true") return; if (showEventModal || detailEvent) return; switch (e.key) { @@ -608,6 +679,7 @@ export default function CalendarPage() { case "w": setViewMode("week"); break; case "d": setViewMode("day"); break; case "a": setViewMode("agenda"); break; + case "k": if (enableCalendarTasks) setViewMode("tasks"); break; case "n": openCreateModal(); break; } }; @@ -668,6 +740,8 @@ export default function CalendarPage() { timeFormat={timeFormat} isMobile={isMobile} pendingPreview={pendingPreview} + tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined} + onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }} /> ); case "day": @@ -683,6 +757,8 @@ export default function CalendarPage() { timeFormat={timeFormat} isMobile={isMobile} pendingPreview={pendingPreview} + tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined} + onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }} /> ); case "agenda": @@ -697,6 +773,33 @@ export default function CalendarPage() { timeFormat={timeFormat} /> ); + case "tasks": + return ( +
+ + { if (client) taskStore.toggleTaskComplete(client, task); }} + selectedTaskId={taskStore.selectedTaskId} + onQuickCreate={(title) => { + if (client) { + taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [calendars[0]?.id ?? ""]: true } }); + } + }} + /> +
+ ); } })(); @@ -721,7 +824,7 @@ export default function CalendarPage() { collapsed quota={quota} isPushConnected={isPushConnected} - onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} + onLogout={logout} onManageApps={handleManageApps} onInlineApp={handleInlineApp} onCloseInlineApp={closeInlineApp} @@ -751,6 +854,7 @@ export default function CalendarPage() { onChangeMonth={handleMiniMonthChange} events={events} firstDayOfWeek={firstDayOfWeek} + showWeekNumbers={showWeekNumbers} />
)} + {/* Desktop task panel */} + {!isMobile && showTaskModal && ( +
+ { setShowTaskModal(false); setEditTask(null); }} + isMobile={false} + /> +
+ )} + {/* Floating Create Event Button (mobile) */} {isMobile && (