Merge dev into main - version 1.4.5

This commit is contained in:
Linus Rath
2026-03-20 18:10:08 +01:00
21 changed files with 506 additions and 117 deletions
+25
View File
@@ -1,5 +1,30 @@
# Changelog
## 1.4.5 (2026-03-20)
### Features
- **Calendar**: Add prev/next navigation buttons and date label to desktop calendar toolbar
- **Calendar**: Add pending event preview functionality to calendar views and event modal
- **Calendar**: Add setting to show event start time in month view
- **Contacts**: Implement pagination for fetching contacts with maxObjectsInGet capability
- **Email**: Add attachment position setting in email settings
- **Layout**: Add mobile visibility toggle for sidebar apps
- **Error**: Add NotFound component to handle 404 errors and redirect unauthenticated users
### Fixes
- **Auth**: Enhance account switching logic and clear stores on account change
- **Auth**: Improve account restoration logic and handle stale accounts
- **Auth**: Improve draft handling in email composer and enhance session cookie verification
- **Calendar**: Expand recurring events in CalendarEvent/query so individual occurrences are returned (#65)
- **Calendar**: Validate event start field when fetching calendar events
- **Calendar**: Auto-scroll agenda view to today's events and include today's date in groups
- **Calendar**: Correct JSX syntax in CalendarToolbar component
- **Dependencies**: Update flatted to 3.4.2
- **DevOps**: Use native ARM runners instead of QEMU for Docker builds
- **DevOps**: Enhance health check with detailed memory diagnostics and stable liveness probe
## 1.4.4 (2026-03-19)
### Features
+1 -1
View File
@@ -1 +1 @@
1.4.4
1.4.5
+21 -13
View File
@@ -25,7 +25,7 @@ import { CalendarDayView } from "@/components/calendar/calendar-day-view";
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal } from "@/components/calendar/event-modal";
import { EventModal, type PendingEventPreview } 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";
@@ -82,6 +82,7 @@ export default function CalendarPage() {
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
const hasFetched = useRef(false);
// Sidebar resize state
@@ -268,12 +269,13 @@ export default function CalendarPage() {
}, [closeDetail, openEditModal]);
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event
if (showEventModal && editEvent?.id === event.id) return;
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, [showEventModal, editEvent]);
}, [isMobile, showEventModal, editEvent]);
const handleHoverLeave = useCallback(() => {
hoverTimerRef.current = setTimeout(() => {
@@ -615,7 +617,7 @@ export default function CalendarPage() {
const visibleEvents = useMemo(() =>
events.filter((e) => {
if (!e.calendarIds) return false;
if (!e.start || !e.calendarIds) return false;
const calIds = Object.keys(e.calendarIds);
return calIds.some((id) => selectedCalendarIds.includes(id));
}),
@@ -648,6 +650,7 @@ export default function CalendarPage() {
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
pendingPreview={pendingPreview}
/>
);
case "week":
@@ -664,6 +667,7 @@ export default function CalendarPage() {
firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat}
isMobile={isMobile}
pendingPreview={pendingPreview}
/>
);
case "day":
@@ -678,6 +682,7 @@ export default function CalendarPage() {
onCreateAtTime={openCreateModal}
timeFormat={timeFormat}
isMobile={isMobile}
pendingPreview={pendingPreview}
/>
);
case "agenda":
@@ -708,7 +713,7 @@ export default function CalendarPage() {
};
return (
<div className="flex h-dvh bg-background overflow-hidden">
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail */}
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
@@ -771,7 +776,7 @@ export default function CalendarPage() {
)}
{!inlineApp && (
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<CalendarToolbar
selectedDate={selectedDate}
viewMode={normalizedViewMode}
@@ -808,7 +813,8 @@ export default function CalendarPage() {
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails}
isMobile={false}
/>
@@ -831,13 +837,15 @@ export default function CalendarPage() {
{/* Mobile Bottom Navigation */}
{isMobile && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
<div className="shrink-0">
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
</div>
)}
{detailEvent && detailAnchorRect && (
+1 -1
View File
@@ -544,7 +544,7 @@ export default function ContactsPage() {
};
return (
<div className="flex h-dvh bg-background overflow-hidden">
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
{/* Navigation Rail - desktop only */}
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
+20 -9
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
function isEnabled(): boolean {
@@ -10,19 +10,30 @@ function isEnabled(): boolean {
}
/**
* Verify identity against the session cookie if available.
* Returns true if no session cookie exists (can't verify) or if identity matches.
* Returns false if session cookie exists but identity doesn't match.
* Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4).
* Returns true if any slot matches OR if no session cookies exist at all.
*/
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies();
const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
if (!sessionToken) return true; // No session cookie, can't verify (same-origin protection applies)
let hasAnyCookie = false;
const session = decryptSession(sessionToken);
if (!session) return true; // Invalid session cookie, skip verification
for (let slot = 0; slot <= 4; slot++) {
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (!token) continue;
hasAnyCookie = true;
return session.username === username && session.serverUrl === serverUrl;
const session = decryptSession(token);
if (session && session.username === username && session.serverUrl === serverUrl) {
return true; // Found a matching slot
}
}
// No cookies at all → can't verify, allow (same-origin protection applies)
if (!hasAnyCookie) return true;
// Cookies exist but none matched → identity mismatch
return false;
}
export async function GET(request: NextRequest) {
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
import { useAuthStore } from "@/stores/auth-store";
export default function NotFound() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
if (!isAuthenticated) {
window.location.href = "/login";
}
}, [isAuthenticated]);
if (!isAuthenticated) {
return null;
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-6">This page could not be found.</p>
<a
href="/"
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
>
Go home
</a>
</div>
</div>
);
}
+32 -1
View File
@@ -2,13 +2,14 @@
import { useMemo, useEffect, useRef, useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { format, isToday, parseISO } from "date-fns";
import { format, isSameDay, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard, parseDuration } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
interface CalendarDayViewProps {
selectedDate: Date;
@@ -20,6 +21,7 @@ interface CalendarDayViewProps {
onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
}
const HOUR_HEIGHT = 64;
@@ -35,6 +37,7 @@ export function CalendarDayView({
onCreateAtTime,
timeFormat = "24h",
isMobile,
pendingPreview,
}: CalendarDayViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -274,6 +277,34 @@ export function CalendarDayView({
</div>
</div>
)}
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
return (
<div
className="absolute left-2 right-2 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(24, (durationMin / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
}}
>
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
{pendingPreview.title}
</div>
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
{formatSnapTime(startMin, timeFormat)} {formatSnapTime(startMin + durationMin, timeFormat)}
</div>
</div>
);
})()
)}
</div>
</div>
</div>
+54 -19
View File
@@ -12,6 +12,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store";
interface CalendarMonthViewProps {
@@ -25,6 +26,7 @@ interface CalendarMonthViewProps {
onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
}
export function CalendarMonthView({
@@ -38,6 +40,7 @@ export function CalendarMonthView({
onCreateAtTime,
firstDayOfWeek = 1,
isMobile,
pendingPreview,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -194,31 +197,63 @@ export function CalendarMonthView({
</span>
</div>
{isMobile ? (
dayEvents.length > 0 && (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
{dayEvents.slice(0, 3).map((ev) => {
const calId = getPrimaryCalendarId(ev);
const cal = calId ? calendarMap.get(calId) : undefined;
const evColor = ev.color || cal?.color || "#3b82f6";
return (
<span
key={ev.id}
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: evColor }}
/>
);
})}
{dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
</div>
)
<div className="flex items-center justify-center gap-0.5 flex-wrap">
{dayEvents.slice(0, 3).map((ev) => {
const calId = getPrimaryCalendarId(ev);
const cal = calId ? calendarMap.get(calId) : undefined;
const evColor = ev.color || cal?.color || "#3b82f6";
return (
<span
key={ev.id}
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: evColor }}
/>
);
})}
{dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
{pendingPreview && isSameDay(pendingPreview.start, day) && (
<span
className="w-1.5 h-1.5 rounded-full border border-dashed"
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
/>
)}
</div>
) : null}
</div>
);
})}
</div>
{!isMobile && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
if (previewDayIdx === -1) return null;
const previewRow = rowCount;
const cal = calendarMap.get(pendingPreview.calendarId);
const color = cal?.color || "#3b82f6";
return (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
<div
className="absolute px-0.5"
style={{
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: previewRow * 22,
height: 20,
}}
>
<div
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
>
{pendingPreview.title}
</div>
</div>
</div>
);
})()}
{!isMobile && segments.length > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
{segments.map((segment) => {
+14
View File
@@ -136,6 +136,20 @@ export function CalendarToolbar({
{t("views.today")}
</Button>
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ml-2 select-none">
{getDateLabel()}
</span>
</div>
)}
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<Button
@@ -11,6 +11,7 @@ import { QuickEventInput } from "./quick-event-input";
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
interface CalendarWeekViewProps {
selectedDate: Date;
@@ -24,6 +25,7 @@ interface CalendarWeekViewProps {
firstDayOfWeek?: number;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
}
const HOUR_HEIGHT = 60;
@@ -41,6 +43,7 @@ export function CalendarWeekView({
firstDayOfWeek = 1,
timeFormat = "24h",
isMobile,
pendingPreview,
}: CalendarWeekViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -366,6 +369,34 @@ export function CalendarWeekView({
</div>
</div>
)}
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
return (
<div
className="absolute left-1 right-1 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(20, (durationMin / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
}}
>
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
{pendingPreview.title}
</div>
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
{formatSnapTime(startMin, timeFormat)} {formatSnapTime(startMin + durationMin, timeFormat)}
</div>
</div>
);
})()
)}
</div>
);
})}
+4
View File
@@ -70,6 +70,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
const color = getEventColor(event, calendar);
const startDate = parseISO(event.start);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const calendarName = calendar?.name || "";
@@ -156,6 +157,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
>
<div className="flex items-center gap-1 min-w-0">
{showTimeInMonthView && !event.showWithoutTime && (
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
)}
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
</div>
</button>
+22
View File
@@ -20,6 +20,14 @@ import {
} from "@/lib/calendar-participants";
import { useSettingsStore } from "@/stores/settings-store";
export interface PendingEventPreview {
start: Date;
end: Date;
title: string;
allDay: boolean;
calendarId: string;
}
interface EventModalProps {
event?: CalendarEvent | null;
calendars: Calendar[];
@@ -30,6 +38,7 @@ interface EventModalProps {
onDuplicate?: (data: Partial<CalendarEvent>) => void;
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
onClose: () => void;
onPreviewChange?: (preview: PendingEventPreview | null) => void;
currentUserEmails?: string[];
isMobile?: boolean;
}
@@ -105,6 +114,7 @@ export function EventModal({
onDuplicate,
onRsvp,
onClose,
onPreviewChange,
currentUserEmails = [],
isMobile = false,
}: EventModalProps) {
@@ -219,6 +229,18 @@ export function EventModal({
});
const [sendInvitations, setSendInvitations] = useState(true);
// Report live preview to parent for grid outline
useEffect(() => {
if (!onPreviewChange || isEdit) return;
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
const s = new Date(startStr);
const e = new Date(endStr);
if (isNaN(s.getTime()) || isNaN(e.getTime())) return;
onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId });
return () => onPreviewChange(null);
}, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]);
const statusCounts = useMemo(() => {
if (!event?.participants) return null;
return getStatusCounts(event);
+23 -12
View File
@@ -217,8 +217,8 @@ export function NavigationRail({
);
})}
{/* Custom sidebar apps */}
{sidebarApps.map((app) => {
{/* Custom sidebar apps (per-app mobile visibility) */}
{sidebarApps.filter((app) => app.showOnMobile).map((app) => {
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
const isActive = activeAppId === app.id;
return (
@@ -252,16 +252,27 @@ export function NavigationRail({
);
})}
{/* Manage apps button */}
{onManageApps && (
<button
onClick={onManageApps}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
>
<Plus className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t("add_app")}</span>
</button>
)}
{/* Settings */}
<Link
href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
"transition-colors duration-150",
isSettingsActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
aria-current={isSettingsActive ? "page" : undefined}
>
<div className="relative">
<Settings className="w-5 h-5" />
{isSettingsActive && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight">{t("settings")}</span>
</Link>
</nav>
);
}
+11
View File
@@ -14,6 +14,7 @@ export function CalendarSettings() {
const {
timeFormat,
firstDayOfWeek,
showTimeInMonthView,
calendarNotificationsEnabled,
calendarNotificationSound,
calendarInvitationParsingEnabled,
@@ -57,6 +58,16 @@ export function CalendarSettings() {
/>
</SettingItem>
<SettingItem
label={t('show_time_in_month_view')}
description={t('show_time_in_month_view_desc')}
>
<ToggleSwitch
checked={showTimeInMonthView}
onChange={(checked) => updateSetting('showTimeInMonthView', checked)}
/>
</SettingItem>
<SettingItem
label={t('notifications_enabled')}
description={t('notifications_enabled_desc')}
@@ -18,6 +18,7 @@ interface SidebarAppFormData {
url: string;
icon: string;
openMode: "tab" | "inline";
showOnMobile: boolean;
}
function AppForm({
@@ -37,6 +38,7 @@ function AppForm({
url: app?.url || "",
icon: app?.icon || "Globe",
openMode: app?.openMode || "tab",
showOnMobile: app?.showOnMobile ?? false,
});
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -144,6 +146,25 @@ function AppForm({
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{t("show_on_mobile")}</label>
<button
type="button"
onClick={() => setFormData({ ...formData, showOnMobile: !formData.showOnMobile })}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
formData.showOnMobile ? "bg-primary" : "bg-muted-foreground/30"
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
formData.showOnMobile ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
{t("cancel")}
+14
View File
@@ -8,6 +8,7 @@ import { useEmailStore } from '@/stores/email-store';
import { useContactStore } from '@/stores/contact-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useFilterStore } from '@/stores/filter-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store';
@@ -97,6 +98,19 @@ export function clearAllStores(): void {
error: null,
searchQuery: '',
quota: null,
isPushConnected: false,
lastPushUpdate: null,
newEmailNotification: null,
selectedEmailIds: new Set<string>(),
hasMoreEmails: false,
totalEmails: 0,
expandedThreadIds: new Set<string>(),
threadEmailsCache: new Map(),
isLoadingThread: null,
selectedKeyword: null,
tagCounts: {},
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
isAdvancedSearchOpen: false,
});
useIdentityStore.getState().clearIdentities();
useContactStore.getState().clearContacts();
+72 -37
View File
@@ -2066,6 +2066,11 @@ export class JMAPClient {
return coreCapability?.maxCallsInRequest || 50;
}
getMaxObjectsInGet(): number {
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInGet?: number } | undefined;
return coreCapability?.maxObjectsInGet || 500;
}
getEventSourceUrl(): string | null {
if (!this.session) return null;
@@ -2428,26 +2433,62 @@ export class JMAPClient {
}
}
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
try {
const accountId = this.getContactsAccountId();
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
if (addressBookId) {
queryArgs.filter = { inAddressBook: addressBookId };
private async fetchPaginatedContacts(
accountId: string,
filter?: Record<string, unknown>,
): Promise<ContactCard[]> {
const batchSize = this.getMaxObjectsInGet();
const allIds: string[] = [];
let position = 0;
// Paginate ContactCard/query to collect all IDs
for (;;) {
const queryArgs: Record<string, unknown> = { accountId, position, limit: batchSize };
if (filter) {
queryArgs.filter = filter;
}
const response = await this.request([
["ContactCard/query", queryArgs, "0"],
["ContactCard/get", {
accountId,
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
}, "1"],
["ContactCard/query", queryArgs, "q"],
], this.contactUsing());
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
return (response.methodResponses[1][1].list || []) as ContactCard[];
const queryResult = response.methodResponses?.[0];
if (queryResult?.[0] !== "ContactCard/query") break;
const ids: string[] = queryResult[1].ids || [];
allIds.push(...ids);
const total: number = queryResult[1].total ?? -1;
if (ids.length < batchSize || (total > 0 && allIds.length >= total)) {
break;
}
return [];
position += ids.length;
}
if (allIds.length === 0) return [];
// Batch ContactCard/get to respect maxObjectsInGet
const allContacts: ContactCard[] = [];
for (let i = 0; i < allIds.length; i += batchSize) {
const chunk = allIds.slice(i, i + batchSize);
const response = await this.request([
["ContactCard/get", { accountId, ids: chunk }, "g"],
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
const list = (response.methodResponses[0][1].list || []) as ContactCard[];
allContacts.push(...list);
}
}
return allContacts;
}
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
try {
const accountId = this.getContactsAccountId();
const filter = addressBookId ? { inAddressBook: addressBookId } : undefined;
return await this.fetchPaginatedContacts(accountId, filter);
} catch (error) {
console.error('Failed to get contacts:', error);
return [];
@@ -2465,29 +2506,19 @@ export class JMAPClient {
const account = this.accounts[accountId];
try {
const response = await this.request([
["ContactCard/query", { accountId, limit: 1000 }, "0"],
["ContactCard/get", {
accountId,
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
}, "1"],
], this.contactUsing());
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[];
const contacts = rawContacts.map((contact) => ({
...contact,
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
originalId: contact.id,
addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries(
Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v])
) : contact.addressBookIds),
accountId,
accountName: account?.name || (isPrimary ? this.username : accountId),
isShared: !isPrimary,
}));
allContacts.push(...contacts);
}
const rawContacts = await this.fetchPaginatedContacts(accountId);
const contacts = rawContacts.map((contact) => ({
...contact,
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
originalId: contact.id,
addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries(
Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v])
) : contact.addressBookIds),
accountId,
accountName: account?.name || (isPrimary ? this.username : accountId),
isShared: !isPrimary,
}));
allContacts.push(...contacts);
} catch (error) {
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
}
@@ -2888,6 +2919,10 @@ export class JMAPClient {
filter,
limit: limit || 1000,
};
// Expand recurring events into individual occurrences when a date range is provided
if (filter.after || filter.before) {
queryArgs.expandRecurrences = true;
}
if (sort) {
queryArgs.sort = sort;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.4.4",
"version": "1.4.5",
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+98 -19
View File
@@ -34,7 +34,7 @@ interface AuthState {
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
refreshAccessToken: () => Promise<string | null>;
logout: () => void;
logout: () => Promise<void>;
logoutAll: () => void;
switchAccount: (accountId: string) => Promise<void>;
checkAuth: () => Promise<void>;
@@ -264,10 +264,12 @@ export const useAuthStore = create<AuthState>()(
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
: accountStore.getNextCookieSlot();
// Snapshot current account if switching away
// Snapshot current account if switching away and clear stores so
// the new account starts with a clean email/contact/calendar state.
const prevAccountId = get().activeAccountId;
if (prevAccountId && prevAccountId !== accountId) {
snapshotAccount(prevAccountId);
clearAllStores();
}
// Store client in multi-account map
@@ -390,10 +392,12 @@ export const useAuthStore = create<AuthState>()(
// Register in account store
const accountId = generateAccountId(username, serverUrl);
// Snapshot current account if switching away
// Snapshot current account if switching away and clear stores so
// the new account starts with a clean email/contact/calendar state.
const prevAccountId = get().activeAccountId;
if (prevAccountId && prevAccountId !== accountId) {
snapshotAccount(prevAccountId);
clearAllStores();
}
clients.set(accountId, client);
@@ -506,7 +510,7 @@ export const useAuthStore = create<AuthState>()(
return promise;
},
logout: () => {
logout: async () => {
const state = get();
const wasOAuth = state.authMode === 'oauth';
const accountId = state.activeAccountId;
@@ -515,6 +519,11 @@ export const useAuthStore = create<AuthState>()(
const slot = account?.cookieSlot ?? 0;
clearRefreshTimer(accountId ?? undefined);
// Null out the client BEFORE disconnecting so the page doesn't fire
// data-loading effects with the stale disconnected client while
// stores are being cleared.
set({ client: null });
state.client?.disconnect();
// Remove client from multi-account map
@@ -536,11 +545,56 @@ export const useAuthStore = create<AuthState>()(
clearAllStores();
// Restore next account
const nextClient = clients.get(nextAccount.id);
let nextClient = clients.get(nextAccount.id);
// If the client isn't in memory, try to restore it from the session
if (!nextClient) {
try {
if (nextAccount.authMode === 'oauth') {
const res = await fetch(`/api/auth/token?slot=${nextAccount.cookieSlot}`, { method: 'PUT' });
if (res.ok) {
const { access_token, expires_in } = await res.json();
const refreshFn = get().refreshAccessToken;
nextClient = JMAPClient.withBearer(nextAccount.serverUrl, access_token, nextAccount.username, () => refreshFn());
nextClient.onConnectionChange((connected) => {
if (get().activeAccountId === nextAccount.id) {
set({ connectionLost: !connected });
}
accountStore.updateAccount(nextAccount.id, { isConnected: connected });
});
await nextClient.connect();
clients.set(nextAccount.id, nextClient);
scheduleRefresh(expires_in, get().refreshAccessToken, nextAccount.id);
}
} else if (nextAccount.authMode === 'basic' && nextAccount.rememberMe) {
const res = await fetch(`/api/auth/session?slot=${nextAccount.cookieSlot}`);
if (res.ok) {
const { serverUrl: sUrl, username: uName, password: pwd } = await res.json();
nextClient = new JMAPClient(sUrl, uName, pwd);
nextClient.onConnectionChange((connected) => {
if (get().activeAccountId === nextAccount.id) {
set({ connectionLost: !connected });
}
accountStore.updateAccount(nextAccount.id, { isConnected: connected });
});
await nextClient.connect();
clients.set(nextAccount.id, nextClient);
}
}
} catch (err) {
debug.error(`Failed to restore next account ${nextAccount.id} during logout:`, err);
nextClient = undefined;
}
}
if (nextClient) {
const restored = restoreAccount(nextAccount.id);
accountStore.setActiveAccount(nextAccount.id);
// Build identity state up front so the name updates atomically
const restoredIdentities = restored ? useIdentityStore.getState().identities : [];
const restoredPrimary = restoredIdentities[0] ?? null;
set({
isAuthenticated: true,
isLoading: false,
@@ -552,6 +606,8 @@ export const useAuthStore = create<AuthState>()(
connectionLost: false,
error: null,
activeAccountId: nextAccount.id,
identities: restoredIdentities,
primaryIdentity: restoredPrimary,
});
if (!restored) {
@@ -560,13 +616,32 @@ export const useAuthStore = create<AuthState>()(
const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username);
set({ identities, primaryIdentity });
}).catch((err) => debug.error('Failed to load identities after switch:', err));
} else {
const identityState = useIdentityStore.getState();
set({
identities: identityState.identities,
primaryIdentity: identityState.identities[0] ?? null,
});
}
} else {
// Could not restore the next account — remove it and do a full logout
debug.error(`Cannot restore next account ${nextAccount.id}, performing full logout`);
evictAccount(nextAccount.id);
accountStore.removeAccount(nextAccount.id);
set({
isAuthenticated: false,
serverUrl: null,
username: null,
client: null,
identities: [],
primaryIdentity: null,
authMode: 'basic',
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
connectionLost: false,
error: null,
activeAccountId: null,
});
localStorage.removeItem('auth-storage');
clearAllStores();
redirectToLogin();
}
} else {
// No accounts remaining — full logout
@@ -691,7 +766,9 @@ export const useAuthStore = create<AuthState>()(
const targetAccount = accountStore.getAccountById(accountId);
if (!targetAccount) return;
set({ isLoading: true });
// Null out the client immediately so the page doesn't fire data-loading
// effects with the old client while stores are being cleared.
set({ isLoading: true, client: null });
// Snapshot current account
if (state.activeAccountId) {
@@ -782,6 +859,10 @@ export const useAuthStore = create<AuthState>()(
accountStore.setActiveAccount(accountId);
accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
// Build identity state up front so the name updates atomically
const restoredIdentities = restored ? useIdentityStore.getState().identities : [];
const restoredPrimary = restoredIdentities[0] ?? null;
set({
isAuthenticated: true,
isLoading: false,
@@ -793,6 +874,8 @@ export const useAuthStore = create<AuthState>()(
connectionLost: false,
error: null,
activeAccountId: accountId,
identities: restoredIdentities,
primaryIdentity: restoredPrimary,
});
if (!restored) {
@@ -804,12 +887,6 @@ export const useAuthStore = create<AuthState>()(
} catch (err) {
debug.error(`Failed to load data for ${accountId}:`, err);
}
} else {
const identityState = useIdentityStore.getState();
set({
identities: identityState.identities,
primaryIdentity: identityState.identities[0] ?? null,
});
}
// Sync settings
@@ -827,7 +904,9 @@ export const useAuthStore = create<AuthState>()(
// Multi-account restoration: restore all registered accounts
if (accounts.length > 0) {
set({ isLoading: true });
// Null out client so the page doesn't fire data-loading effects
// with a stale client reference while we're restoring accounts.
set({ isLoading: true, client: null });
// Determine which account to activate first
const defaultAccount = accountStore.getDefaultAccount();
+3 -1
View File
@@ -110,10 +110,12 @@ export const useCalendarStore = create<CalendarStore>()(
fetchEvents: async (client, start, end) => {
set({ isLoadingEvents: true, error: null });
try {
const events = await client.queryAllCalendarEvents({
const rawEvents = await client.queryAllCalendarEvents({
after: start,
before: end,
});
// Filter out malformed events missing required 'start' field
const events = rawEvents.filter(e => typeof e.start === 'string' && e.start);
set({ events, isLoadingEvents: false, dateRange: { start, end } });
} catch (error) {
debug.error('Failed to fetch events:', error);
+5 -3
View File
@@ -239,15 +239,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
const mailboxes = await client.getAllMailboxes();
// Auto-select inbox if no mailbox is currently selected
// Auto-select inbox if no mailbox is selected or the current selection
// doesn't exist in the fetched list (e.g. after an account switch)
const currentSelectedMailbox = get().selectedMailbox;
if (!currentSelectedMailbox) {
const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox);
if (!selectionValid) {
// Find inbox from PRIMARY account (not shared accounts)
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
if (inboxMailbox) {
set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false });
} else {
set({ mailboxes, isLoading: false });
set({ mailboxes, selectedMailbox: '', isLoading: false });
}
} else {
set({ mailboxes, isLoading: false });