From c1c06c68bbae984e49f9a7010eab31cfa49bfd9c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:45:25 +0100 Subject: [PATCH 01/12] fix: improve draft handling in email composer and enhance session cookie verification logic --- app/[locale]/page.tsx | 4 +++- app/api/settings/route.ts | 29 ++++++++++++++++++++--------- stores/auth-store.ts | 8 ++++++-- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 520fbb56..b010dfa5 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -471,7 +471,9 @@ export default function Home() { }; const handleEditDraft = (email?: Email) => { - const draft = email || selectedEmail; + // Guard: when used directly as an onClick handler, the click event is passed + // as the first argument. Detect this and fall back to selectedEmail. + const draft = (email && 'mailboxIds' in email) ? email : selectedEmail; if (!draft) return; const bodyText = draft.bodyValues ? Object.values(draft.bodyValues).map(v => v.value).join('\n') diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index ccb3eb26..ed688c5f 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -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 { 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) { diff --git a/stores/auth-store.ts b/stores/auth-store.ts index e9ea638f..34f6db15 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -691,7 +691,9 @@ export const useAuthStore = create()( 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) { @@ -827,7 +829,9 @@ export const useAuthStore = create()( // 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(); From e26654a005555772c7937674eab634528fcb03f7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:29:32 +0100 Subject: [PATCH 02/12] fix: enhance account switching logic and clear stores on account change --- app/[locale]/page.tsx | 26 +++++++-- lib/account-state-manager.ts | 14 +++++ stores/auth-store.ts | 109 +++++++++++++++++++++++++++++------ stores/email-store.ts | 8 ++- 4 files changed, 132 insertions(+), 25 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index b010dfa5..3f1b5c8f 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -70,9 +70,12 @@ export default function Home() { const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null); const markAsReadTimeoutRef = useRef(null); - const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore(); + const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, activeAccountId } = useAuthStore(); const { identities } = useIdentityStore(); + // Track account switches to force data reload + const lastLoadedAccountRef = useRef(null); + // Mobile/tablet responsive hooks const { isMobile, isTablet } = useDeviceDetection(); const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore(); @@ -289,9 +292,19 @@ export default function Home() { } }, [initialCheckDone, isAuthenticated, authLoading, router]); - // Load mailboxes and emails when authenticated (only if not already loaded) + // Load mailboxes and emails when authenticated + // Re-fetches on initial load (mailboxes empty) or when account switches useEffect(() => { - if (isAuthenticated && client && mailboxes.length === 0) { + const accountChanged = lastLoadedAccountRef.current !== null && lastLoadedAccountRef.current !== activeAccountId; + if (isAuthenticated && client && (mailboxes.length === 0 || accountChanged)) { + lastLoadedAccountRef.current = activeAccountId; + + // Clear stale selected email from the previous account so the viewer + // doesn't flash old content while fresh data loads. + if (accountChanged) { + selectEmail(null); + } + const loadData = async () => { try { // First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes) @@ -337,15 +350,18 @@ export default function Home() { } }; loadData(); + } else if (isAuthenticated && client && lastLoadedAccountRef.current === null) { + // First render with existing data (e.g. restored from snapshot) — just record the account + lastLoadedAccountRef.current = activeAccountId; } - // Cleanup push notifications on unmount + // Cleanup push notifications on unmount or client change return () => { if (client) { client.closePushNotifications(); } }; - }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]); + }, [isAuthenticated, client, mailboxes.length, activeAccountId, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected, selectEmail]); // Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive) useEffect(() => { diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts index 6c39606d..b7661023 100644 --- a/lib/account-state-manager.ts +++ b/lib/account-state-manager.ts @@ -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(), + hasMoreEmails: false, + totalEmails: 0, + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + isLoadingThread: null, + selectedKeyword: null, + tagCounts: {}, + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + isAdvancedSearchOpen: false, }); useIdentityStore.getState().clearIdentities(); useContactStore.getState().clearContacts(); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 34f6db15..54d79816 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -34,7 +34,7 @@ interface AuthState { login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise; loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise; refreshAccessToken: () => Promise; - logout: () => void; + logout: () => Promise; logoutAll: () => void; switchAccount: (accountId: string) => Promise; checkAuth: () => Promise; @@ -264,10 +264,12 @@ export const useAuthStore = create()( ? (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()( // 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()( 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()( 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()( 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()( connectionLost: false, error: null, activeAccountId: nextAccount.id, + identities: restoredIdentities, + primaryIdentity: restoredPrimary, }); if (!restored) { @@ -560,13 +616,32 @@ export const useAuthStore = create()( 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 @@ -784,6 +859,10 @@ export const useAuthStore = create()( 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, @@ -795,6 +874,8 @@ export const useAuthStore = create()( connectionLost: false, error: null, activeAccountId: accountId, + identities: restoredIdentities, + primaryIdentity: restoredPrimary, }); if (!restored) { @@ -806,12 +887,6 @@ export const useAuthStore = create()( } 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 diff --git a/stores/email-store.ts b/stores/email-store.ts index 1822bf9e..01110c7e 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -239,15 +239,17 @@ export const useEmailStore = create((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 }); From 8a54ae24564d120cd49038d7e2f288640c404f7a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:31:57 +0100 Subject: [PATCH 03/12] feat: add NotFound component to handle 404 errors and redirect unauthenticated users --- app/not-found.tsx | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 app/not-found.tsx diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 00000000..c1d0dd64 --- /dev/null +++ b/app/not-found.tsx @@ -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 ( +
+
+

404

+

This page could not be found.

+ + Go home + +
+
+ ); +} From dcc35335f5891c7e907d4f04e0ff9c03e18059ee Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:42:07 +0100 Subject: [PATCH 04/12] feat: add setting to show event start time in month view --- components/calendar/event-card.tsx | 4 ++++ components/settings/calendar-settings.tsx | 11 +++++++++++ locales/en/common.json | 4 +++- stores/settings-store.ts | 7 +++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index e45bfa93..40908ac1 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -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 }} >
+ {showTimeInMonthView && !event.showWithoutTime && ( + {format(startDate, timeFmt)} + )} {event.title || t("events.no_title")}
diff --git a/components/settings/calendar-settings.tsx b/components/settings/calendar-settings.tsx index d399b538..6f858f4a 100644 --- a/components/settings/calendar-settings.tsx +++ b/components/settings/calendar-settings.tsx @@ -14,6 +14,7 @@ export function CalendarSettings() { const { timeFormat, firstDayOfWeek, + showTimeInMonthView, calendarNotificationsEnabled, calendarNotificationSound, calendarInvitationParsingEnabled, @@ -57,6 +58,16 @@ export function CalendarSettings() { /> + + updateSetting('showTimeInMonthView', checked)} + /> + + ()( sendConfirmation: state.sendConfirmation, defaultReplyMode: state.defaultReplyMode, sessionTimeout: state.sessionTimeout, + showTimeInMonthView: state.showTimeInMonthView, calendarNotificationsEnabled: state.calendarNotificationsEnabled, calendarNotificationSound: state.calendarNotificationSound, calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled, From 6cff98ddb82366332564d96e12e2650b722f408c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:47:27 +0100 Subject: [PATCH 05/12] feat: implement pagination for fetching contacts and add maxObjectsInGet capability --- lib/jmap/client.ts | 105 +++++++++++++++++++++++++++++---------------- 1 file changed, 68 insertions(+), 37 deletions(-) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 7c4feb03..fffca0e4 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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 { - try { - const accountId = this.getContactsAccountId(); - const queryArgs: Record = { accountId, limit: 1000 }; - if (addressBookId) { - queryArgs.filter = { inAddressBook: addressBookId }; + private async fetchPaginatedContacts( + accountId: string, + filter?: Record, + ): Promise { + const batchSize = this.getMaxObjectsInGet(); + const allIds: string[] = []; + let position = 0; + + // Paginate ContactCard/query to collect all IDs + for (;;) { + const queryArgs: Record = { 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 { + 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); } From bd686c092ce0a6fd3f5cb6d35c591d74a4edd68e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:11:36 +0100 Subject: [PATCH 06/12] fix: validate event start field when fetching calendar events --- app/[locale]/calendar/page.tsx | 2 +- stores/calendar-store.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 398abaa2..310b6dc1 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -615,7 +615,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)); }), diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 17f71f10..44f9b94b 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -110,10 +110,12 @@ export const useCalendarStore = create()( 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); From 65fc489b9cf4ace9dd1a672d23f209714a9e0496 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:21:44 +0100 Subject: [PATCH 07/12] feat: add pending event preview functionality to calendar views and event modal --- app/[locale]/calendar/page.tsx | 9 ++- components/calendar/calendar-day-view.tsx | 33 +++++++++- components/calendar/calendar-month-view.tsx | 73 +++++++++++++++------ components/calendar/calendar-week-view.tsx | 31 +++++++++ components/calendar/event-modal.tsx | 22 +++++++ 5 files changed, 146 insertions(+), 22 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 310b6dc1..45a5214b 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -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(null); const [detailEvent, setDetailEvent] = useState(null); const [detailAnchorRect, setDetailAnchorRect] = useState(null); + const [pendingPreview, setPendingPreview] = useState(null); const hasFetched = useRef(false); // Sidebar resize state @@ -648,6 +649,7 @@ export default function CalendarPage() { onCreateAtTime={openCreateModal} firstDayOfWeek={firstDayOfWeek} isMobile={isMobile} + pendingPreview={pendingPreview} /> ); case "week": @@ -664,6 +666,7 @@ export default function CalendarPage() { firstDayOfWeek={firstDayOfWeek} timeFormat={timeFormat} isMobile={isMobile} + pendingPreview={pendingPreview} /> ); case "day": @@ -678,6 +681,7 @@ export default function CalendarPage() { onCreateAtTime={openCreateModal} timeFormat={timeFormat} isMobile={isMobile} + pendingPreview={pendingPreview} /> ); case "agenda": @@ -808,7 +812,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} /> diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index fbc9af96..09cd5372 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -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({ )} + + {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 ( +
+
+ {pendingPreview.title} +
+
+ {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} +
+
+ ); + })() + )} diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 8974a917..be21714c 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -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({ {isMobile ? ( - dayEvents.length > 0 && ( -
- {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 ( - - ); - })} - {dayEvents.length > 3 && ( - - )} -
- ) +
+ {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 ( + + ); + })} + {dayEvents.length > 3 && ( + + )} + {pendingPreview && isSameDay(pendingPreview.start, day) && ( + + )} +
) : null} ); })} + {!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 ( +
+
+
+ {pendingPreview.title} +
+
+
+ ); + })()} + {!isMobile && segments.length > 0 && (
{segments.map((segment) => { diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index bd4543ec..33327f6e 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -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({
)} + + {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 ( +
+
+ {pendingPreview.title} +
+
+ {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} +
+
+ ); + })() + )} ); })} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 8f08c8d5..61961bbf 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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) => 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); From 9495b34430f2936a2e808b2a0226300a3459c2fb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:23:22 +0100 Subject: [PATCH 08/12] feat: add prev/next navigation buttons and date label to desktop calendar toolbar Closes #59 --- components/calendar/calendar-toolbar.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 9320dcd2..0319ccc8 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -136,6 +136,20 @@ export function CalendarToolbar({ {t("views.today")} + {!isMobile && ( +
+ + + + {getDateLabel()} + +
+ ) + {isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
- ) + )} {isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
From 68e141b7874bad6c9869352f8272a4d8717ae1c6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:44:26 +0100 Subject: [PATCH 10/12] feat: add mobile visibility toggle for sidebar apps and update related components --- app/[locale]/calendar/page.tsx | 23 ++++++------ app/[locale]/contacts/page.tsx | 2 +- components/layout/navigation-rail.tsx | 35 ++++++++++++------- components/settings/sidebar-apps-settings.tsx | 21 +++++++++++ locales/en/common.json | 3 +- stores/settings-store.ts | 1 + 6 files changed, 61 insertions(+), 24 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 45a5214b..19e41bbc 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -269,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(() => { @@ -712,7 +713,7 @@ export default function CalendarPage() { }; return ( -
+
{/* Left Navigation Rail */} {!isMobile && (
@@ -775,7 +776,7 @@ export default function CalendarPage() { )} {!inlineApp && ( -
+
+
+ +
)} {detailEvent && detailAnchorRect && ( diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index fbf631dd..a7f47981 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -544,7 +544,7 @@ export default function ContactsPage() { }; return ( -
+
{/* Navigation Rail - desktop only */} {!isMobile && (
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index aaa93e12..4e22eba8 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -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 && ( - - )} + {/* Settings */} + 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} + > +
+ + {isSettingsActive && ( + + )} +
+ {t("settings")} + ); } diff --git a/components/settings/sidebar-apps-settings.tsx b/components/settings/sidebar-apps-settings.tsx index 857cff4f..61478a0c 100644 --- a/components/settings/sidebar-apps-settings.tsx +++ b/components/settings/sidebar-apps-settings.tsx @@ -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>({}); @@ -144,6 +146,25 @@ function AppForm({
+
+ + +
+