Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01c8afbfe8 | ||
|
|
be2e0f2b68 | ||
|
|
aa40c8be26 | ||
|
|
b3d4c9241c | ||
|
|
34dd5122b3 | ||
|
|
dab3606b04 | ||
|
|
0f7638055c | ||
|
|
66fe7fd359 | ||
|
|
1b2ee7da3a | ||
|
|
f6bec519f4 | ||
|
|
7102add194 | ||
|
|
a3d894730b | ||
|
|
79b99ed4f8 | ||
|
|
68214c3e91 | ||
|
|
5da0e2bdf1 | ||
|
|
92ada0460a | ||
|
|
1cce5c3c8a | ||
|
|
40fd7799e9 | ||
|
|
aaa283357e | ||
|
|
066ab1bc32 | ||
|
|
92fb9bf132 | ||
|
|
4c804d5d2b | ||
|
|
63b464641b | ||
|
|
67210c9924 | ||
|
|
8937777bcd | ||
|
|
789d211a71 | ||
|
|
c512352f77 | ||
|
|
c859230862 | ||
|
|
0fb8b81acd | ||
|
|
1eebec292a | ||
|
|
2d983c9853 | ||
|
|
a453f7fa67 | ||
|
|
4c853f176b | ||
|
|
5583d95ecc | ||
|
|
9d1302ef0a | ||
|
|
75b5d31414 | ||
|
|
1e90885f3f | ||
|
|
02577c7502 | ||
|
|
375c0a7339 | ||
|
|
99d5276cfa | ||
|
|
60b2cf5911 | ||
|
|
8d7cd26fa9 | ||
|
|
1ba8e2fd47 | ||
|
|
c7c22bd210 | ||
|
|
6e0c79ca2c | ||
|
|
9140110435 | ||
|
|
e49f7c1a75 | ||
|
|
098127148e | ||
|
|
308adf0101 | ||
|
|
574bd7ecef | ||
|
|
d6b0714b4e | ||
|
|
af8ea8349e | ||
|
|
8eeabfc995 | ||
|
|
ddb636ed73 | ||
|
|
92ff5fe449 |
+8
-1
@@ -9,9 +9,16 @@
|
|||||||
# App name displayed in the UI
|
# App name displayed in the UI
|
||||||
APP_NAME=Bulwark Webmail
|
APP_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL of your JMAP-compatible mail server (required)
|
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||||
JMAP_SERVER_URL=https://your-jmap-server.com
|
JMAP_SERVER_URL=https://your-jmap-server.com
|
||||||
|
|
||||||
|
# Allow users to specify a custom JMAP server URL on the login form.
|
||||||
|
# When enabled, a "JMAP Server" field appears on the login page.
|
||||||
|
# Users can connect to any JMAP-compatible server.
|
||||||
|
# NOTE: External JMAP servers must include this domain in their CORS
|
||||||
|
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||||
|
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stalwart Mail Server Integration
|
# Stalwart Mail Server Integration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# Check for AI attribution in commit message
|
|
||||||
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
|
|
||||||
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
|
|
||||||
echo " This violates project policy in CLAUDE.md"
|
|
||||||
echo " Remove 'Co-Authored-By: Claude' and similar references"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,5 +1,67 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.4.11 (2026-03-31)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Logging**: Add logging categories for better log management
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Harden security with CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, and PDF iframe sandbox
|
||||||
|
- **Security**: Harden proxy authentication and SSRF defenses
|
||||||
|
- **Security**: Block plugins with dangerous JS patterns and enforce strict session secret length validation
|
||||||
|
- **S/MIME**: Add self-signed certificate detection and update status messages for S/MIME signatures
|
||||||
|
- **Email**: Auto-focus input fields in email composer for improved user experience (#126)
|
||||||
|
- **Mailbox**: Prevent orphaning of nested mailboxes by restricting deduplication to root-level folders
|
||||||
|
- **JMAP**: Strip server-immutable fields from updates before sending to JMAP (#128)
|
||||||
|
- **Files**: Update file feature disabled messages and add stability warnings
|
||||||
|
- **i18n**: Add missing translation keys to all non-English locales
|
||||||
|
|
||||||
|
## 1.4.10 (2026-03-31)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin
|
||||||
|
- **Calendar**: Implement client-side recurrence expansion for calendar events
|
||||||
|
- **Calendar**: Add iCal subscription editing and batch event import
|
||||||
|
- **Calendar**: Add hover preview settings and functionality
|
||||||
|
- **Calendar**: Add virtual location input for calendar events (#121)
|
||||||
|
- **Email**: Add reply-to addresses support in email composer
|
||||||
|
- **Email**: Add mail layout settings and update email list components
|
||||||
|
- **Email**: Add auto-select reply identity feature with settings and localization
|
||||||
|
- **Email**: Enhance compose functionality with button integration and translations
|
||||||
|
- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts
|
||||||
|
- **Filters**: Skip server-managed vacation script in Sieve script handling
|
||||||
|
- **Settings**: Add support for custom JMAP server endpoints in login and settings
|
||||||
|
- **Settings**: Add folder expansion state management and settings navigation
|
||||||
|
- **UI**: Add options to hide account switcher and show account avatars on navigation rail
|
||||||
|
- **i18n**: Add JMAP server endpoint labels and hints in multiple languages
|
||||||
|
- **i18n**: Add missing translation keys to all non-English locales
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Patch critical auth bypass and credential leak vulnerabilities
|
||||||
|
- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35)
|
||||||
|
- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35)
|
||||||
|
- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116)
|
||||||
|
- **Calendar**: Enhance calendar event handling to distinguish between events and tasks
|
||||||
|
- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113)
|
||||||
|
- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113)
|
||||||
|
- **Calendar**: Fix events disappearing after iCal import/subscription refresh
|
||||||
|
- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments
|
||||||
|
- **Calendar**: Use UTC timestamps for timed event rendering
|
||||||
|
- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query
|
||||||
|
- **Email**: Enhance email loading and deduplication logic in email store (#119)
|
||||||
|
- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60)
|
||||||
|
- **Email**: Match hover action background to selected row state
|
||||||
|
- **Email**: Align tag counts with mailbox folder counts in sidebar
|
||||||
|
- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117)
|
||||||
|
- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118)
|
||||||
|
- **UI**: Improve dark mode handling for media elements and background images
|
||||||
|
- **UI**: Adjust account list spacing and remove push connection indicator
|
||||||
|
- **UI**: Fix nested button in theme card
|
||||||
|
|
||||||
## 1.4.9 (2026-03-27)
|
## 1.4.9 (2026-03-27)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+1
-1
@@ -195,7 +195,7 @@ webmail/
|
|||||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||||
- **Sanitize user input** and email content
|
- **Sanitize user input** and email content
|
||||||
- **Block external content** by default for privacy
|
- **Block external content** by default for privacy
|
||||||
- Report security vulnerabilities privately
|
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
|
|||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||||
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
@@ -66,9 +67,9 @@ export default function CalendarPage() {
|
|||||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||||
refreshAllSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
@@ -82,6 +83,7 @@ export default function CalendarPage() {
|
|||||||
const [showEventModal, setShowEventModal] = useState(false);
|
const [showEventModal, setShowEventModal] = useState(false);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
|
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
@@ -337,18 +339,28 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
|
if (calendarHoverPreview === 'off') return;
|
||||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||||
// Don't show hover popover if the sidebar is already open for this event
|
// Don't show hover popover if the sidebar is already open for this event
|
||||||
if (showEventModal && editEvent?.id === event.id) return;
|
if (showEventModal && editEvent?.id === event.id) return;
|
||||||
setDetailEvent(event);
|
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
|
||||||
setDetailAnchorRect(anchorRect);
|
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
|
||||||
}, [isMobile, showEventModal, editEvent]);
|
hoverTimerRef.current = setTimeout(() => {
|
||||||
|
setDetailEvent(event);
|
||||||
|
setDetailAnchorRect(anchorRect);
|
||||||
|
}, ms);
|
||||||
|
} else {
|
||||||
|
setDetailEvent(event);
|
||||||
|
setDetailAnchorRect(anchorRect);
|
||||||
|
}
|
||||||
|
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
|
||||||
|
|
||||||
const handleHoverLeave = useCallback(() => {
|
const handleHoverLeave = useCallback(() => {
|
||||||
|
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||||
hoverTimerRef.current = setTimeout(() => {
|
hoverTimerRef.current = setTimeout(() => {
|
||||||
setDetailEvent(null);
|
setDetailEvent(null);
|
||||||
setDetailAnchorRect(null);
|
setDetailAnchorRect(null);
|
||||||
}, 200);
|
}, 300);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEditFromDetail = useCallback(() => {
|
const handleEditFromDetail = useCallback(() => {
|
||||||
@@ -385,12 +397,16 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [client, fetchEvents]);
|
}, [client, fetchEvents]);
|
||||||
|
|
||||||
const focusCalendarOnEvent = useCallback((event: Pick<CalendarEvent, "start">) => {
|
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||||
if (!event.start) {
|
if (!event.start) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventDate = parseISO(event.start);
|
const eventDate = getEventStartDate({
|
||||||
|
start: event.start,
|
||||||
|
utcStart: event.utcStart ?? null,
|
||||||
|
showWithoutTime: event.showWithoutTime ?? false,
|
||||||
|
});
|
||||||
if (Number.isNaN(eventDate.getTime())) {
|
if (Number.isNaN(eventDate.getTime())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -749,7 +765,7 @@ export default function CalendarPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('Calendar visibility summary', {
|
debug.log('calendar', 'Calendar visibility summary', {
|
||||||
totalEvents: events.length,
|
totalEvents: events.length,
|
||||||
visibleEvents: visibleEvents.length,
|
visibleEvents: visibleEvents.length,
|
||||||
hiddenEvents: hiddenEvents.length,
|
hiddenEvents: hiddenEvents.length,
|
||||||
@@ -873,7 +889,7 @@ export default function CalendarPage() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 flex flex-col overflow-hidden">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
{viewContent}
|
{viewContent}
|
||||||
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
||||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||||
@@ -933,6 +949,7 @@ export default function CalendarPage() {
|
|||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1099,6 +1116,18 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingSubscription && client && (() => {
|
||||||
|
const sub = icalSubscriptions.find(s => s.id === editingSubscription);
|
||||||
|
if (!sub) return null;
|
||||||
|
return (
|
||||||
|
<ICalSubscriptionModal
|
||||||
|
client={client}
|
||||||
|
editSubscription={sub}
|
||||||
|
onClose={() => setEditingSubscription(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<RecurrenceScopeDialog
|
<RecurrenceScopeDialog
|
||||||
isOpen={!!pendingScopeAction}
|
isOpen={!!pendingScopeAction}
|
||||||
|
|||||||
@@ -17,15 +17,18 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
|||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { FileBrowser } from "@/components/files/file-browser";
|
import { FileBrowser } from "@/components/files/file-browser";
|
||||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("files");
|
const t = useTranslations("files");
|
||||||
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
@@ -393,11 +396,24 @@ export default function FilesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
{supportsFiles === false ? (
|
{!filesEnabled ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="max-w-lg text-center space-y-3 px-4">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||||
|
<p className="text-sm font-medium">{t("disabled_title")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : supportsFiles === false ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
|
||||||
|
</div>
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
resources={resources}
|
resources={resources}
|
||||||
@@ -442,6 +458,7 @@ export default function FilesPage() {
|
|||||||
onToggleDetails={handleToggleDetails}
|
onToggleDetails={handleToggleDetails}
|
||||||
detailResource={detailResource}
|
detailResource={detailResource}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
|||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
|
|
||||||
const APP_VERSION = "1.4.9";
|
const APP_VERSION = "1.4.10";
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||||
@@ -32,13 +32,14 @@ export default function LoginPage() {
|
|||||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = useConfig();
|
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
|
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||||
const [totpCode, setTotpCode] = useState("");
|
const [totpCode, setTotpCode] = useState("");
|
||||||
const [showTotpField, setShowTotpField] = useState(false);
|
const [showTotpField, setShowTotpField] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
@@ -73,6 +74,12 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [appName, serverUrl]);
|
}, [appName, serverUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverUrl && !jmapEndpoint) {
|
||||||
|
setJmapEndpoint(serverUrl);
|
||||||
|
}
|
||||||
|
}, [serverUrl, jmapEndpoint]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||||
@@ -91,6 +98,14 @@ export default function LoginPage() {
|
|||||||
prevError.current = error;
|
prevError.current = error;
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
|
// Auto-show and focus TOTP field when server requires it
|
||||||
|
useEffect(() => {
|
||||||
|
if (error === 'totp_required') {
|
||||||
|
setShowTotpField(true);
|
||||||
|
setTimeout(() => totpInputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl) return;
|
if (!serverUrl) return;
|
||||||
const saved = localStorage.getItem("webmail_usernames");
|
const saved = localStorage.getItem("webmail_usernames");
|
||||||
@@ -264,7 +279,7 @@ export default function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serverUrl && !demoMode) {
|
if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||||
<div className="w-full max-w-md mx-auto px-4 text-center">
|
<div className="w-full max-w-md mx-auto px-4 text-center">
|
||||||
@@ -362,7 +377,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||||
sessionStorage.setItem("oauth_state", state);
|
sessionStorage.setItem("oauth_state", state);
|
||||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
|
||||||
if (isAddAccountMode) {
|
if (isAddAccountMode) {
|
||||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||||
}
|
}
|
||||||
@@ -382,8 +397,9 @@ export default function LoginPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
|
||||||
const success = await login(
|
const success = await login(
|
||||||
serverUrl,
|
effectiveServerUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
formData.password,
|
formData.password,
|
||||||
totpCode || undefined,
|
totpCode || undefined,
|
||||||
@@ -749,6 +765,27 @@ export default function LoginPage() {
|
|||||||
/* Login Form */
|
/* Login Form */
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<fieldset disabled={isLoading} className="space-y-4">
|
<fieldset disabled={isLoading} className="space-y-4">
|
||||||
|
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
|
||||||
|
{allowCustomJmapEndpoint && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
||||||
|
{t("jmap_endpoint_label")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="jmap-endpoint"
|
||||||
|
type="url"
|
||||||
|
value={jmapEndpoint}
|
||||||
|
onChange={(e) => setJmapEndpoint(e.target.value)}
|
||||||
|
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||||
|
placeholder={t("jmap_endpoint_placeholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{t("jmap_endpoint_cors_hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Username field */}
|
{/* Username field */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
||||||
@@ -861,7 +898,10 @@ export default function LoginPage() {
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||||
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
|
className={cn(
|
||||||
|
"h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest",
|
||||||
|
error === 'totp_required' && "border-primary ring-2 ring-primary/30"
|
||||||
|
)}
|
||||||
placeholder={t("totp_placeholder")}
|
placeholder={t("totp_placeholder")}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
aria-label={t("totp_label")}
|
aria-label={t("totp_label")}
|
||||||
|
|||||||
+54
-35
@@ -30,6 +30,7 @@ import {
|
|||||||
ComposerErrorFallback,
|
ComposerErrorFallback,
|
||||||
} from "@/components/error";
|
} from "@/components/error";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||||
@@ -53,6 +54,7 @@ export default function Home() {
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { appName } = useConfig();
|
const { appName } = useConfig();
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const [showComposer, setShowComposer] = useState(false);
|
const [showComposer, setShowComposer] = useState(false);
|
||||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||||
const [composerDraftText, setComposerDraftText] = useState("");
|
const [composerDraftText, setComposerDraftText] = useState("");
|
||||||
@@ -350,13 +352,13 @@ export default function Home() {
|
|||||||
|
|
||||||
if (pushEnabled) {
|
if (pushEnabled) {
|
||||||
setPushConnected(true);
|
setPushConnected(true);
|
||||||
debug.log('[Push] Push notifications successfully enabled');
|
debug.log('push', '[Push] Push notifications successfully enabled');
|
||||||
} else {
|
} else {
|
||||||
debug.log('[Push] Push notifications not available on this server');
|
debug.log('push', '[Push] Push notifications not available on this server');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Push notifications are optional - don't break the app if they fail
|
// Push notifications are optional - don't break the app if they fail
|
||||||
debug.log('[Push] Failed to setup push notifications:', error);
|
debug.log('push', '[Push] Failed to setup push notifications:', error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading email data:', error);
|
console.error('Error loading email data:', error);
|
||||||
@@ -390,7 +392,7 @@ export default function Home() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Clear any existing timeout when email changes
|
// Clear any existing timeout when email changes
|
||||||
if (markAsReadTimeoutRef.current) {
|
if (markAsReadTimeoutRef.current) {
|
||||||
debug.log('[Mark as Read] Clearing previous timeout');
|
debug.log('email', '[Mark as Read] Clearing previous timeout');
|
||||||
clearTimeout(markAsReadTimeoutRef.current);
|
clearTimeout(markAsReadTimeoutRef.current);
|
||||||
markAsReadTimeoutRef.current = null;
|
markAsReadTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -402,20 +404,20 @@ export default function Home() {
|
|||||||
|
|
||||||
// Get current setting value
|
// Get current setting value
|
||||||
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
||||||
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
debug.log('email', '[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
||||||
|
|
||||||
if (markAsReadDelay === -1) {
|
if (markAsReadDelay === -1) {
|
||||||
// Never mark as read automatically
|
// Never mark as read automatically
|
||||||
debug.log('[Mark as Read] Never mode - email will stay unread');
|
debug.log('email', '[Mark as Read] Never mode - email will stay unread');
|
||||||
} else if (markAsReadDelay === 0) {
|
} else if (markAsReadDelay === 0) {
|
||||||
// Mark as read instantly
|
// Mark as read instantly
|
||||||
debug.log('[Mark as Read] Instant mode - marking as read now');
|
debug.log('email', '[Mark as Read] Instant mode - marking as read now');
|
||||||
markAsRead(client, selectedEmail.id, true);
|
markAsRead(client, selectedEmail.id, true);
|
||||||
} else {
|
} else {
|
||||||
// Mark as read after delay
|
// Mark as read after delay
|
||||||
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
|
debug.log('email', '[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
|
||||||
markAsReadTimeoutRef.current = setTimeout(() => {
|
markAsReadTimeoutRef.current = setTimeout(() => {
|
||||||
debug.log('[Mark as Read] Timeout fired - marking as read now');
|
debug.log('email', '[Mark as Read] Timeout fired - marking as read now');
|
||||||
markAsRead(client, selectedEmail.id, true);
|
markAsRead(client, selectedEmail.id, true);
|
||||||
markAsReadTimeoutRef.current = null;
|
markAsReadTimeoutRef.current = null;
|
||||||
}, markAsReadDelay);
|
}, markAsReadDelay);
|
||||||
@@ -424,7 +426,7 @@ export default function Home() {
|
|||||||
// Cleanup on unmount or when dependencies change
|
// Cleanup on unmount or when dependencies change
|
||||||
return () => {
|
return () => {
|
||||||
if (markAsReadTimeoutRef.current) {
|
if (markAsReadTimeoutRef.current) {
|
||||||
debug.log('[Mark as Read] Cleanup - clearing timeout');
|
debug.log('email', '[Mark as Read] Cleanup - clearing timeout');
|
||||||
clearTimeout(markAsReadTimeoutRef.current);
|
clearTimeout(markAsReadTimeoutRef.current);
|
||||||
markAsReadTimeoutRef.current = null;
|
markAsReadTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -439,7 +441,7 @@ export default function Home() {
|
|||||||
if (emailNotificationsEnabled && emailNotificationSound) {
|
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||||
playNotificationSound(notificationSoundChoice);
|
playNotificationSound(notificationSoundChoice);
|
||||||
}
|
}
|
||||||
debug.log('New email received:', newEmailNotification.subject);
|
debug.log('email', 'New email received:', newEmailNotification.subject);
|
||||||
clearNewEmailNotification();
|
clearNewEmailNotification();
|
||||||
}
|
}
|
||||||
}, [newEmailNotification, clearNewEmailNotification]);
|
}, [newEmailNotification, clearNewEmailNotification]);
|
||||||
@@ -519,7 +521,10 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleEditDraft = async (email?: Email) => {
|
const handleEditDraft = async (email?: Email) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
let draft = email || selectedEmail;
|
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
|
||||||
|
? email
|
||||||
|
: selectedEmail;
|
||||||
|
let draft = draftCandidate;
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
|
|
||||||
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
|
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
|
||||||
@@ -967,6 +972,10 @@ export default function Home() {
|
|||||||
|
|
||||||
// Get current mailbox name for mobile header
|
// Get current mailbox name for mobile header
|
||||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
|
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||||
|
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
||||||
|
|
||||||
// Handle email selection with mobile view switching
|
// Handle email selection with mobile view switching
|
||||||
const handleEmailSelect = async (email: { id: string }) => {
|
const handleEmailSelect = async (email: { id: string }) => {
|
||||||
@@ -1017,6 +1026,9 @@ export default function Home() {
|
|||||||
setConversationEmails([]);
|
setConversationEmails([]);
|
||||||
}
|
}
|
||||||
selectEmail(null);
|
selectEmail(null);
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
setActiveView("list");
|
setActiveView("list");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1199,10 +1211,9 @@ export default function Home() {
|
|||||||
// Tablet/Desktop: fixed width with collapse animation
|
// Tablet/Desktop: fixed width with collapse animation
|
||||||
"md:flex-shrink-0 md:shadow-sm",
|
"md:flex-shrink-0 md:shadow-sm",
|
||||||
!isResizing && "transition-all duration-200 ease-out",
|
!isResizing && "transition-all duration-200 ease-out",
|
||||||
// Tablet: collapse when email selected
|
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||||
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
|
||||||
)}
|
)}
|
||||||
style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined}
|
style={!isMobile && !shouldCollapseListPane ? { width: emailListWidth } : undefined}
|
||||||
>
|
>
|
||||||
{/* Mobile Header for List View */}
|
{/* Mobile Header for List View */}
|
||||||
<MobileHeader
|
<MobileHeader
|
||||||
@@ -1487,24 +1498,27 @@ export default function Home() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Compose Button (mobile) */}
|
{/* Floating Compose Button */}
|
||||||
{isMobile && (
|
<Button
|
||||||
<Button
|
onClick={() => {
|
||||||
onClick={() => {
|
setComposerMode('compose');
|
||||||
setComposerMode('compose');
|
setShowComposer(true);
|
||||||
setShowComposer(true);
|
if (isMobile) setActiveView('viewer');
|
||||||
setActiveView('viewer');
|
}}
|
||||||
}}
|
className={cn(
|
||||||
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
|
"absolute z-40 rounded-full shadow-lg",
|
||||||
aria-label={t('sidebar.compose')}
|
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
|
||||||
>
|
)}
|
||||||
<PenSquare className="h-6 w-6" />
|
aria-label={t('sidebar.compose')}
|
||||||
</Button>
|
title={t('sidebar.compose_hint')}
|
||||||
)}
|
data-tour="compose-button"
|
||||||
|
>
|
||||||
|
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email list resize handle (desktop only) */}
|
{/* Email list resize handle (desktop only) */}
|
||||||
{!isMobile && !isTablet && (
|
{!isMobile && !isTablet && !isFocusedMailLayout && (
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||||
@@ -1521,7 +1535,8 @@ export default function Home() {
|
|||||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||||
// Tablet/Desktop: relative
|
// Tablet/Desktop: relative
|
||||||
"md:relative"
|
"md:relative",
|
||||||
|
shouldHideViewerPane && "md:hidden"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Inline Composer - shown in viewer pane */}
|
{/* Inline Composer - shown in viewer pane */}
|
||||||
@@ -1537,8 +1552,10 @@ export default function Home() {
|
|||||||
mode={pendingDraft?.mode ?? composerMode}
|
mode={pendingDraft?.mode ?? composerMode}
|
||||||
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
||||||
from: selectedEmail.from,
|
from: selectedEmail.from,
|
||||||
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
to: selectedEmail.to,
|
to: selectedEmail.to,
|
||||||
cc: selectedEmail.cc,
|
cc: selectedEmail.cc,
|
||||||
|
bcc: selectedEmail.bcc,
|
||||||
subject: selectedEmail.subject,
|
subject: selectedEmail.subject,
|
||||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||||
@@ -1648,14 +1665,15 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
onQuickReply={handleQuickReply}
|
onQuickReply={handleQuickReply}
|
||||||
onBack={() => {
|
onBack={handleMobileBack}
|
||||||
setTabletListVisible(true);
|
|
||||||
selectEmail(null);
|
|
||||||
}}
|
|
||||||
onNavigateNext={handleNavigateNext}
|
onNavigateNext={handleNavigateNext}
|
||||||
onNavigatePrev={handleNavigatePrev}
|
onNavigatePrev={handleNavigatePrev}
|
||||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||||
onEditDraft={handleEditDraft}
|
onEditDraft={handleEditDraft}
|
||||||
|
onCompose={() => {
|
||||||
|
setComposerMode('compose');
|
||||||
|
setShowComposer(true);
|
||||||
|
}}
|
||||||
currentUserEmail={client?.getUsername()}
|
currentUserEmail={client?.getUsername()}
|
||||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||||
@@ -1709,6 +1727,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
<TotpReauthDialog />
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
@@ -78,13 +79,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
function getJmapHeaders(): Record<string, string> {
|
function getJmapHeaders(): Record<string, string> {
|
||||||
const client = useAuthStore.getState().client;
|
return getActiveAccountSlotHeaders();
|
||||||
if (!client) return {};
|
|
||||||
return {
|
|
||||||
'Authorization': client.getAuthHeader(),
|
|
||||||
'X-JMAP-Server-URL': client.getServerUrl(),
|
|
||||||
'X-JMAP-Username': client.getUsername(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface ConfigField {
|
||||||
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginConfig {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
type: string;
|
||||||
|
permissions: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
configSchema?: Record<string, ConfigField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginConfigPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const pluginId = params.id as string;
|
||||||
|
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
|
||||||
|
const [config, setConfig] = useState<PluginConfig>({});
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pluginId]);
|
||||||
|
|
||||||
|
// Initialize form values from config + schema defaults when data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!plugin?.configSchema) return;
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
const stored = config[key];
|
||||||
|
if (stored !== undefined && stored !== null) {
|
||||||
|
initial[key] = String(stored);
|
||||||
|
} else if (field.default !== undefined) {
|
||||||
|
initial[key] = String(field.default);
|
||||||
|
} else {
|
||||||
|
initial[key] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFormValues(initial);
|
||||||
|
}, [plugin, config]);
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [pluginsRes, configRes] = await Promise.all([
|
||||||
|
fetch('/api/admin/plugins'),
|
||||||
|
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (pluginsRes.ok) {
|
||||||
|
const plugins: PluginInfo[] = await pluginsRes.json();
|
||||||
|
setPlugin(plugins.find(p => p.id === pluginId) || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configRes.ok) {
|
||||||
|
setConfig(await configRes.json());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAll() {
|
||||||
|
if (!plugin?.configSchema) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
if (field.required && !formValues[key]?.trim()) {
|
||||||
|
setMessage({ type: 'error', text: `"${field.label}" is required` });
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save each changed field
|
||||||
|
let hasError = false;
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
const newVal = formValues[key] ?? '';
|
||||||
|
const oldVal = config[key] !== undefined ? String(config[key]) : '';
|
||||||
|
|
||||||
|
// Skip unchanged fields (and skip secret fields that show as empty when they have a stored value)
|
||||||
|
if (newVal === oldVal) continue;
|
||||||
|
if (field.type === 'secret' && !newVal && config[key]) continue;
|
||||||
|
|
||||||
|
// Convert types
|
||||||
|
let value: unknown = newVal;
|
||||||
|
if (field.type === 'boolean') value = newVal === 'true';
|
||||||
|
else if (field.type === 'number') value = Number(newVal);
|
||||||
|
|
||||||
|
// Delete if clearing a non-required field
|
||||||
|
if (!newVal && !field.required) {
|
||||||
|
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => ({ ...prev, [key]: value }));
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(hasError
|
||||||
|
? { type: 'error', text: 'Some settings failed to save' }
|
||||||
|
: { type: 'success', text: 'Configuration saved' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save configuration' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back to Plugins
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = plugin.configSchema;
|
||||||
|
const hasSchema = schema && Object.keys(schema).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Puzzle className="w-5 h-5" />
|
||||||
|
{plugin.name} Configuration
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
v{plugin.version} by {plugin.author}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSchema ? (
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-5">
|
||||||
|
{Object.entries(schema).map(([key, field]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="text-sm font-medium text-foreground block mb-1">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'boolean' ? (
|
||||||
|
<select
|
||||||
|
value={formValues[key] ?? String(field.default ?? 'false')}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="true">Enabled</option>
|
||||||
|
<option value="false">Disabled</option>
|
||||||
|
</select>
|
||||||
|
) : field.type === 'select' && field.options ? (
|
||||||
|
<select
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
{field.options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : field.type === 'secret' ? (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={revealSecrets[key] ? 'text' : 'password'}
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
||||||
|
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
|
||||||
|
>
|
||||||
|
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder || ''}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-border rounded-lg p-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
|
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
|
||||||
@@ -396,6 +397,13 @@ export default function AdminPluginsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/plugins/${plugin.id}`}
|
||||||
|
title="Configure"
|
||||||
|
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||||
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
|||||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||||
|
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESTRICTABLE_SETTINGS = [
|
const RESTRICTABLE_SETTINGS = [
|
||||||
@@ -28,10 +29,12 @@ const RESTRICTABLE_SETTINGS = [
|
|||||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||||
|
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
||||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||||
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||||
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
||||||
|
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
||||||
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
||||||
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||||
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ export default function AdminSettingsPage() {
|
|||||||
<SettingsSection title="General">
|
<SettingsSection title="General">
|
||||||
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||||
|
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||||
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||||
|
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||||
|
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
||||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { encryptSession } from '@/lib/auth/crypto';
|
import { encryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -69,10 +70,19 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If session cookie exists, update it with the new password
|
// If session cookie exists, update it with the new password
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
if (creds.hasSessionCookie) {
|
if (creds.hasSessionCookie) {
|
||||||
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
||||||
const cookieStore = await cookies();
|
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
|
||||||
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
|
}
|
||||||
|
|
||||||
|
if (creds.authHeader.startsWith('Basic ')) {
|
||||||
|
setStalwartAuthContextInStore(cookieStore, creds.slot, {
|
||||||
|
serverUrl: creds.serverUrl,
|
||||||
|
username: creds.username,
|
||||||
|
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const code = await jsFile.async('string');
|
const code = await jsFile.async('string');
|
||||||
|
|
||||||
|
// Block plugins with dangerous JS patterns
|
||||||
|
const DANGEROUS_JS_PATTERNS = [
|
||||||
|
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||||
|
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||||
|
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||||
|
{ pattern: /document\.write/g, label: 'document.write' },
|
||||||
|
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||||
|
];
|
||||||
|
const dangerousFindings: string[] = [];
|
||||||
|
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
|
||||||
|
if (pattern.test(code)) dangerousFindings.push(label);
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
}
|
||||||
|
if (dangerousFindings.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate permissions
|
// Validate permissions
|
||||||
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
||||||
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
|
||||||
|
*
|
||||||
|
* Returns the full config object for admin-configured plugin settings.
|
||||||
|
* This endpoint is accessible from the client-side plugin API.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getPluginConfig(id);
|
||||||
|
return NextResponse.json(config, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/plugins/[id]/config — Set a config key
|
||||||
|
*
|
||||||
|
* Body: { key: string, value: unknown }
|
||||||
|
* Requires admin authentication (checked via admin session).
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { key?: string; value?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'key is required and must be a string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key format (alphanumeric, hyphens, underscores, dots)
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(body.key)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setPluginConfig(id, body.key, body.value);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/plugins/[id]/config — Delete a config key
|
||||||
|
*
|
||||||
|
* Body: { key: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { key?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'key is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deletePluginConfigKey(id, body.key);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,12 +139,18 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
const code = await entryFile.async('string');
|
const code = await entryFile.async('string');
|
||||||
|
|
||||||
// Security warnings (logged but not blocking for admin)
|
// Security: block plugins containing dangerous JS patterns
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||||
pattern.lastIndex = 0;
|
pattern.lastIndex = 0;
|
||||||
}
|
}
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
@@ -157,14 +163,17 @@ export async function POST(request: NextRequest) {
|
|||||||
permissions: (manifest.permissions as string[]) || [],
|
permissions: (manifest.permissions as string[]) || [],
|
||||||
entrypoint: manifest.entrypoint as string,
|
entrypoint: manifest.entrypoint as string,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip);
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ plugin, warnings });
|
return NextResponse.json({ plugin });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { logger } from '@/lib/logger';
|
|||||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
|
import {
|
||||||
|
clearStalwartAuthContextInStore,
|
||||||
|
setStalwartAuthContextInStore,
|
||||||
|
} from '@/lib/stalwart/auth-context';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
const cookieName = sessionCookieName(slot);
|
const cookieName = sessionCookieName(slot);
|
||||||
const token = encryptSession(serverUrl, username, password);
|
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||||
|
const token = encryptSession(normalizedServerUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: normalizedServerUrl,
|
||||||
|
username,
|
||||||
|
authHeader,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
@@ -56,9 +72,66 @@ export async function GET(request: NextRequest) {
|
|||||||
const credentials = decryptSession(token);
|
const credentials = decryptSession(token);
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
cookieStore.delete(cookieName);
|
cookieStore.delete(cookieName);
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: credentials.serverUrl,
|
||||||
|
username: credentials.username,
|
||||||
|
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
||||||
|
const { serverUrl, username } = credentials;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ serverUrl, username },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT — retrieve full credentials (including password) for session restoration.
|
||||||
|
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
||||||
|
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Require all Sec-Fetch-* headers to match a same-origin fetch() call.
|
||||||
|
// Browsers set these automatically and they cannot be overridden by JS.
|
||||||
|
const secFetchSite = request.headers.get('sec-fetch-site');
|
||||||
|
const secFetchMode = request.headers.get('sec-fetch-mode');
|
||||||
|
const secFetchDest = request.headers.get('sec-fetch-dest');
|
||||||
|
if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = decryptSession(token);
|
||||||
|
if (!credentials) {
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: credentials.serverUrl,
|
||||||
|
username: credentials.username,
|
||||||
|
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(credentials, {
|
return NextResponse.json(credentials, {
|
||||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
||||||
});
|
});
|
||||||
@@ -77,10 +150,12 @@ export async function DELETE(request: NextRequest) {
|
|||||||
// Delete all session cookies (slots 0-4)
|
// Delete all session cookies (slots 0-4)
|
||||||
for (let i = 0; i <= 4; i++) {
|
for (let i = 0; i <= 4; i++) {
|
||||||
cookieStore.delete(sessionCookieName(i));
|
cookieStore.delete(sessionCookieName(i));
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, i);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const slot = getSlot(request);
|
const slot = getSlot(request);
|
||||||
cookieStore.delete(sessionCookieName(slot));
|
cookieStore.delete(sessionCookieName(slot));
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
|
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
|
|
||||||
|
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||||
|
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||||
|
return bodySlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
|
if (raw === null) return 0;
|
||||||
|
|
||||||
|
const slot = parseInt(raw, 10);
|
||||||
|
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
|
if (!serverUrl || !username || !authHeader) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request, bodySlot);
|
||||||
|
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||||
|
|
||||||
|
await setStalwartAuthContext(slot, {
|
||||||
|
serverUrl: normalizedServerUrl,
|
||||||
|
username,
|
||||||
|
authHeader,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Failed to store Stalwart auth context', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
|
*
|
||||||
|
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
||||||
|
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
||||||
|
*
|
||||||
|
* Tries three strategies:
|
||||||
|
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
||||||
|
* 2. ROPC grant without client_id
|
||||||
|
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function tryTokenRequest(
|
||||||
|
tokenEndpoint: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
extraHeaders?: Record<string, string>,
|
||||||
|
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
||||||
|
const response = await fetch(tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await response.json();
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
return { ok: false, status: 502, error: 'Response missing access_token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, tokens };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||||
|
// 1. Try OAuth discovery
|
||||||
|
const metadata = await discoverOAuth(serverUrl);
|
||||||
|
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||||
|
|
||||||
|
// 2. Try common Stalwart token endpoint paths directly
|
||||||
|
const candidates = [
|
||||||
|
`${serverUrl}/auth/token`,
|
||||||
|
`${serverUrl}/api/oauth/token`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
||||||
|
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
||||||
|
if (probe.status !== 404 && probe.status !== 405) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error — endpoint not reachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
|
if (!serverUrl || !username || !password) {
|
||||||
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||||
|
|
||||||
|
// Use the server-side JMAP_SERVER_URL if set (may differ from the
|
||||||
|
// public URL the browser uses, e.g. inside Docker).
|
||||||
|
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
|
||||||
|
|
||||||
|
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
|
||||||
|
if (!tokenEndpoint) {
|
||||||
|
// Also try with the client-provided URL in case the internal one differs
|
||||||
|
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
|
||||||
|
if (!clientEndpoint) {
|
||||||
|
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
|
||||||
|
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return await attemptAllStrategies(clientEndpoint, username, password, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptAllStrategies(
|
||||||
|
tokenEndpoint: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
slot: number,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
|
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
||||||
|
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
|
// Strategy 1: ROPC with client_id (if configured)
|
||||||
|
if (clientId) {
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
||||||
|
if (clientSecret) params.set('client_secret', clientSecret);
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: ROPC without client_id
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password' });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: client_credentials with Basic Auth (last resort)
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'token_exchange_failed',
|
||||||
|
detail: 'All token exchange strategies failed',
|
||||||
|
attempts,
|
||||||
|
}, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeAndRespond(
|
||||||
|
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||||
|
slot: number,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
expires_in: tokens.expires_in || 3600,
|
||||||
|
has_refresh_token: !!tokens.refresh_token,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ export async function GET() {
|
|||||||
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||||
demoMode: configManager.get<boolean>('demoMode', false),
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||||
|
|||||||
+86
-21
@@ -1,9 +1,39 @@
|
|||||||
|
import { lookup } from 'node:dns/promises';
|
||||||
|
import { BlockList, isIP } from 'node:net';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
function isValidExternalUrl(urlString: string): boolean {
|
const blockedAddressRanges = new BlockList();
|
||||||
|
blockedAddressRanges.addAddress('0.0.0.0');
|
||||||
|
blockedAddressRanges.addAddress('127.0.0.1');
|
||||||
|
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
||||||
|
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
||||||
|
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
||||||
|
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
||||||
|
blockedAddressRanges.addAddress('::', 'ipv6');
|
||||||
|
blockedAddressRanges.addAddress('::1', 'ipv6');
|
||||||
|
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
||||||
|
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
||||||
|
|
||||||
|
function normalizeHostname(hostname: string): string {
|
||||||
|
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpAddress(hostname: string): boolean {
|
||||||
|
const normalized = normalizeHostname(hostname);
|
||||||
|
const family = isIP(normalized);
|
||||||
|
if (family === 4) {
|
||||||
|
return blockedAddressRanges.check(normalized, 'ipv4');
|
||||||
|
}
|
||||||
|
if (family === 6) {
|
||||||
|
return blockedAddressRanges.check(normalized, 'ipv6');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isValidExternalUrl(urlString: string): Promise<boolean> {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
url = new URL(urlString);
|
url = new URL(urlString);
|
||||||
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostname = url.hostname.toLowerCase();
|
const hostname = normalizeHostname(url.hostname);
|
||||||
|
|
||||||
// Block private/internal hostnames
|
// Block private/internal hostnames
|
||||||
if (
|
if (
|
||||||
hostname === 'localhost' ||
|
hostname === 'localhost' ||
|
||||||
hostname === '127.0.0.1' ||
|
hostname.endsWith('.localhost') ||
|
||||||
hostname === '::1' ||
|
|
||||||
hostname === '0.0.0.0' ||
|
|
||||||
hostname.endsWith('.local') ||
|
hostname.endsWith('.local') ||
|
||||||
hostname.endsWith('.internal') ||
|
hostname.endsWith('.internal') ||
|
||||||
hostname.endsWith('.arpa') ||
|
hostname.endsWith('.arpa') ||
|
||||||
hostname.startsWith('10.') ||
|
hostname.endsWith('.localdomain')
|
||||||
hostname.startsWith('192.168.') ||
|
|
||||||
hostname.startsWith('169.254.') ||
|
|
||||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
if (isBlockedIpAddress(hostname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIP(hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const records = await lookup(hostname, { all: true, verbatim: true });
|
||||||
|
if (records.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return records.every((record) => !isBlockedIpAddress(record.address));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidExternalUrl(url)) {
|
if (!(await isValidExternalUrl(url))) {
|
||||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,20 +106,43 @@ export async function POST(request: NextRequest) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const MAX_REDIRECTS = 5;
|
||||||
signal: controller.signal,
|
let currentUrl = url;
|
||||||
headers: {
|
let response: Response | undefined;
|
||||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
|
||||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||||
},
|
if (!(await isValidExternalUrl(currentUrl))) {
|
||||||
redirect: 'follow',
|
clearTimeout(timeout);
|
||||||
});
|
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await fetch(currentUrl, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||||
|
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||||
|
},
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (!location) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||||
|
}
|
||||||
|
// Resolve relative redirects
|
||||||
|
currentUrl = new URL(location, currentUrl).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response || !response.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Remote server returned ${response.status}` },
|
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,16 +53,14 @@ function isEnabled(): boolean {
|
|||||||
/**
|
/**
|
||||||
* Verify identity against session cookies across all account slots.
|
* Verify identity against session cookies across all account slots.
|
||||||
* With multi-account, the requesting account may be on any slot (0-4).
|
* 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.
|
* Returns true only if a matching session cookie is found.
|
||||||
*/
|
*/
|
||||||
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
let hasAnyCookie = false;
|
|
||||||
|
|
||||||
for (let slot = 0; slot <= 4; slot++) {
|
for (let slot = 0; slot <= 4; slot++) {
|
||||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||||
if (!token) continue;
|
if (!token) continue;
|
||||||
hasAnyCookie = true;
|
|
||||||
|
|
||||||
const session = decryptSession(token);
|
const session = decryptSession(token);
|
||||||
if (session && session.username === username && session.serverUrl === serverUrl) {
|
if (session && session.username === username && session.serverUrl === serverUrl) {
|
||||||
@@ -70,10 +68,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No cookies at all → can't verify, allow (same-origin protection applies)
|
// No matching session found (or no cookies at all) → reject
|
||||||
if (!hasAnyCookie) return true;
|
|
||||||
|
|
||||||
// Cookies exist but none matched → identity mismatch
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-8
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|||||||
|
|
||||||
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
|
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
|
||||||
|
|
||||||
|
function normalizeDavRelativePath(rawPath: string): string {
|
||||||
|
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
|
||||||
|
const segments = sanitized.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
return segments.map((segment) => {
|
||||||
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(segment);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid WebDAV path encoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
|
||||||
|
throw new Error('Invalid WebDAV path segment');
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeURIComponent(decoded);
|
||||||
|
}).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
|
||||||
|
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
|
||||||
|
const relativePath = normalizeDavRelativePath(rawPath);
|
||||||
|
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/webdav
|
* POST /api/webdav
|
||||||
* Proxies WebDAV requests to the Stalwart server.
|
* Proxies WebDAV requests to the Stalwart server.
|
||||||
@@ -29,11 +55,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
||||||
const cleanPath = davPath.replace(/^\/+/, '');
|
|
||||||
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
||||||
const targetUrl = cleanPath
|
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
||||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
|
|
||||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
|
||||||
|
|
||||||
// Build headers for the upstream request
|
// Build headers for the upstream request
|
||||||
const upstreamHeaders: Record<string, string> = {
|
const upstreamHeaders: Record<string, string> = {
|
||||||
@@ -50,10 +73,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// For MOVE/COPY, construct the full Destination URL from the relative path
|
// For MOVE/COPY, construct the full Destination URL from the relative path
|
||||||
const destination = request.headers.get('X-WebDAV-Destination');
|
const destination = request.headers.get('X-WebDAV-Destination');
|
||||||
if (destination) {
|
if (destination) {
|
||||||
const cleanDest = destination.replace(/^\/+/, '');
|
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
|
||||||
upstreamHeaders['Destination'] = cleanDest
|
|
||||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
|
|
||||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const overwrite = request.headers.get('Overwrite');
|
const overwrite = request.headers.get('Overwrite');
|
||||||
@@ -104,6 +124,10 @@ export async function POST(request: NextRequest) {
|
|||||||
status: response.status,
|
status: response.status,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useMemo, useRef, useEffect, useCallback } from "react";
|
import { useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, parseISO, isToday, isTomorrow, startOfDay } from "date-fns";
|
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
||||||
import { MapPin, Users } from "lucide-react";
|
import { MapPin, Users } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export function CalendarAgendaView({
|
|||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const sorted = [...events].sort((a, b) =>
|
const sorted = [...events].sort((a, b) =>
|
||||||
parseISO(a.start).getTime() - parseISO(b.start).getTime()
|
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups: DayGroup[] = [];
|
const groups: DayGroup[] = [];
|
||||||
@@ -144,9 +144,9 @@ export function CalendarAgendaView({
|
|||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||||
const color = getEventColor(ev, calendar);
|
const color = getEventColor(ev, calendar);
|
||||||
const start = parseISO(ev.start);
|
const start = getEventStartDate(ev);
|
||||||
const durMin = parseDuration(ev.duration);
|
const durMin = parseDuration(ev.duration);
|
||||||
const end = new Date(start.getTime() + durMin * 60000);
|
const end = getEventEndDate(ev);
|
||||||
const locationName = ev.locations
|
const locationName = ev.locations
|
||||||
? Object.values(ev.locations)[0]?.name
|
? Object.values(ev.locations)[0]?.name
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
|
|||||||
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
import type { PendingEventPreview } from "./event-modal";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
@@ -66,7 +66,7 @@ export function CalendarDayView({
|
|||||||
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
||||||
if (!spansThisDay) return;
|
if (!spansThisDay) return;
|
||||||
|
|
||||||
if (ev.showWithoutTime) allDay.push(ev);
|
if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
|
||||||
else timed.push(ev);
|
else timed.push(ev);
|
||||||
} catch { /* skip invalid dates */ }
|
} catch { /* skip invalid dates */ }
|
||||||
});
|
});
|
||||||
@@ -127,10 +127,10 @@ export function CalendarDayView({
|
|||||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
<div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
||||||
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
||||||
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
||||||
{isMobile
|
{isMobile
|
||||||
@@ -200,7 +200,7 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
@@ -241,11 +241,9 @@ export function CalendarDayView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, ListTodo, RefreshCw, Share2, Trash2 } from "lucide-react";
|
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
@@ -18,6 +18,7 @@ interface CalendarSidebarPanelProps {
|
|||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
client?: IJMAPClient | null;
|
client?: IJMAPClient | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export function CalendarSidebarPanel({
|
|||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
@@ -173,6 +175,16 @@ export function CalendarSidebarPanel({
|
|||||||
ref={contextMenuRef}
|
ref={contextMenuRef}
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setContextMenuCalId(null);
|
||||||
|
onEditSubscription?.(sub.id);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
{tSub('edit')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRefreshSubscription(sub.id)}
|
onClick={() => handleRefreshSubscription(sub.id)}
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
import type { PendingEventPreview } from "./event-modal";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
@@ -77,7 +77,7 @@ export function CalendarWeekView({
|
|||||||
const cursor = new Date(startDay);
|
const cursor = new Date(startDay);
|
||||||
while (cursor <= endDay) {
|
while (cursor <= endDay) {
|
||||||
const key = format(cursor, "yyyy-MM-dd");
|
const key = format(cursor, "yyyy-MM-dd");
|
||||||
if (!ev.showWithoutTime) {
|
if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
|
||||||
const arr = timed.get(key) || [];
|
const arr = timed.get(key) || [];
|
||||||
arr.push(ev);
|
arr.push(ev);
|
||||||
timed.set(key, arr);
|
timed.set(key, arr);
|
||||||
@@ -89,10 +89,18 @@ export function CalendarWeekView({
|
|||||||
return timed;
|
return timed;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const allDaySegments = useMemo(() => buildWeekSegments(
|
const allDaySegments = useMemo(() => {
|
||||||
events.filter((event) => event.showWithoutTime),
|
const explicitAllDay = buildWeekSegmentsRaw(
|
||||||
weekDays,
|
events.filter((event) => event.showWithoutTime),
|
||||||
), [events, weekDays]);
|
weekDays,
|
||||||
|
);
|
||||||
|
const timedFullDay = buildTimedFullDayWeekSegments(
|
||||||
|
events.filter((event) => !event.showWithoutTime),
|
||||||
|
weekDays,
|
||||||
|
);
|
||||||
|
|
||||||
|
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
|
||||||
|
}, [events, weekDays]);
|
||||||
|
|
||||||
const allDayRowCount = useMemo(() => {
|
const allDayRowCount = useMemo(() => {
|
||||||
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||||
@@ -189,13 +197,13 @@ export function CalendarWeekView({
|
|||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col flex-1",
|
"flex min-h-0 min-w-0 flex-col flex-1",
|
||||||
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
|
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
|
||||||
)}
|
)}
|
||||||
role="grid"
|
role="grid"
|
||||||
aria-label={t("views.week")}
|
aria-label={t("views.week")}
|
||||||
>
|
>
|
||||||
<div className={cn("flex flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
<div
|
<div
|
||||||
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
||||||
@@ -321,7 +329,7 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
|
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
@@ -344,7 +352,7 @@ export function CalendarWeekView({
|
|||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = timedEvents.get(key) || [];
|
const dayEvents = timedEvents.get(key) || [];
|
||||||
const todayCol = isToday(day);
|
const todayCol = isToday(day);
|
||||||
const layouted = layoutOverlappingEvents(dayEvents);
|
const layouted = layoutOverlappingEvents(dayEvents, day);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -371,11 +379,9 @@ export function CalendarWeekView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
@@ -68,14 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
|
|
||||||
const calendarName = calendar?.name || "";
|
const calendarName = calendar?.name || "";
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
|
const endTime = getEventEndDate(event);
|
||||||
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
||||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -138,9 +139,9 @@ export function EventDetailPopover({
|
|||||||
const [isSavingNote, setIsSavingNote] = useState(false);
|
const [isSavingNote, setIsSavingNote] = useState(false);
|
||||||
|
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
const endDate = getEventEndDate(event);
|
||||||
|
|
||||||
const locationName = useMemo(() => {
|
const locationName = useMemo(() => {
|
||||||
if (!event.locations) return null;
|
if (!event.locations) return null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
|
|||||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput } from "./participant-input";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getStatusCounts,
|
getStatusCounts,
|
||||||
buildParticipantMap,
|
buildParticipantMap,
|
||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
@@ -159,7 +160,7 @@ export function EventModal({
|
|||||||
}, [event, existingParticipants]);
|
}, [event, existingParticipants]);
|
||||||
|
|
||||||
const getInitialStart = (): Date => {
|
const getInitialStart = (): Date => {
|
||||||
if (event?.start) return parseISO(event.start);
|
if (event?.start) return getEventStartDate(event);
|
||||||
if (defaultDate) {
|
if (defaultDate) {
|
||||||
const d = new Date(defaultDate);
|
const d = new Date(defaultDate);
|
||||||
if (defaultEndDate) return d;
|
if (defaultEndDate) return d;
|
||||||
@@ -177,9 +178,7 @@ export function EventModal({
|
|||||||
if (event.showWithoutTime) {
|
if (event.showWithoutTime) {
|
||||||
return getEventDisplayEndDate(event);
|
return getEventDisplayEndDate(event);
|
||||||
}
|
}
|
||||||
const s = parseISO(event.start);
|
return getEventEndDate(event);
|
||||||
const dur = parseDuration(event.duration);
|
|
||||||
return new Date(s.getTime() + dur * 60000);
|
|
||||||
}
|
}
|
||||||
if (defaultEndDate) return new Date(defaultEndDate);
|
if (defaultEndDate) return new Date(defaultEndDate);
|
||||||
return addHours(getInitialStart(), 1);
|
return addHours(getInitialStart(), 1);
|
||||||
@@ -190,6 +189,9 @@ export function EventModal({
|
|||||||
const [location, setLocation] = useState(
|
const [location, setLocation] = useState(
|
||||||
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
||||||
);
|
);
|
||||||
|
const [virtualLocation, setVirtualLocation] = useState(
|
||||||
|
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
|
||||||
|
);
|
||||||
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
||||||
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||||
@@ -315,6 +317,20 @@ export function EventModal({
|
|||||||
data.locations = null;
|
data.locations = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (virtualLocation.trim()) {
|
||||||
|
data.virtualLocations = {
|
||||||
|
vl1: {
|
||||||
|
"@type": "VirtualLocation",
|
||||||
|
name: null,
|
||||||
|
description: null,
|
||||||
|
uri: virtualLocation.trim(),
|
||||||
|
features: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (event && event.virtualLocations && Object.keys(event.virtualLocations).length > 0) {
|
||||||
|
data.virtualLocations = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (recurrence !== "none") {
|
if (recurrence !== "none") {
|
||||||
data.recurrenceRules = [{
|
data.recurrenceRules = [{
|
||||||
"@type": "RecurrenceRule",
|
"@type": "RecurrenceRule",
|
||||||
@@ -374,7 +390,7 @@ export function EventModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||||
|
|
||||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -384,7 +400,7 @@ export function EventModal({
|
|||||||
|
|
||||||
const handleDuplicate = useCallback(() => {
|
const handleDuplicate = useCallback(() => {
|
||||||
if (!event || !onDuplicate) return;
|
if (!event || !onDuplicate) return;
|
||||||
const start = parseISO(event.start);
|
const start = getEventStartDate(event);
|
||||||
const newStart = addDays(start, 1);
|
const newStart = addDays(start, 1);
|
||||||
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
@@ -403,6 +419,7 @@ export function EventModal({
|
|||||||
privacy: event.privacy,
|
privacy: event.privacy,
|
||||||
};
|
};
|
||||||
if (event.locations) data.locations = structuredClone(event.locations);
|
if (event.locations) data.locations = structuredClone(event.locations);
|
||||||
|
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
|
||||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||||
if (event.participants) data.participants = structuredClone(event.participants);
|
if (event.participants) data.participants = structuredClone(event.participants);
|
||||||
@@ -456,9 +473,9 @@ export function EventModal({
|
|||||||
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
||||||
|
|
||||||
if (isAttendeeMode && event) {
|
if (isAttendeeMode && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
||||||
const participants = getParticipantList(event);
|
const participants = getParticipantList(event);
|
||||||
|
|
||||||
@@ -567,9 +584,9 @@ export function EventModal({
|
|||||||
|
|
||||||
// View mode: read-only display of event details with Edit button
|
// View mode: read-only display of event details with Edit button
|
||||||
if (mode === "view" && event) {
|
if (mode === "view" && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
||||||
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||||
const viewParticipants = getParticipantList(event);
|
const viewParticipants = getParticipantList(event);
|
||||||
@@ -780,6 +797,39 @@ export function EventModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1 block">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Video className="w-4 h-4" />
|
||||||
|
{t("form.meeting_link")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={virtualLocation}
|
||||||
|
onChange={(e) => setVirtualLocation(e.target.value)}
|
||||||
|
placeholder="https://meet.example.com/..."
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<PluginSlot
|
||||||
|
name="calendar-event-actions"
|
||||||
|
className="mt-2 flex flex-wrap gap-2"
|
||||||
|
extraProps={{
|
||||||
|
eventData: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start: startDate + 'T' + startTime,
|
||||||
|
end: endDate + 'T' + endTime,
|
||||||
|
isAllDay: allDay,
|
||||||
|
location,
|
||||||
|
virtualLocation,
|
||||||
|
calendarId,
|
||||||
|
},
|
||||||
|
setVirtualLocation,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">
|
<label className="text-sm font-medium mb-1 block">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
|||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
@@ -17,7 +18,7 @@ interface ICalImportModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||||
|
|
||||||
type ImportStep = "select" | "preview" | "importing";
|
type ImportStep = "select" | "preview" | "importing";
|
||||||
@@ -195,7 +196,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
||||||
if (!event.start) return "";
|
if (!event.start) return "";
|
||||||
try {
|
try {
|
||||||
const date = parseISO(event.start);
|
const date = getEventStartDate(event as CalendarEvent);
|
||||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
return event.showWithoutTime
|
return event.showWithoutTime
|
||||||
? format(date, "MMM d, yyyy")
|
? format(date, "MMM d, yyyy")
|
||||||
|
|||||||
@@ -5,24 +5,28 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Loader2, Globe } from "lucide-react";
|
import { X, Loader2, Globe } from "lucide-react";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore, type ICalSubscription } from "@/stores/calendar-store";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface ICalSubscriptionModalProps {
|
interface ICalSubscriptionModalProps {
|
||||||
client: IJMAPClient;
|
client: IJMAPClient;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
editSubscription?: ICalSubscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
|
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
|
||||||
const t = useTranslations("calendar.subscription");
|
const t = useTranslations("calendar.subscription");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||||
|
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
|
||||||
|
|
||||||
const [url, setUrl] = useState("");
|
const isEdit = !!editSubscription;
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [color, setColor] = useState("#3b82f6");
|
const [url, setUrl] = useState(editSubscription?.url || "");
|
||||||
const [refreshInterval, setRefreshInterval] = useState(60);
|
const [name, setName] = useState(editSubscription?.name || "");
|
||||||
|
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
|
||||||
|
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
if (isEdit && editSubscription) {
|
||||||
if (subscription) {
|
const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
|
||||||
toast.success(t("success", { name: name.trim() }));
|
if (trimmedUrl !== editSubscription.url) updates.url = trimmedUrl;
|
||||||
|
if (name.trim() !== editSubscription.name) updates.name = name.trim();
|
||||||
|
if (color !== editSubscription.color) updates.color = color;
|
||||||
|
if (refreshInterval !== editSubscription.refreshInterval) updates.refreshInterval = refreshInterval;
|
||||||
|
await updateICalSubscription(client, editSubscription.id, updates);
|
||||||
|
toast.success(t("updated", { name: name.trim() }));
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
setError(t("error"));
|
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
||||||
|
if (subscription) {
|
||||||
|
toast.success(t("success", { name: name.trim() }));
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(t("error"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("error"));
|
setError(isEdit ? t("update_error") : t("error"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -108,7 +123,7 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Globe className="w-5 h-5 text-primary" />
|
<Globe className="w-5 h-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
<h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
{t("subscribing")}
|
{isEdit ? t("saving") : t("subscribing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
t("subscribe")
|
isEdit ? t("save") : t("subscribe")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||||
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
||||||
isSameDay, isSameMonth, isToday, format, parseISO,
|
isSameDay, isSameMonth, isToday, format,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
type PickerView = "days" | "months" | "years";
|
type PickerView = "days" | "months" | "years";
|
||||||
@@ -54,7 +55,14 @@ export function MiniCalendar({
|
|||||||
const eventDates = useMemo(() => {
|
const eventDates = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
events.forEach(e => {
|
events.forEach(e => {
|
||||||
try { set.add(format(parseISO(e.start), "yyyy-MM-dd")); } catch { /* skip */ }
|
try {
|
||||||
|
const { startDay, endDay } = getEventDayBounds(e);
|
||||||
|
const cursor = new Date(startDay);
|
||||||
|
while (cursor <= endDay) {
|
||||||
|
set.add(format(cursor, "yyyy-MM-dd"));
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
});
|
});
|
||||||
return set;
|
return set;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
|
|||||||
useSettingsStore.setState({
|
useSettingsStore.setState({
|
||||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||||
showPreview: false,
|
showPreview: false,
|
||||||
|
mailLayout: 'split',
|
||||||
});
|
});
|
||||||
useEmailStore.setState({
|
useEmailStore.setState({
|
||||||
selectedEmailIds: new Set<string>(),
|
selectedEmailIds: new Set<string>(),
|
||||||
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
|
|||||||
render(<EmailListItem email={email} />);
|
render(<EmailListItem email={email} />);
|
||||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders inline preview text in focused mail layout', () => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
showPreview: true,
|
||||||
|
mailLayout: 'focus',
|
||||||
|
});
|
||||||
|
const email = makeEmail({ preview: 'Inline preview content' });
|
||||||
|
const { container } = render(<EmailListItem email={email} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('p')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
|||||||
import { TemplateForm } from "@/components/templates/template-form";
|
import { TemplateForm } from "@/components/templates/template-form";
|
||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
|
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
|
|
||||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||||
@@ -76,8 +77,10 @@ interface EmailComposerProps {
|
|||||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||||
replyTo?: {
|
replyTo?: {
|
||||||
from?: { email?: string; name?: string }[];
|
from?: { email?: string; name?: string }[];
|
||||||
|
replyToAddresses?: { email?: string; name?: string }[];
|
||||||
to?: { email?: string; name?: string }[];
|
to?: { email?: string; name?: string }[];
|
||||||
cc?: { email?: string; name?: string }[];
|
cc?: { email?: string; name?: string }[];
|
||||||
|
bcc?: { email?: string; name?: string }[];
|
||||||
subject?: string;
|
subject?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
htmlBody?: string;
|
htmlBody?: string;
|
||||||
@@ -100,17 +103,20 @@ export function EmailComposer({
|
|||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||||
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
|
|
||||||
// Initialize with reply/forward data if provided
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = () => {
|
const getInitialTo = () => {
|
||||||
if (!replyTo) return "";
|
if (!replyTo) return "";
|
||||||
|
// RFC 5322: use Reply-To header if present, otherwise fall back to From
|
||||||
|
const replyTarget = replyTo.replyToAddresses?.length
|
||||||
|
? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ")
|
||||||
|
: replyTo.from?.[0]?.email || "";
|
||||||
if (mode === 'reply') {
|
if (mode === 'reply') {
|
||||||
const email = replyTo.from?.[0]?.email || "";
|
return replyTarget ? replyTarget + ', ' : "";
|
||||||
return email ? email + ', ' : "";
|
|
||||||
} else if (mode === 'replyAll') {
|
} else if (mode === 'replyAll') {
|
||||||
const from = replyTo.from?.[0]?.email || "";
|
|
||||||
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
||||||
const combined = [from, originalTo].filter(Boolean).join(", ");
|
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
|
||||||
return combined ? combined + ', ' : "";
|
return combined ? combined + ', ' : "";
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
@@ -226,6 +232,31 @@ export function EmailComposer({
|
|||||||
const currentIdentity = selectedIdentityId
|
const currentIdentity = selectedIdentityId
|
||||||
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||||
: primaryIdentity;
|
: primaryIdentity;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSelectReplyIdentity) return;
|
||||||
|
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||||
|
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||||
|
|
||||||
|
const matchedIdentityId = findReplyIdentityId(identities, {
|
||||||
|
to: replyTo?.to,
|
||||||
|
cc: replyTo?.cc,
|
||||||
|
bcc: replyTo?.bcc,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchedIdentityId) {
|
||||||
|
setSelectedIdentityId(matchedIdentityId);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
autoSelectReplyIdentity,
|
||||||
|
identities,
|
||||||
|
initialData?.selectedIdentityId,
|
||||||
|
mode,
|
||||||
|
replyTo?.bcc,
|
||||||
|
replyTo?.cc,
|
||||||
|
replyTo?.to,
|
||||||
|
selectedIdentityId,
|
||||||
|
]);
|
||||||
|
|
||||||
const composerSignatureHtml = currentIdentity?.htmlSignature
|
const composerSignatureHtml = currentIdentity?.htmlSignature
|
||||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||||
: currentIdentity?.textSignature
|
: currentIdentity?.textSignature
|
||||||
@@ -301,6 +332,17 @@ export function EmailComposer({
|
|||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-focus the To field when composing a new email or forwarding
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'forward' || mode === 'compose') {
|
||||||
|
// Small delay to ensure the input is rendered
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
toInputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
||||||
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||||
@@ -308,10 +350,26 @@ export function EmailComposer({
|
|||||||
const toInputRef = useRef<HTMLInputElement>(null);
|
const toInputRef = useRef<HTMLInputElement>(null);
|
||||||
const ccInputRef = useRef<HTMLInputElement>(null);
|
const ccInputRef = useRef<HTMLInputElement>(null);
|
||||||
const bccInputRef = useRef<HTMLInputElement>(null);
|
const bccInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const subjectInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const toDropdownRef = useRef<HTMLDivElement>(null);
|
const toDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const ccDropdownRef = useRef<HTMLDivElement>(null);
|
const ccDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const bccDropdownRef = useRef<HTMLDivElement>(null);
|
const bccDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const focusSubject = useCallback(() => {
|
||||||
|
subjectInputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const focusBody = useCallback(() => {
|
||||||
|
if (plainTextMode) {
|
||||||
|
bodyRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null;
|
||||||
|
proseMirror?.focus();
|
||||||
|
}
|
||||||
|
}, [plainTextMode]);
|
||||||
|
|
||||||
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
|
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
|
||||||
if (autocompleteTimeoutRef.current) {
|
if (autocompleteTimeoutRef.current) {
|
||||||
clearTimeout(autocompleteTimeoutRef.current);
|
clearTimeout(autocompleteTimeoutRef.current);
|
||||||
@@ -1040,6 +1098,7 @@ export function EmailComposer({
|
|||||||
onInsertAutocomplete={insertAutocomplete}
|
onInsertAutocomplete={insertAutocomplete}
|
||||||
validationError={validationErrors.to}
|
validationError={validationErrors.to}
|
||||||
validationMessage={t('validation.recipient_required')}
|
validationMessage={t('validation.recipient_required')}
|
||||||
|
onTab={focusSubject}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-0.5 shrink-0">
|
<div className="flex gap-0.5 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
@@ -1109,6 +1168,7 @@ export function EmailComposer({
|
|||||||
<div className="flex items-center gap-2 px-4 py-2.5">
|
<div className="flex items-center gap-2 px-4 py-2.5">
|
||||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
|
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
|
||||||
<Input
|
<Input
|
||||||
|
ref={subjectInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('subject_placeholder')}
|
placeholder={t('subject_placeholder')}
|
||||||
value={subject}
|
value={subject}
|
||||||
@@ -1116,6 +1176,12 @@ export function EmailComposer({
|
|||||||
setSubject(e.target.value);
|
setSubject(e.target.value);
|
||||||
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
|
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
focusBody();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
|
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
|
||||||
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
|
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
|
||||||
@@ -1128,6 +1194,7 @@ export function EmailComposer({
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
{plainTextMode ? (
|
{plainTextMode ? (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={bodyRef}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBody(e.target.value);
|
setBody(e.target.value);
|
||||||
@@ -1142,16 +1209,18 @@ export function EmailComposer({
|
|||||||
aria-invalid={validationErrors.body || undefined}
|
aria-invalid={validationErrors.body || undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RichTextEditor
|
<div ref={editorContainerRef}>
|
||||||
content={body}
|
<RichTextEditor
|
||||||
onChange={(html) => {
|
content={body}
|
||||||
setBody(html);
|
onChange={(html) => {
|
||||||
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
setBody(html);
|
||||||
}}
|
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||||
onImageUpload={handleImageUpload}
|
}}
|
||||||
placeholder={t('body_placeholder')}
|
onImageUpload={handleImageUpload}
|
||||||
hasError={validationErrors.body}
|
placeholder={t('body_placeholder')}
|
||||||
/>
|
hasError={validationErrors.body}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{plainTextMode ? (
|
{plainTextMode ? (
|
||||||
@@ -1483,6 +1552,7 @@ function RecipientChipInput({
|
|||||||
onInsertAutocomplete,
|
onInsertAutocomplete,
|
||||||
validationError,
|
validationError,
|
||||||
validationMessage,
|
validationMessage,
|
||||||
|
onTab,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@@ -1499,6 +1569,7 @@ function RecipientChipInput({
|
|||||||
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
|
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
|
||||||
validationError?: boolean;
|
validationError?: boolean;
|
||||||
validationMessage?: string;
|
validationMessage?: string;
|
||||||
|
onTab?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
|
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
const hasTrailingComma = value.trimEnd().endsWith(',');
|
const hasTrailingComma = value.trimEnd().endsWith(',');
|
||||||
@@ -1532,7 +1603,18 @@ function RecipientChipInput({
|
|||||||
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
|
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
|
||||||
if (e.key !== 'Tab') e.preventDefault();
|
if (e.key !== 'Tab') e.preventDefault();
|
||||||
commitCurrentInput();
|
commitCurrentInput();
|
||||||
setTimeout(() => inputRef.current?.focus(), 0);
|
if (e.key === 'Tab' && onTab) {
|
||||||
|
e.preventDefault();
|
||||||
|
setTimeout(() => onTab(), 0);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey && onTab) {
|
||||||
|
e.preventDefault();
|
||||||
|
onTab();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
interface EmailHoverActionsProps {
|
interface EmailHoverActionsProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
|
backgroundClassName?: string;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
@@ -63,6 +64,7 @@ const CORNER_CLASSES = {
|
|||||||
|
|
||||||
export function EmailHoverActions({
|
export function EmailHoverActions({
|
||||||
email,
|
email,
|
||||||
|
backgroundClassName = "bg-muted",
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -77,6 +79,7 @@ export function EmailHoverActions({
|
|||||||
|
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const hoverBackgroundClassName = backgroundClassName;
|
||||||
|
|
||||||
if (hoverActions.length === 0) return null;
|
if (hoverActions.length === 0) return null;
|
||||||
|
|
||||||
@@ -144,7 +147,10 @@ export function EmailHoverActions({
|
|||||||
CORNER_CLASSES[hoverActionsCorner],
|
CORNER_CLASSES[hoverActionsCorner],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-0.5 bg-muted rounded-lg px-1.5 py-0.5 shadow-md border border-border">
|
<div className={cn(
|
||||||
|
"flex items-center gap-0.5 rounded-lg px-1.5 py-0.5 shadow-md border border-border",
|
||||||
|
hoverBackgroundClassName,
|
||||||
|
)}>
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,8 +161,14 @@ export function EmailHoverActions({
|
|||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-full bg-gradient-to-r from-transparent to-muted" />
|
<div
|
||||||
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
|
className={cn("w-8 h-full", hoverBackgroundClassName)}
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
|
||||||
|
maskImage: "linear-gradient(to right, transparent, black)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
@@ -44,6 +45,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tag using keyword definitions from settings
|
// Resolve color tag using keyword definitions from settings
|
||||||
const colorTagId = getEmailColorTag(email.keywords);
|
const colorTagId = getEmailColorTag(email.keywords);
|
||||||
@@ -114,15 +117,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox - only visible when in selection mode */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -145,7 +152,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
@@ -156,85 +163,131 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* First Line: Sender and Date */}
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-sm",
|
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||||
isUnread
|
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
? "font-bold text-foreground"
|
)}>
|
||||||
: "font-medium text-muted-foreground"
|
{sender?.name || sender?.email || 'Unknown'}
|
||||||
)}>
|
</span>
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
</span>
|
<span className={cn(
|
||||||
<div className="flex items-center gap-1.5">
|
'shrink-0 truncate',
|
||||||
{isStarred && (
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
)}>
|
||||||
)}
|
{email.subject || t('no_subject')}
|
||||||
{isImportant && (
|
|
||||||
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
|
|
||||||
Important
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{inlinePreview && (
|
||||||
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
{isAnswered && !isForwarded && (
|
)}
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
</div>
|
||||||
)}
|
</div>
|
||||||
{isForwarded && !isAnswered && (
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
)}
|
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||||
|
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{isAnswered && isForwarded && (
|
{isAnswered && isForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && (
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||||
)}
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{keywordDef && (
|
<>
|
||||||
<span className={cn(
|
{/* First Line: Sender and Date */}
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
)}>
|
<span className={cn(
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
"truncate text-sm",
|
||||||
{keywordDef.label}
|
isUnread
|
||||||
</span>
|
? "font-bold text-foreground"
|
||||||
)}
|
: "font-medium text-muted-foreground"
|
||||||
<span className={cn(
|
)}>
|
||||||
"text-xs tabular-nums",
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isStarred && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
{isImportant && (
|
||||||
|
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
|
||||||
|
Important
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
||||||
|
{isAnswered && !isForwarded && (
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isForwarded && !isAnswered && (
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isAnswered && isForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{email.hasAttachment && (
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{keywordDef && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
|
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||||
|
)}>
|
||||||
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||||
|
{keywordDef.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
isUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Second Line: Subject */}
|
||||||
|
<div className={cn(
|
||||||
|
"mb-1 line-clamp-1 text-sm",
|
||||||
isUnread
|
isUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(email.receivedAt)}
|
{email.subject || t('no_subject')}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Second Line: Subject */}
|
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
isUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
isUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{email.subject || t('no_subject')}
|
)}>
|
||||||
</div>
|
{email.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
)}
|
||||||
{showPreview && density !== 'extra-compact' && (
|
</>
|
||||||
<p className={cn(
|
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
isUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{email.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,6 +295,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
|
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
|||||||
@@ -103,11 +103,16 @@ export function EmailList({
|
|||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
const estimateSize = useCallback(() => {
|
const estimateSize = useCallback(() => {
|
||||||
|
if (isFocusedMailLayout) {
|
||||||
|
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
|
||||||
|
}
|
||||||
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
||||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||||
}, [density, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: threadGroups.length,
|
count: threadGroups.length,
|
||||||
@@ -216,15 +221,26 @@ export function EmailList({
|
|||||||
}
|
}
|
||||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
||||||
|
|
||||||
// Range-based load more: trigger when last visible item is near the end
|
// Range-based load more: trigger when last visible item is near the end.
|
||||||
|
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||||
|
// count below the viewport size (e.g. 2400 emails → fewer thread groups).
|
||||||
const virtualItems = virtualizer.getVirtualItems();
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
||||||
|
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastVirtualItemIndex === undefined) return;
|
if (lastVirtualItemIndex === undefined) return;
|
||||||
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
||||||
handleLoadMore();
|
// Clear any pending timer so we don't stack calls
|
||||||
|
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
|
||||||
|
loadMoreTimerRef.current = setTimeout(() => {
|
||||||
|
handleLoadMore();
|
||||||
|
loadMoreTimerRef.current = null;
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
|
||||||
|
};
|
||||||
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
||||||
|
|
||||||
// Scroll to the thread group containing the selected email
|
// Scroll to the thread group containing the selected email
|
||||||
@@ -244,7 +260,7 @@ export function EmailList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
virtualizer.measure();
|
virtualizer.measure();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [density, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col min-h-0", className)}>
|
<div className={cn("flex flex-col min-h-0", className)}>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
|
PenSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||||
@@ -121,6 +122,7 @@ interface EmailViewerProps {
|
|||||||
onNavigatePrev?: () => void;
|
onNavigatePrev?: () => void;
|
||||||
onShowShortcuts?: () => void;
|
onShowShortcuts?: () => void;
|
||||||
onEditDraft?: () => void;
|
onEditDraft?: () => void;
|
||||||
|
onCompose?: () => void;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
currentMailboxRole?: string;
|
currentMailboxRole?: string;
|
||||||
@@ -865,6 +867,7 @@ export function EmailViewer({
|
|||||||
onNavigatePrev,
|
onNavigatePrev,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onEditDraft,
|
onEditDraft,
|
||||||
|
onCompose,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
currentMailboxRole,
|
currentMailboxRole,
|
||||||
@@ -887,8 +890,10 @@ export function EmailViewer({
|
|||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
// Detect if current mailbox is Junk folder
|
// Detect if current mailbox is Junk folder
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
@@ -906,7 +911,7 @@ export function EmailViewer({
|
|||||||
// Tablet list visibility
|
// Tablet list visibility
|
||||||
const { isTablet, isMobile } = useDeviceDetection();
|
const { isTablet, isMobile } = useDeviceDetection();
|
||||||
const { tabletListVisible } = useUIStore();
|
const { tabletListVisible } = useUIStore();
|
||||||
const { identities, client, isDemoMode } = useAuthStore();
|
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const { startTour } = useTour();
|
const { startTour } = useTour();
|
||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
@@ -960,9 +965,9 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Ensure S/MIME key records are loaded from IndexedDB
|
// Ensure S/MIME key records are loaded from IndexedDB
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
smimeStore.load();
|
smimeStore.load(activeAccountId ?? undefined);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [activeAccountId]);
|
||||||
|
|
||||||
// Build mailbox tree for move-to dropdown
|
// Build mailbox tree for move-to dropdown
|
||||||
const moveTargetIds = useMemo(() => new Set(
|
const moveTargetIds = useMemo(() => new Set(
|
||||||
@@ -1540,8 +1545,25 @@ export function EmailViewer({
|
|||||||
|
|
||||||
if (detection.type === 'enveloped-data') {
|
if (detection.type === 'enveloped-data') {
|
||||||
// Encrypted message
|
// Encrypted message
|
||||||
const { keyRecords, unlockedDecryptionKeys } = smimeStore;
|
const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
|
||||||
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
|
smimeDebug('[S/MIME] decrypt attempt:', {
|
||||||
|
keyRecordCount: keyRecords.length,
|
||||||
|
unlockedKeyCount: unlockedDecryptionKeys.size,
|
||||||
|
legacyKeyCount: unlockedLegacyDecryptionKeys.size,
|
||||||
|
keyRecordIds: keyRecords.map(k => k.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Short-circuit: no keys imported at all
|
||||||
|
if (keyRecords.length === 0) {
|
||||||
|
smimeDebug('[S/MIME] no key records available, skipping decrypt');
|
||||||
|
setSmimeStatus({
|
||||||
|
isSigned: false,
|
||||||
|
isEncrypted: true,
|
||||||
|
decryptionError: 'no-key',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
@@ -1552,6 +1574,7 @@ export function EmailViewer({
|
|||||||
cmsBytes: candidate.normalized,
|
cmsBytes: candidate.normalized,
|
||||||
keyRecords,
|
keyRecords,
|
||||||
unlockedKeys: unlockedDecryptionKeys,
|
unlockedKeys: unlockedDecryptionKeys,
|
||||||
|
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
|
||||||
});
|
});
|
||||||
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
||||||
source: candidate.source,
|
source: candidate.source,
|
||||||
@@ -1633,8 +1656,15 @@ export function EmailViewer({
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
try {
|
try {
|
||||||
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
|
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
|
||||||
} catch { /* ignore import errors */ }
|
smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint });
|
||||||
|
} catch (importErr) {
|
||||||
|
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||||
}
|
}
|
||||||
|
} else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) {
|
||||||
|
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
smimeError('[S/MIME] nested signature verify failed:', {
|
smimeError('[S/MIME] nested signature verify failed:', {
|
||||||
@@ -1672,10 +1702,12 @@ export function EmailViewer({
|
|||||||
decryptionError: 'locked',
|
decryptionError: 'locked',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
|
||||||
|
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
|
||||||
setSmimeStatus({
|
setSmimeStatus({
|
||||||
isSigned: false,
|
isSigned: false,
|
||||||
isEncrypted: true,
|
isEncrypted: true,
|
||||||
decryptionError: err instanceof Error ? err.message : 'Decryption failed',
|
decryptionError: isNoKeyError ? 'no-key' : errMsg,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1737,8 +1769,15 @@ export function EmailViewer({
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
try {
|
try {
|
||||||
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
|
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
|
||||||
} catch { /* ignore import errors */ }
|
smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint });
|
||||||
|
} catch (importErr) {
|
||||||
|
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||||
}
|
}
|
||||||
|
} else if (result.status.signatureValid && result.status.signerCert) {
|
||||||
|
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -1771,6 +1810,7 @@ export function EmailViewer({
|
|||||||
smimeStore.autoImportSignerCerts,
|
smimeStore.autoImportSignerCerts,
|
||||||
smimeStore.keyRecords,
|
smimeStore.keyRecords,
|
||||||
smimeStore.unlockedDecryptionKeys,
|
smimeStore.unlockedDecryptionKeys,
|
||||||
|
smimeStore.unlockedLegacyDecryptionKeys,
|
||||||
smimeStore,
|
smimeStore,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1780,12 +1820,12 @@ export function EmailViewer({
|
|||||||
|
|
||||||
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
||||||
if (!tnefAtt?.blobId) {
|
if (!tnefAtt?.blobId) {
|
||||||
debug.log('TNEF: No winmail.dat attachment found in email', email?.id);
|
debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.group('TNEF Processing');
|
debug.group('TNEF Processing', 'email');
|
||||||
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
||||||
|
|
||||||
// Check if the email already has a usable HTML body with real content
|
// Check if the email already has a usable HTML body with real content
|
||||||
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||||
@@ -1795,46 +1835,46 @@ export function EmailViewer({
|
|||||||
let hasRealHtmlBody = !!htmlValue;
|
let hasRealHtmlBody = !!htmlValue;
|
||||||
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
|
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
|
||||||
hasRealHtmlBody = false;
|
hasRealHtmlBody = false;
|
||||||
debug.log('TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
|
debug.log('email', 'TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
|
||||||
}
|
}
|
||||||
if (hasRealHtmlBody) {
|
if (hasRealHtmlBody) {
|
||||||
debug.log('TNEF: Email has real HTML body, will extract attachments only');
|
debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only');
|
||||||
} else {
|
} else {
|
||||||
debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
|
debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function processTnef() {
|
async function processTnef() {
|
||||||
try {
|
try {
|
||||||
debug.time('TNEF fetch blob');
|
debug.time('TNEF fetch blob', 'email');
|
||||||
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
||||||
debug.timeEnd('TNEF fetch blob');
|
debug.timeEnd('TNEF fetch blob', 'email');
|
||||||
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
debug.log('TNEF: Processing cancelled after fetch');
|
debug.log('email', 'TNEF: Processing cancelled after fetch');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (blobBytes.byteLength === 0) {
|
if (blobBytes.byteLength === 0) {
|
||||||
debug.warn('TNEF: Fetched blob is empty (0 bytes)');
|
debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tnefData = new Uint8Array(blobBytes);
|
const tnefData = new Uint8Array(blobBytes);
|
||||||
debug.time('TNEF parse');
|
debug.time('TNEF parse', 'email');
|
||||||
const parsed = parseTnef(tnefData);
|
const parsed = parseTnef(tnefData);
|
||||||
debug.timeEnd('TNEF parse');
|
debug.timeEnd('TNEF parse', 'email');
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
debug.log('TNEF: Processing cancelled after parse');
|
debug.log('email', 'TNEF: Processing cancelled after parse');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
||||||
|
|
||||||
if (parsed.htmlBody && !hasRealHtmlBody) {
|
if (parsed.htmlBody && !hasRealHtmlBody) {
|
||||||
setTnefHtml(parsed.htmlBody);
|
setTnefHtml(parsed.htmlBody);
|
||||||
@@ -1844,11 +1884,11 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
if (parsed.attachments.length > 0) {
|
if (parsed.attachments.length > 0) {
|
||||||
setTnefAttachments(parsed.attachments);
|
setTnefAttachments(parsed.attachments);
|
||||||
debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
|
debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
||||||
debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
@@ -1886,13 +1926,13 @@ export function EmailViewer({
|
|||||||
const hasRealText = !!textValue;
|
const hasRealText = !!textValue;
|
||||||
|
|
||||||
if (hasRealHtml || hasRealText) {
|
if (hasRealHtml || hasRealText) {
|
||||||
debug.log('Embedded RFC822: Outer email has real body content, not unwrapping');
|
debug.log('email', 'Embedded RFC822: Outer email has real body content, not unwrapping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.group('Embedded RFC822 Unwrapping');
|
debug.group('Embedded RFC822 Unwrapping', 'email');
|
||||||
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
||||||
debug.log('Outer email body is empty, will unwrap embedded email');
|
debug.log('email', 'Outer email body is empty, will unwrap embedded email');
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -1901,7 +1941,7 @@ export function EmailViewer({
|
|||||||
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
||||||
if (cancelled) { debug.groupEnd(); return; }
|
if (cancelled) { debug.groupEnd(); return; }
|
||||||
if (blobBytes.byteLength === 0) {
|
if (blobBytes.byteLength === 0) {
|
||||||
debug.warn('Embedded RFC822: Fetched blob is empty');
|
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1911,7 +1951,7 @@ export function EmailViewer({
|
|||||||
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
||||||
if (cancelled) { debug.groupEnd(); return; }
|
if (cancelled) { debug.groupEnd(); return; }
|
||||||
|
|
||||||
debug.log('Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
||||||
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
||||||
', attachments:', parsed.attachments?.length ?? 0);
|
', attachments:', parsed.attachments?.length ?? 0);
|
||||||
|
|
||||||
@@ -1923,7 +1963,7 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
if (parsed.attachments && parsed.attachments.length > 0) {
|
if (parsed.attachments && parsed.attachments.length > 0) {
|
||||||
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
|
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
|
||||||
debug.log('Embedded RFC822 attachments:', parsed.attachments.map(
|
debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map(
|
||||||
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
|
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
|
||||||
).join(', '));
|
).join(', '));
|
||||||
}
|
}
|
||||||
@@ -2526,14 +2566,21 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// If email has native dark mode, let it handle its own theming
|
// If email has native dark mode, let it handle its own theming
|
||||||
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
|
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
|
||||||
|
// Re-invert leaf media elements so they appear normal.
|
||||||
|
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
|
||||||
|
// double re-inverting images nested inside those containers.
|
||||||
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
||||||
html { background: #1a1a1a; }
|
html { background: #1a1a1a; }
|
||||||
body { filter: invert(1) hue-rotate(180deg); }
|
body { filter: invert(1) hue-rotate(180deg); }
|
||||||
img, video, picture, svg, canvas, object, embed,
|
img, video, svg, canvas, object, embed, input[type="image"] {
|
||||||
[style*="background-image"], [style*="background:"],
|
filter: invert(1) hue-rotate(180deg);
|
||||||
[background], [bgcolor],
|
}
|
||||||
td[background], table[background],
|
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
img[src], input[type="image"] {
|
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
[background]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
td[background]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
|
||||||
filter: invert(1) hue-rotate(180deg);
|
filter: invert(1) hue-rotate(180deg);
|
||||||
}
|
}
|
||||||
` : '';
|
` : '';
|
||||||
@@ -2572,11 +2619,35 @@ export function EmailViewer({
|
|||||||
a.setAttribute('target', '_blank');
|
a.setAttribute('target', '_blank');
|
||||||
a.setAttribute('rel', 'noopener noreferrer');
|
a.setAttribute('rel', 'noopener noreferrer');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dark mode: re-invert elements with stylesheet-defined background images
|
||||||
|
// (CSS attribute selectors only catch inline styles, not <style> block rules)
|
||||||
|
if (isDark && !emailHasNativeDarkMode) {
|
||||||
|
const win = doc.defaultView;
|
||||||
|
if (win) {
|
||||||
|
doc.body.querySelectorAll('*').forEach(el => {
|
||||||
|
const htmlEl = el as HTMLElement;
|
||||||
|
// Skip elements already handled by CSS attribute selectors
|
||||||
|
if (htmlEl.style.backgroundImage || htmlEl.style.background ||
|
||||||
|
htmlEl.hasAttribute('background') || htmlEl.hasAttribute('bgcolor')) return;
|
||||||
|
// Skip leaf media elements (already re-inverted by CSS)
|
||||||
|
const tag = htmlEl.tagName;
|
||||||
|
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
|
||||||
|
const computed = win.getComputedStyle(htmlEl);
|
||||||
|
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||||||
|
// Only re-invert if this container doesn't have media children
|
||||||
|
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) {
|
||||||
|
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restrictions - iframe will still display content
|
// Cross-origin restrictions - iframe will still display content
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isDark, emailHasNativeDarkMode]);
|
||||||
|
|
||||||
// Export email as .eml file
|
// Export email as .eml file
|
||||||
const handleExportEmail = async () => {
|
const handleExportEmail = async () => {
|
||||||
@@ -2775,6 +2846,12 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||||
|
{onCompose && (
|
||||||
|
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||||
|
<PenSquare className="w-4 h-4 mr-2" />
|
||||||
|
{t('compose')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2790,7 +2867,7 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
{/* Left: Reply actions */}
|
{/* Left: Reply actions */}
|
||||||
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
||||||
{showBackButton && isTablet && !tabletListVisible && onBack && (
|
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -2805,7 +2882,7 @@ export function EmailViewer({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||||
title={t('tooltips.edit_draft')}
|
title={t('tooltips.edit_draft')}
|
||||||
>
|
>
|
||||||
@@ -3524,7 +3601,7 @@ export function EmailViewer({
|
|||||||
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||||
{/* Back button (for below-subject mode on tablet) */}
|
{/* Back button (for below-subject mode on tablet) */}
|
||||||
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && (
|
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -4403,7 +4480,7 @@ export function EmailViewer({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
<EditIcon className="w-3.5 h-3.5" />
|
<EditIcon className="w-3.5 h-3.5" />
|
||||||
@@ -4672,7 +4749,7 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
{isDraft && onEditDraft ? (
|
{isDraft && onEditDraft ? (
|
||||||
<button
|
<button
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
||||||
aria-label={t('tooltips.edit_draft')}
|
aria-label={t('tooltips.edit_draft')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
text: t('unlock_key_desc'),
|
text: t('unlock_key_desc'),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
|
} else if (status.decryptionError === 'no-key') {
|
||||||
|
items.push({
|
||||||
|
icon: <Lock className="w-4 h-4" />,
|
||||||
|
text: t('status_encrypted_no_key'),
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <ShieldX className="w-4 h-4" />,
|
icon: <ShieldX className="w-4 h-4" />,
|
||||||
@@ -49,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
// Signature status
|
// Signature status
|
||||||
if (status.isSigned) {
|
if (status.isSigned) {
|
||||||
if (status.signatureValid === true) {
|
if (status.signatureValid === true) {
|
||||||
if (status.signerEmailMatch === false) {
|
if (status.selfSigned) {
|
||||||
|
items.push({
|
||||||
|
icon: <AlertTriangle className="w-4 h-4" />,
|
||||||
|
text: t('status_signed_self_signed'),
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
|
} else if (status.signerEmailMatch === false) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <AlertTriangle className="w-4 h-4" />,
|
icon: <AlertTriangle className="w-4 h-4" />,
|
||||||
text: t('status_signed_mismatch'),
|
text: t('status_signed_mismatch'),
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color and keyword definition from keyword definitions if not passed directly
|
// Resolve color and keyword definition from keyword definitions if not passed directly
|
||||||
const tagId = getEmailColorTag(email.keywords);
|
const tagId = getEmailColorTag(email.keywords);
|
||||||
@@ -131,15 +134,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox - only visible when in selection mode */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -160,7 +167,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
@@ -170,76 +177,121 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className={cn(
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
"truncate text-sm",
|
<span className={cn(
|
||||||
isUnread
|
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||||
? "font-bold text-foreground"
|
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
: "font-medium text-muted-foreground"
|
)}>
|
||||||
)}>
|
{sender?.name || sender?.email || 'Unknown'}
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
</span>
|
||||||
</span>
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
<span className={cn(
|
||||||
{isStarred && (
|
'shrink-0 truncate',
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
)}
|
)}>
|
||||||
{isAnswered && !isForwarded && (
|
{email.subject || '(no subject)'}
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
</span>
|
||||||
)}
|
{inlinePreview && (
|
||||||
{isForwarded && !isAnswered && (
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
)}
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
|
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{isAnswered && isForwarded && (
|
{isAnswered && isForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && (
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||||
)}
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{resolvedKeywordDef && (
|
<>
|
||||||
<span className={cn(
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
<span className={cn(
|
||||||
)}>
|
"truncate text-sm",
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
isUnread
|
||||||
{resolvedKeywordDef.label}
|
? "font-bold text-foreground"
|
||||||
</span>
|
: "font-medium text-muted-foreground"
|
||||||
)}
|
)}>
|
||||||
<span className={cn(
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
"text-xs tabular-nums",
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isStarred && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
{isAnswered && !isForwarded && (
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isForwarded && !isAnswered && (
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isAnswered && isForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{email.hasAttachment && (
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{resolvedKeywordDef && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
|
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
||||||
|
)}>
|
||||||
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
||||||
|
{resolvedKeywordDef.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
isUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"mb-1 line-clamp-1 text-sm",
|
||||||
isUnread
|
isUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(email.receivedAt)}
|
{email.subject || "(no subject)"}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
isUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
isUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{email.subject || "(no subject)"}
|
)}>
|
||||||
</div>
|
{email.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{showPreview && density !== 'extra-compact' && (
|
)}
|
||||||
<p className={cn(
|
</>
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
isUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{email.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,6 +299,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
|
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@@ -280,8 +333,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const t = useTranslations('threads');
|
const t = useTranslations('threads');
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||||
|
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
|
|
||||||
@@ -405,15 +461,19 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}
|
)}
|
||||||
onClick={handleHeaderClick}
|
onClick={handleHeaderClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox for thread selection - only visible when in selection mode */}
|
{/* Checkbox for thread selection - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleThreadCheckboxClick}
|
onClick={handleThreadCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -428,7 +488,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && !isFocusedMailLayout && (
|
||||||
<button
|
<button
|
||||||
data-expand-toggle
|
data-expand-toggle
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -460,7 +520,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={latestEmail.from?.[0]?.name}
|
name={latestEmail.from?.[0]?.name}
|
||||||
email={latestEmail.from?.[0]?.email}
|
email={latestEmail.from?.[0]?.email}
|
||||||
@@ -470,88 +530,143 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className={cn(
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
"truncate text-sm",
|
<span className={cn(
|
||||||
hasUnread
|
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||||
? "font-bold text-foreground"
|
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
: "font-medium text-muted-foreground"
|
)}>
|
||||||
)}>
|
{participantNames.join(', ')}
|
||||||
{participantNames.join(", ")}
|
</span>
|
||||||
</span>
|
<span
|
||||||
<span
|
className={cn(
|
||||||
className={cn(
|
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
|
||||||
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
|
||||||
hasUnread
|
)}
|
||||||
? "bg-primary text-primary-foreground"
|
title={t('messages_tooltip', { count: emailCount })}
|
||||||
: "bg-muted text-muted-foreground"
|
>
|
||||||
)}
|
<MessageSquare className="w-3 h-3" />
|
||||||
title={t('messages_tooltip', { count: emailCount })}
|
{emailCount}
|
||||||
>
|
</span>
|
||||||
<MessageSquare className="w-3 h-3" />
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
{emailCount}
|
<span className={cn(
|
||||||
</span>
|
'shrink-0 truncate',
|
||||||
<div className="flex items-center gap-1.5">
|
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
{hasStarred && (
|
)}>
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
{latestEmail.subject || '(no subject)'}
|
||||||
)}
|
</span>
|
||||||
{hasAnswered && !hasForwarded && (
|
{inlinePreview && (
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
)}
|
)}
|
||||||
{hasForwarded && !hasAnswered && (
|
</div>
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
</div>
|
||||||
)}
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
|
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{hasAnswered && hasForwarded && (
|
{hasAnswered && hasForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasAttachment && (
|
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||||
)}
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(latestEmail.receivedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{keywordDef && (
|
<>
|
||||||
<span className={cn(
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
<span className={cn(
|
||||||
)}>
|
"truncate text-sm",
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
hasUnread
|
||||||
{keywordDef.label}
|
? "font-bold text-foreground"
|
||||||
</span>
|
: "font-medium text-muted-foreground"
|
||||||
)}
|
)}>
|
||||||
<span className={cn(
|
{participantNames.join(", ")}
|
||||||
"text-xs tabular-nums",
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
||||||
|
hasUnread
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
title={t('messages_tooltip', { count: emailCount })}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
{emailCount}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasStarred && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
{hasAnswered && !hasForwarded && (
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{hasForwarded && !hasAnswered && (
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{hasAnswered && hasForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasAttachment && (
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{keywordDef && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
|
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||||
|
)}>
|
||||||
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||||
|
{keywordDef.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
hasUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(latestEmail.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"mb-1 line-clamp-1 text-sm",
|
||||||
hasUnread
|
hasUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(latestEmail.receivedAt)}
|
{latestEmail.subject || "(no subject)"}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
hasUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
hasUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{latestEmail.subject || "(no subject)"}
|
)}>
|
||||||
</div>
|
{latestEmail.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{showPreview && density !== 'extra-compact' && (
|
)}
|
||||||
<p className={cn(
|
</>
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
hasUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{latestEmail.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,6 +674,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{/* Hover Quick Actions for thread header */}
|
{/* Hover Quick Actions for thread header */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={latestEmail}
|
email={latestEmail}
|
||||||
|
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
@@ -568,7 +684,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && !isMobile && (
|
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||||
<iframe
|
<iframe
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
|
sandbox="allow-same-origin"
|
||||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||||
title={name}
|
title={name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield } from "lucide-react";
|
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check } from "lucide-react";
|
||||||
import { AccountSwitcher } from "./account-switcher";
|
import { AccountSwitcher } from "./account-switcher";
|
||||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
@@ -16,8 +16,12 @@ import { useWebDAVStore } from "@/stores/webdav-store";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||||
|
import { getInitials } from "@/lib/account-utils";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -164,20 +168,60 @@ export function NavigationRail({
|
|||||||
const { mailboxes } = useEmailStore();
|
const { mailboxes } = useEmailStore();
|
||||||
const { supportsWebDAV } = useWebDAVStore();
|
const { supportsWebDAV } = useWebDAVStore();
|
||||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||||
|
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||||
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
|
|
||||||
|
// Account list for rail
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
||||||
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||||
|
const [logoutMenuOpen, setLogoutMenuOpen] = useState(false);
|
||||||
|
const logoutBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const logoutPopoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [logoutPopoverStyle, setLogoutPopoverStyle] = useState<React.CSSProperties>({});
|
||||||
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
|
|
||||||
|
const updateLogoutPosition = useCallback(() => {
|
||||||
|
if (!logoutBtnRef.current) return;
|
||||||
|
const rect = logoutBtnRef.current.getBoundingClientRect();
|
||||||
|
setLogoutPopoverStyle({
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.right + 8,
|
||||||
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logoutMenuOpen) return;
|
||||||
|
updateLogoutPosition();
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
logoutBtnRef.current?.contains(e.target as Node) ||
|
||||||
|
logoutPopoverRef.current?.contains(e.target as Node)
|
||||||
|
) return;
|
||||||
|
setLogoutMenuOpen(false);
|
||||||
|
};
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setLogoutMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [logoutMenuOpen, updateLogoutPosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const { client } = useAuthStore.getState();
|
const headers = getActiveAccountSlotHeaders();
|
||||||
if (!client) return;
|
if (!headers['X-JMAP-Cookie-Slot']) return;
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Authorization': client.getAuthHeader(),
|
|
||||||
'X-JMAP-Server-URL': client.getServerUrl(),
|
|
||||||
'X-JMAP-Username': client.getUsername(),
|
|
||||||
};
|
|
||||||
fetch('/api/admin/stalwart-check', { headers })
|
fetch('/api/admin/stalwart-check', { headers })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -199,7 +243,7 @@ export function NavigationRail({
|
|||||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
||||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
|
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
|
||||||
];
|
];
|
||||||
|
|
||||||
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||||
@@ -504,6 +548,22 @@ export function NavigationRail({
|
|||||||
<Keyboard className="w-[18px] h-[18px]" />
|
<Keyboard className="w-[18px] h-[18px]" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!onShowShortcuts && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowShortcutsModal(true)}
|
||||||
|
data-tour="nav-shortcuts"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title={t("keyboard_shortcuts")}
|
||||||
|
>
|
||||||
|
<Keyboard className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
<KeyboardShortcutsModal
|
||||||
|
isOpen={showShortcutsModal}
|
||||||
|
onClose={() => setShowShortcutsModal(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{quota && quota.total > 0 && (
|
{quota && quota.total > 0 && (
|
||||||
<div data-tour="storage-quota">
|
<div data-tour="storage-quota">
|
||||||
@@ -511,21 +571,85 @@ export function NavigationRail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPushConnected != null && (
|
{onLogout && showRailAccountList && accounts.length > 0 && (
|
||||||
<span
|
<>
|
||||||
className="relative group"
|
<div className="w-8 border-t my-1" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
|
||||||
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
|
|
||||||
>
|
{/* Account circles */}
|
||||||
<span
|
<div className="flex flex-col items-center gap-3">
|
||||||
className={cn(
|
{accounts.map((account) => {
|
||||||
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
|
const isActive = account.id === activeAccountId;
|
||||||
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||||
)}
|
return (
|
||||||
/>
|
<button
|
||||||
</span>
|
key={account.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isActive) switchAccount(account.id);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
|
||||||
|
isActive
|
||||||
|
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||||
|
: "opacity-70 hover:opacity-100"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: account.avatarColor }}
|
||||||
|
title={`${account.displayName || account.label} (${account.email || account.username})`}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<Check className="w-2 h-2 text-primary-foreground" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout button with popover */}
|
||||||
|
<button
|
||||||
|
ref={logoutBtnRef}
|
||||||
|
onClick={() => setLogoutMenuOpen(!logoutMenuOpen)}
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title={t("sign_out")}
|
||||||
|
aria-expanded={logoutMenuOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{logoutMenuOpen && createPortal(
|
||||||
|
<div
|
||||||
|
ref={logoutPopoverRef}
|
||||||
|
style={logoutPopoverStyle}
|
||||||
|
className="w-56 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => { setLogoutMenuOpen(false); logout(); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
{t("sign_out")}
|
||||||
|
</button>
|
||||||
|
{accounts.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setLogoutMenuOpen(false); logoutAll(); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-destructive hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
{t("sign_out_all")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onLogout && (
|
{onLogout && !showRailAccountList && (
|
||||||
<AccountSwitcher variant="rail" />
|
<AccountSwitcher variant="rail" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+132
-37
@@ -12,7 +12,6 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
PenSquare,
|
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -309,7 +308,7 @@ function TagItem({
|
|||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="truncate">{kw.label}</span>
|
<span className="flex-1 truncate">{kw.label}</span>
|
||||||
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
|
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -428,9 +427,16 @@ export function Sidebar({
|
|||||||
onUnreadFilterClick,
|
onUnreadFilterClick,
|
||||||
className,
|
className,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
const router = useRouter();
|
||||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||||
const { primaryIdentity: _primaryIdentity } = useAuthStore();
|
const { primaryIdentity: _primaryIdentity } = useAuthStore();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||||
|
return stored !== null ? JSON.parse(stored) : true;
|
||||||
|
} catch { return true; }
|
||||||
|
});
|
||||||
const [tagsExpanded, setTagsExpanded] = useState(() => {
|
const [tagsExpanded, setTagsExpanded] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('sidebarTagsExpanded');
|
const stored = localStorage.getItem('sidebarTagsExpanded');
|
||||||
@@ -438,6 +444,7 @@ export function Sidebar({
|
|||||||
} catch { return true; }
|
} catch { return true; }
|
||||||
});
|
});
|
||||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||||
|
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher);
|
||||||
const tagCounts = useEmailStore(s => s.tagCounts);
|
const tagCounts = useEmailStore(s => s.tagCounts);
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
|
|
||||||
@@ -452,10 +459,17 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const tree = buildMailboxTree(mailboxes);
|
const tree = buildMailboxTree(mailboxes);
|
||||||
const defaultExpanded = tree
|
const collectExpandable = (nodes: MailboxNode[]): string[] => {
|
||||||
.filter(node => node.children.length > 0)
|
const ids: string[] = [];
|
||||||
.map(node => node.id);
|
for (const node of nodes) {
|
||||||
setExpandedFolders(new Set(defaultExpanded));
|
if (node.children.length > 0) {
|
||||||
|
ids.push(node.id);
|
||||||
|
ids.push(...collectExpandable(node.children));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
setExpandedFolders(new Set(collectExpandable(tree)));
|
||||||
}
|
}
|
||||||
}, [mailboxes]);
|
}, [mailboxes]);
|
||||||
|
|
||||||
@@ -539,7 +553,7 @@ export function Sidebar({
|
|||||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && !hideAccountSwitcher && (
|
||||||
<AccountSwitcher variant="expanded" className="flex-1" />
|
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -553,24 +567,103 @@ export function Sidebar({
|
|||||||
{/* Mailbox List */}
|
{/* Mailbox List */}
|
||||||
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{mailboxes.length === 0 ? (
|
{/* Folders Section Header */}
|
||||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
<div
|
||||||
{!isCollapsed && t("loading_mailboxes")}
|
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||||
</div>
|
className={cn(
|
||||||
) : (
|
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 font-medium",
|
||||||
|
isCollapsed ? "justify-center px-1" : "px-2",
|
||||||
|
"text-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setFoldersExpanded((prev: boolean) => {
|
||||||
|
const next = !prev;
|
||||||
|
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"p-0.5 rounded mr-1 transition-all duration-200",
|
||||||
|
"hover:bg-muted active:bg-accent"
|
||||||
|
)}
|
||||||
|
title={foldersExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
|
||||||
|
>
|
||||||
|
{foldersExpanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (isCollapsed) return;
|
||||||
|
setFoldersExpanded((prev: boolean) => {
|
||||||
|
const next = !prev;
|
||||||
|
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-1 rounded",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
isCollapsed ? "justify-center" : "flex-1 text-left"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py)', ...(isCollapsed ? {} : { paddingLeft: '4px' }) }}
|
||||||
|
title={isCollapsed ? t("folders") : undefined}
|
||||||
|
>
|
||||||
|
<Folder className={cn(
|
||||||
|
"w-4 h-4 flex-shrink-0 transition-colors",
|
||||||
|
!isCollapsed && "mr-2",
|
||||||
|
foldersExpanded && "text-primary"
|
||||||
|
)} />
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className="flex-1 truncate">{t("folders")}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
|
||||||
|
router.push('/settings');
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
|
||||||
|
title={t('settings')}
|
||||||
|
>
|
||||||
|
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folder Items */}
|
||||||
|
{((foldersExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
<>
|
<>
|
||||||
{mailboxTree.map((node) => (
|
{mailboxes.length === 0 ? (
|
||||||
<MailboxTreeItem
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||||
key={node.id}
|
{!isCollapsed && t("loading_mailboxes")}
|
||||||
node={node}
|
</div>
|
||||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
) : (
|
||||||
expandedFolders={expandedFolders}
|
<>
|
||||||
onMailboxSelect={onMailboxSelect}
|
{mailboxTree.map((node) => (
|
||||||
onToggleExpand={handleToggleExpand}
|
<MailboxTreeItem
|
||||||
isCollapsed={isCollapsed}
|
key={node.id}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
node={node}
|
||||||
/>
|
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||||
))}
|
expandedFolders={expandedFolders}
|
||||||
|
onMailboxSelect={onMailboxSelect}
|
||||||
|
onToggleExpand={handleToggleExpand}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -635,6 +728,20 @@ export function Sidebar({
|
|||||||
<span className="flex-1 truncate">{t("tags")}</span>
|
<span className="flex-1 truncate">{t("tags")}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ }
|
||||||
|
router.push('/settings');
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
|
||||||
|
title={t('settings')}
|
||||||
|
>
|
||||||
|
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
@@ -661,19 +768,7 @@ export function Sidebar({
|
|||||||
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compose Button */}
|
|
||||||
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")} data-tour="compose-button">
|
|
||||||
<PenSquare className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={onCompose} className="w-full" title={t("compose_hint")} data-tour="compose-button">
|
|
||||||
<PenSquare className="w-4 h-4 mr-2" />
|
|
||||||
{t("compose")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import { useConfig } from '@/hooks/use-config';
|
|||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
|
||||||
|
|
||||||
export function AdvancedSettings() {
|
export function AdvancedSettings() {
|
||||||
const t = useTranslations('settings.advanced');
|
const t = useTranslations('settings.advanced');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { debugMode, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||||
useSettingsStore();
|
useSettingsStore();
|
||||||
const { settingsSyncEnabled } = useConfig();
|
const { settingsSyncEnabled } = useConfig();
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
@@ -72,6 +73,30 @@ export function AdvancedSettings() {
|
|||||||
</SettingItem>
|
</SettingItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Debug Categories */}
|
||||||
|
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
|
||||||
|
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
|
||||||
|
{ALL_DEBUG_CATEGORIES.map((cat) => (
|
||||||
|
<SettingItem
|
||||||
|
key={cat.id}
|
||||||
|
label={t(`debug_categories.${cat.labelKey}`)}
|
||||||
|
description={t(`debug_categories.${cat.labelKey}_description`)}
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={debugCategories?.[cat.id] !== false}
|
||||||
|
onChange={(checked) => {
|
||||||
|
updateSetting('debugCategories', {
|
||||||
|
...debugCategories,
|
||||||
|
[cat.id]: checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings Sync */}
|
{/* Settings Sync */}
|
||||||
{settingsSyncEnabled && (
|
{settingsSyncEnabled && (
|
||||||
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
|
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
|||||||
const t = useTranslations('settings.appearance');
|
const t = useTranslations('settings.appearance');
|
||||||
const tTour = useTranslations('tour');
|
const tTour = useTranslations('tour');
|
||||||
const { theme, setTheme } = useThemeStore();
|
const { theme, setTheme } = useThemeStore();
|
||||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
|
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore();
|
||||||
const { startTour, resetTourCompletion } = useTour();
|
const { startTour, resetTourCompletion } = useTour();
|
||||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||||
|
|
||||||
@@ -145,6 +145,22 @@ export function AppearanceSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Hide Account Switcher */}
|
||||||
|
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={hideAccountSwitcher}
|
||||||
|
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Show Rail Account List */}
|
||||||
|
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={showRailAccountList}
|
||||||
|
onChange={(checked) => updateSetting('showRailAccountList', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Animations */}
|
{/* Animations */}
|
||||||
{!isSettingHidden('animationsEnabled') && (
|
{!isSettingHidden('animationsEnabled') && (
|
||||||
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useCalendarStore } from '@/stores/calendar-store';
|
import { useCalendarStore } from '@/stores/calendar-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import { SettingsSection } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
|
||||||
@@ -164,6 +165,7 @@ export function CalendarManagementSettings() {
|
|||||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
|
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
||||||
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
|
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
|
||||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||||
const tImport = useTranslations('calendar.import');
|
const tImport = useTranslations('calendar.import');
|
||||||
@@ -202,7 +204,10 @@ export function CalendarManagementSettings() {
|
|||||||
|
|
||||||
fetch('/api/caldav/discover', {
|
fetch('/api/caldav/discover', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...getActiveAccountSlotHeaders(),
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
|
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
|
||||||
}),
|
}),
|
||||||
@@ -629,6 +634,14 @@ export function CalendarManagementSettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingSubscription(sub)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={tSub('edit')}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRefreshSubscription(sub.id)}
|
onClick={() => handleRefreshSubscription(sub.id)}
|
||||||
@@ -667,6 +680,14 @@ export function CalendarManagementSettings() {
|
|||||||
onClose={() => setShowSubscriptionModal(false)}
|
onClose={() => setShowSubscriptionModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingSubscription && client && (
|
||||||
|
<ICalSubscriptionModal
|
||||||
|
client={client}
|
||||||
|
editSubscription={editingSubscription}
|
||||||
|
onClose={() => setEditingSubscription(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function CalendarSettings() {
|
|||||||
showWeekNumbers,
|
showWeekNumbers,
|
||||||
enableCalendarTasks,
|
enableCalendarTasks,
|
||||||
showTasksOnCalendar,
|
showTasksOnCalendar,
|
||||||
|
calendarHoverPreview,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
@@ -80,6 +81,23 @@ export function CalendarSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('hover_preview')}
|
||||||
|
description={t('hover_preview_desc')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={calendarHoverPreview}
|
||||||
|
onChange={(value) => updateSetting('calendarHoverPreview', value as 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s')}
|
||||||
|
options={[
|
||||||
|
{ value: 'instant', label: t('hover_preview_instant') },
|
||||||
|
{ value: 'delay-500ms', label: t('hover_preview_delay_500ms') },
|
||||||
|
{ value: 'delay-1s', label: t('hover_preview_delay_1s') },
|
||||||
|
{ value: 'delay-2s', label: t('hover_preview_delay_2s') },
|
||||||
|
{ value: 'off', label: t('hover_preview_off') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{isFeatureEnabled('calendarTasksEnabled') && (
|
{isFeatureEnabled('calendarTasksEnabled') && (
|
||||||
<>
|
<>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
|
|||||||
@@ -4,16 +4,92 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
|
import type { ArchiveMode, HoverAction, MailLayout } from '@/stores/settings-store';
|
||||||
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
|
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||||
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
|
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
|
||||||
|
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||||
|
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||||
|
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
|
||||||
|
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function MailLayoutPreview({
|
||||||
|
value,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
value: MailLayout;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}) {
|
||||||
|
const isSplit = value === 'split';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-xl border border-border bg-background p-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||||
|
<div className="flex h-28">
|
||||||
|
<div className="w-11 border-r border-border bg-muted/40" />
|
||||||
|
|
||||||
|
{isSplit ? (
|
||||||
|
<>
|
||||||
|
<div className="w-28 border-r border-border bg-background">
|
||||||
|
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.subject}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
|
||||||
|
row.selected && 'bg-primary/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="truncate font-medium text-foreground">{row.sender}</div>
|
||||||
|
<div className="truncate text-muted-foreground">{row.subject}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-background px-3 py-2">
|
||||||
|
<div className="h-2.5 w-20 rounded bg-foreground/10" />
|
||||||
|
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
|
||||||
|
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
|
||||||
|
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 bg-background px-2 py-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.subject}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-2 py-1 text-[10px]',
|
||||||
|
row.selected ? 'bg-primary/10' : 'bg-muted/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="truncate text-foreground">
|
||||||
|
<span className="font-medium">{row.sender}</span>
|
||||||
|
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
|
||||||
|
<span className="text-muted-foreground/80">{row.preview}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EmailSettings() {
|
export function EmailSettings() {
|
||||||
const t = useTranslations('settings.email_behavior');
|
const t = useTranslations('settings.email_behavior');
|
||||||
const { appName } = useConfig();
|
const { appName } = useConfig();
|
||||||
@@ -39,7 +115,9 @@ export function EmailSettings() {
|
|||||||
deleteAction,
|
deleteAction,
|
||||||
permanentlyDeleteJunk,
|
permanentlyDeleteJunk,
|
||||||
showPreview,
|
showPreview,
|
||||||
|
mailLayout,
|
||||||
disableThreading,
|
disableThreading,
|
||||||
|
autoSelectReplyIdentity,
|
||||||
plainTextMode,
|
plainTextMode,
|
||||||
emailsPerPage,
|
emailsPerPage,
|
||||||
externalContentPolicy,
|
externalContentPolicy,
|
||||||
@@ -62,6 +140,8 @@ export function EmailSettings() {
|
|||||||
return t('trusted_senders.count_other', { count });
|
return t('trusted_senders.count_other', { count });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isFocusedLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
const handleReorganizeArchive = async () => {
|
const handleReorganizeArchive = async () => {
|
||||||
const { client } = useAuthStore.getState();
|
const { client } = useAuthStore.getState();
|
||||||
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
|
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
|
||||||
@@ -207,9 +287,29 @@ export function EmailSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{!isSettingHidden('mailLayout') && (
|
||||||
|
<SettingItem label={t('mail_layout.label')} description={t('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
|
||||||
|
<div className="w-[22rem] max-w-full">
|
||||||
|
<RadioGroup
|
||||||
|
value={mailLayout}
|
||||||
|
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
|
||||||
|
options={[
|
||||||
|
{ value: 'split', label: t('mail_layout.split') },
|
||||||
|
{ value: 'focus', label: t('mail_layout.focus') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<MailLayoutPreview value={mailLayout} t={t} />
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show Preview */}
|
{/* Show Preview */}
|
||||||
{!isSettingHidden('showPreview') && (
|
{!isSettingHidden('showPreview') && (
|
||||||
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')} locked={isSettingLocked('showPreview')}>
|
<SettingItem
|
||||||
|
label={t('show_preview.label')}
|
||||||
|
description={isFocusedLayout ? t('show_preview.focus_description') : t('show_preview.description')}
|
||||||
|
locked={isSettingLocked('showPreview')}
|
||||||
|
>
|
||||||
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
|
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
)}
|
)}
|
||||||
@@ -230,6 +330,13 @@ export function EmailSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={autoSelectReplyIdentity}
|
||||||
|
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Quick Hover Actions */}
|
{/* Quick Hover Actions */}
|
||||||
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||||
<div className="py-3 border-b border-border space-y-3">
|
<div className="py-3 border-b border-border space-y-3">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-di
|
|||||||
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
|
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
|
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
|
||||||
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export function SmimeSettings() {
|
|||||||
} = useSmimeStore();
|
} = useSmimeStore();
|
||||||
|
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
|
|
||||||
// Local UI state
|
// Local UI state
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
@@ -75,8 +77,8 @@ export function SmimeSettings() {
|
|||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load(activeAccountId ?? undefined);
|
||||||
}, [load]);
|
}, [load, activeAccountId]);
|
||||||
|
|
||||||
// ── PKCS#12 import flow ────────────────────────────────────────
|
// ── PKCS#12 import flow ────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -174,64 +174,68 @@ interface ThemeCardProps {
|
|||||||
|
|
||||||
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
|
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={onActivate}
|
<button
|
||||||
disabled={disabled}
|
type="button"
|
||||||
className={cn(
|
onClick={onActivate}
|
||||||
'relative flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
|
disabled={disabled}
|
||||||
isActive
|
className={cn(
|
||||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
'flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
|
||||||
: 'border-border hover:border-primary/40 bg-card',
|
isActive
|
||||||
disabled && !isActive && 'hover:border-border'
|
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||||
)}
|
: 'border-border hover:border-primary/40 bg-card',
|
||||||
>
|
disabled && !isActive && 'hover:border-border'
|
||||||
{/* Preview / Placeholder */}
|
|
||||||
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
|
|
||||||
{preview ? (
|
|
||||||
<img src={preview} alt={name} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<Palette className="w-8 h-8 text-muted-foreground/40" />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
|
{/* Preview / Placeholder */}
|
||||||
{/* Info */}
|
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
|
||||||
<div className="w-full">
|
{preview ? (
|
||||||
<div className="flex items-center justify-between gap-1">
|
<img src={preview} alt={name} className="w-full h-full object-cover" />
|
||||||
<span className="text-sm font-medium text-foreground truncate">{name}</span>
|
) : (
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<Palette className="w-8 h-8 text-muted-foreground/40" />
|
||||||
{isForceEnabled && (
|
)}
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium flex items-center gap-0.5" title="Admin enforced">
|
|
||||||
<Lock className="w-2.5 h-2.5" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isDefault && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
|
|
||||||
)}
|
|
||||||
{isActive && <Check className="w-4 h-4 text-primary" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground truncate block">{author}</span>
|
|
||||||
{variants && (
|
{/* Info */}
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="w-full">
|
||||||
{variants.map(v => (
|
<div className="flex items-center justify-between gap-1">
|
||||||
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
<span className="text-sm font-medium text-foreground truncate">{name}</span>
|
||||||
{v}
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
</span>
|
{isForceEnabled && (
|
||||||
))}
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium flex items-center gap-0.5" title="Admin enforced">
|
||||||
|
<Lock className="w-2.5 h-2.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isDefault && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
|
||||||
|
)}
|
||||||
|
{isActive && <Check className="w-4 h-4 text-primary" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<span className="text-xs text-muted-foreground truncate block">{author}</span>
|
||||||
</div>
|
{variants && (
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
{variants.map(v => (
|
||||||
|
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Remove button */}
|
{/* Remove button */}
|
||||||
{onRemove && !isActive && (
|
{onRemove && !isActive && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
title="Remove theme"
|
title="Remove theme"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||||
|
import { useTotpReauthStore } from "@/stores/totp-reauth-store";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Shield } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal dialog that prompts the user for a fresh TOTP code when their
|
||||||
|
* 2FA session expires (TOTP rotates every ~30 seconds).
|
||||||
|
*
|
||||||
|
* Rendered once at the app root level. The JMAP client triggers it via
|
||||||
|
* the useTotpReauthStore when a 401 is received on a TOTP-authenticated session.
|
||||||
|
*/
|
||||||
|
export function TotpReauthDialog() {
|
||||||
|
const { isOpen, submit, cancel } = useTotpReauthStore();
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const dialogRef = useFocusTrap({
|
||||||
|
isActive: isOpen,
|
||||||
|
onEscape: cancel,
|
||||||
|
restoreFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setCode("");
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (code.length >= 6) {
|
||||||
|
submit(code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={cancel} />
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Two-factor authentication required"
|
||||||
|
className="relative z-10 w-full max-w-sm mx-4 bg-background rounded-2xl shadow-xl border border-border p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
|
||||||
|
<Shield className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Session Expired</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Your 2FA code has rotated</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Enter a fresh authentication code from your authenticator app to continue.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400 mb-4 leading-relaxed">
|
||||||
|
To avoid being prompted repeatedly, ask your administrator to enable OAuth authentication
|
||||||
|
(either Stalwart's built-in OAuth or an external identity provider).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
className="h-12 text-center font-mono tracking-widest text-lg bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||||
|
placeholder="000000"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
aria-label="Authentication code"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={cancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={code.length < 6}
|
||||||
|
>
|
||||||
|
Verify
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ export default [
|
|||||||
".next/**",
|
".next/**",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"repos/**",
|
"repos/**",
|
||||||
|
"data/admin/plugins/**",
|
||||||
"*.config.js",
|
"*.config.js",
|
||||||
"*.config.mjs",
|
"*.config.mjs",
|
||||||
"e2e/**",
|
"e2e/**",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface ConfigData {
|
|||||||
loginWebsiteUrl: string;
|
loginWebsiteUrl: string;
|
||||||
demoMode: boolean;
|
demoMode: boolean;
|
||||||
autoSsoEnabled: boolean;
|
autoSsoEnabled: boolean;
|
||||||
|
allowCustomJmapEndpoint: boolean;
|
||||||
embeddedMode: boolean;
|
embeddedMode: boolean;
|
||||||
parentOrigin: string;
|
parentOrigin: string;
|
||||||
}
|
}
|
||||||
@@ -100,6 +101,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
||||||
demoMode: configCache?.demoMode || false,
|
demoMode: configCache?.demoMode || false,
|
||||||
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
||||||
|
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
|
||||||
embeddedMode: configCache?.embeddedMode || false,
|
embeddedMode: configCache?.embeddedMode || false,
|
||||||
parentOrigin: configCache?.parentOrigin || '',
|
parentOrigin: configCache?.parentOrigin || '',
|
||||||
isLoading: !configCache,
|
isLoading: !configCache,
|
||||||
@@ -131,6 +133,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
||||||
demoMode: configCache.demoMode,
|
demoMode: configCache.demoMode,
|
||||||
autoSsoEnabled: configCache.autoSsoEnabled,
|
autoSsoEnabled: configCache.autoSsoEnabled,
|
||||||
|
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
|
||||||
embeddedMode: configCache.embeddedMode,
|
embeddedMode: configCache.embeddedMode,
|
||||||
parentOrigin: configCache.parentOrigin,
|
parentOrigin: configCache.parentOrigin,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -163,6 +166,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginWebsiteUrl: data.loginWebsiteUrl,
|
loginWebsiteUrl: data.loginWebsiteUrl,
|
||||||
demoMode: data.demoMode,
|
demoMode: data.demoMode,
|
||||||
autoSsoEnabled: data.autoSsoEnabled,
|
autoSsoEnabled: data.autoSsoEnabled,
|
||||||
|
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
|
||||||
embeddedMode: data.embeddedMode,
|
embeddedMode: data.embeddedMode,
|
||||||
parentOrigin: data.parentOrigin,
|
parentOrigin: data.parentOrigin,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|||||||
@@ -45,4 +45,43 @@ describe('calendar event normalization', () => {
|
|||||||
showWithoutTime: true,
|
showWithoutTime: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('recurrenceRule normalization (JSCalendar 2.0 singular→plural)', () => {
|
||||||
|
it('wraps a single recurrenceRule object in an array', () => {
|
||||||
|
const raw = {
|
||||||
|
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
|
||||||
|
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' },
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
|
||||||
|
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]);
|
||||||
|
expect((result as Record<string, unknown>).recurrenceRule).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through recurrenceRule when already an array', () => {
|
||||||
|
const raw = {
|
||||||
|
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
|
||||||
|
recurrenceRule: [{ '@type': 'RecurrenceRule', frequency: 'daily' }],
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
|
||||||
|
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through null recurrenceRule as-is', () => {
|
||||||
|
const raw = {
|
||||||
|
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
|
||||||
|
recurrenceRule: null,
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
|
||||||
|
expect(result.recurrenceRules).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps a single excludedRecurrenceRule object in an array', () => {
|
||||||
|
const raw = {
|
||||||
|
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
|
||||||
|
excludedRecurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
|
||||||
|
expect(result.excludedRecurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||||
import {
|
import {
|
||||||
|
buildTimedFullDayWeekSegments,
|
||||||
buildWeekSegments,
|
buildWeekSegments,
|
||||||
buildAllDayDuration,
|
buildAllDayDuration,
|
||||||
getEventDayBounds,
|
getEventDayBounds,
|
||||||
getEventDisplayEndDate,
|
getEventDisplayEndDate,
|
||||||
getEventEndDate,
|
getEventEndDate,
|
||||||
|
getEventStartDate,
|
||||||
|
getTimedEventBoundsForDay,
|
||||||
|
isTimedEventFullDayOnDate,
|
||||||
|
layoutOverlappingEvents,
|
||||||
normalizeAllDayDuration,
|
normalizeAllDayDuration,
|
||||||
} from '../calendar-utils';
|
} from '../calendar-utils';
|
||||||
|
|
||||||
@@ -92,7 +97,110 @@ describe('calendar-utils all-day handling', () => {
|
|||||||
utcEnd: '2026-03-14T11:00:00Z',
|
utcEnd: '2026-03-14T11:00:00Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
expectLocalDateParts(getEventDisplayEndDate(event), 2026, 3, 14, 11);
|
expect(getEventDisplayEndDate(event).toISOString()).toBe('2026-03-14T11:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers utc timestamps for timed events with an event timezone', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2026-03-15T09:00:00',
|
||||||
|
duration: 'PT1H',
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
showWithoutTime: false,
|
||||||
|
utcStart: '2026-03-15T13:00:00Z',
|
||||||
|
utcEnd: '2026-03-15T14:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getEventStartDate(event).toISOString()).toBe('2026-03-15T13:00:00.000Z');
|
||||||
|
expect(getEventEndDate(event).toISOString()).toBe('2026-03-15T14:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clips timed multi-day events to the visible day bounds', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2026-03-14T22:00:00',
|
||||||
|
duration: 'PT4H',
|
||||||
|
showWithoutTime: false,
|
||||||
|
utcStart: '2026-03-14T22:00:00Z',
|
||||||
|
utcEnd: '2026-03-15T02:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getTimedEventBoundsForDay(event, new Date('2026-03-14T00:00:00Z'))).toMatchObject({
|
||||||
|
startMinutes: 1380,
|
||||||
|
endMinutes: 1440,
|
||||||
|
continuesBefore: false,
|
||||||
|
continuesAfter: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({
|
||||||
|
startMinutes: 0,
|
||||||
|
endMinutes: 180,
|
||||||
|
continuesBefore: true,
|
||||||
|
continuesAfter: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays out continued timed events using clipped bounds for the active day', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2026-03-14T22:00:00',
|
||||||
|
duration: 'PT4H',
|
||||||
|
showWithoutTime: false,
|
||||||
|
utcStart: '2026-03-14T22:00:00Z',
|
||||||
|
utcEnd: '2026-03-15T02:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const layout = layoutOverlappingEvents([event], new Date('2026-03-15T00:00:00Z'));
|
||||||
|
|
||||||
|
expect(layout).toHaveLength(1);
|
||||||
|
expect(layout[0]).toMatchObject({
|
||||||
|
startMinutes: 0,
|
||||||
|
endMinutes: 180,
|
||||||
|
column: 0,
|
||||||
|
totalColumns: 1,
|
||||||
|
continuesBefore: true,
|
||||||
|
continuesAfter: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects when a timed multi-day event fully occupies an intermediate day', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2026-03-14T12:00:00',
|
||||||
|
duration: 'PT48H',
|
||||||
|
showWithoutTime: false,
|
||||||
|
utcStart: '2026-03-14T12:00:00Z',
|
||||||
|
utcEnd: '2026-03-16T12:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-15T00:00:00Z'))).toBe(true);
|
||||||
|
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-14T00:00:00Z'))).toBe(false);
|
||||||
|
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-16T00:00:00Z'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates week-bar segments for timed events that fully cover visible days', () => {
|
||||||
|
const week = [
|
||||||
|
new Date('2026-03-14T00:00:00Z'),
|
||||||
|
new Date('2026-03-15T00:00:00Z'),
|
||||||
|
new Date('2026-03-16T00:00:00Z'),
|
||||||
|
new Date('2026-03-17T00:00:00Z'),
|
||||||
|
new Date('2026-03-18T00:00:00Z'),
|
||||||
|
new Date('2026-03-19T00:00:00Z'),
|
||||||
|
new Date('2026-03-20T00:00:00Z'),
|
||||||
|
];
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2026-03-14T12:00:00',
|
||||||
|
duration: 'PT72H',
|
||||||
|
showWithoutTime: false,
|
||||||
|
utcStart: '2026-03-14T12:00:00Z',
|
||||||
|
utcEnd: '2026-03-17T12:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const segments = buildTimedFullDayWeekSegments([event], week);
|
||||||
|
|
||||||
|
expect(segments).toHaveLength(1);
|
||||||
|
expect(segments[0]).toMatchObject({
|
||||||
|
startIndex: 1,
|
||||||
|
span: 2,
|
||||||
|
continuesBefore: false,
|
||||||
|
continuesAfter: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes imported all-day durations to day units', () => {
|
it('normalizes imported all-day durations to day units', () => {
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
// ─── Inline copies of the plugin helpers (pure functions, no deps) ──────────
|
||||||
|
|
||||||
|
// These mirror the implementations in repos/plugins/jitsi-meet/src/index.js
|
||||||
|
// so we can unit-test them without esbuild bundling.
|
||||||
|
|
||||||
|
function generateRoomName(eventTitle: string): string {
|
||||||
|
const slug = eventTitle
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 60);
|
||||||
|
|
||||||
|
const suffix = crypto.randomUUID().slice(0, 8);
|
||||||
|
return slug ? `${slug}-${suffix}` : suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMeetingUrl(jitsiUrl: string, roomName: string): string {
|
||||||
|
const base = jitsiUrl.replace(/\/+$/, '');
|
||||||
|
return `${base}/${encodeURIComponent(roomName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64url(input: string | ArrayBuffer): string {
|
||||||
|
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input);
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createJitsiJwt(options: {
|
||||||
|
secret: string;
|
||||||
|
roomName: string;
|
||||||
|
userEmail?: string;
|
||||||
|
userName?: string;
|
||||||
|
jitsiUrl: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const { secret, roomName, userEmail, userName, jitsiUrl } = options;
|
||||||
|
|
||||||
|
let domain: string;
|
||||||
|
try {
|
||||||
|
domain = new URL(jitsiUrl).hostname;
|
||||||
|
} catch {
|
||||||
|
domain = jitsiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
iss: 'bulwark-webmail',
|
||||||
|
sub: domain,
|
||||||
|
aud: 'jitsi',
|
||||||
|
room: roomName,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 86400,
|
||||||
|
context: {
|
||||||
|
user: {
|
||||||
|
...(userName ? { name: userName } : {}),
|
||||||
|
...(userEmail ? { email: userEmail } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const headerB64 = base64url(JSON.stringify(header));
|
||||||
|
const payloadB64 = base64url(JSON.stringify(payload));
|
||||||
|
const signingInput = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
enc.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign'],
|
||||||
|
);
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, enc.encode(signingInput));
|
||||||
|
const signatureB64 = base64url(signature);
|
||||||
|
|
||||||
|
return `${signingInput}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('generateRoomName', () => {
|
||||||
|
it('should slugify the event title and append a random suffix', () => {
|
||||||
|
const room = generateRoomName('Team Standup');
|
||||||
|
expect(room).toMatch(/^team-standup-[a-f0-9]{8}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters', () => {
|
||||||
|
const room = generateRoomName('Q&A Session: "Ask Me Anything!"');
|
||||||
|
expect(room).toMatch(/^q-a-session-ask-me-anything-[a-f0-9]{8}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty title', () => {
|
||||||
|
const room = generateRoomName('');
|
||||||
|
expect(room).toMatch(/^[a-f0-9]{8}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle whitespace-only title', () => {
|
||||||
|
const room = generateRoomName(' ');
|
||||||
|
expect(room).toMatch(/^[a-f0-9]{8}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate long titles to 60 chars plus suffix', () => {
|
||||||
|
const longTitle = 'A'.repeat(100);
|
||||||
|
const room = generateRoomName(longTitle);
|
||||||
|
// 60 chars of slug + '-' + 8 char suffix = 69 max
|
||||||
|
expect(room.length).toBeLessThanOrEqual(69);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate unique room names for the same title', () => {
|
||||||
|
const room1 = generateRoomName('Standup');
|
||||||
|
const room2 = generateRoomName('Standup');
|
||||||
|
expect(room1).not.toBe(room2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildMeetingUrl', () => {
|
||||||
|
it('should combine base URL and room name', () => {
|
||||||
|
const url = buildMeetingUrl('https://meet.example.com', 'my-room-abc12345');
|
||||||
|
expect(url).toBe('https://meet.example.com/my-room-abc12345');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strip trailing slashes from base URL', () => {
|
||||||
|
const url = buildMeetingUrl('https://meet.example.com/', 'room');
|
||||||
|
expect(url).toBe('https://meet.example.com/room');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strip multiple trailing slashes', () => {
|
||||||
|
const url = buildMeetingUrl('https://meet.example.com///', 'room');
|
||||||
|
expect(url).toBe('https://meet.example.com/room');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should URL-encode the room name', () => {
|
||||||
|
const url = buildMeetingUrl('https://meet.example.com', 'room with spaces');
|
||||||
|
expect(url).toBe('https://meet.example.com/room%20with%20spaces');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createJitsiJwt', () => {
|
||||||
|
it('should create a valid HS256 JWT', async () => {
|
||||||
|
const token = await createJitsiJwt({
|
||||||
|
secret: 'test-secret-key',
|
||||||
|
roomName: 'test-room',
|
||||||
|
userEmail: 'user@example.com',
|
||||||
|
userName: 'Test User',
|
||||||
|
jitsiUrl: 'https://meet.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parts = token.split('.');
|
||||||
|
expect(parts).toHaveLength(3);
|
||||||
|
|
||||||
|
const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
expect(header.alg).toBe('HS256');
|
||||||
|
expect(header.typ).toBe('JWT');
|
||||||
|
|
||||||
|
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
expect(payload.iss).toBe('bulwark-webmail');
|
||||||
|
expect(payload.sub).toBe('meet.example.com');
|
||||||
|
expect(payload.aud).toBe('jitsi');
|
||||||
|
expect(payload.room).toBe('test-room');
|
||||||
|
expect(payload.context.user.name).toBe('Test User');
|
||||||
|
expect(payload.context.user.email).toBe('user@example.com');
|
||||||
|
expect(payload.exp).toBe(payload.iat + 86400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set the sub claim to the Jitsi hostname', async () => {
|
||||||
|
const token = await createJitsiJwt({
|
||||||
|
secret: 'secret',
|
||||||
|
roomName: 'room',
|
||||||
|
jitsiUrl: 'https://jitsi.corp.example.com/subfolder',
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
expect(payload.sub).toBe('jitsi.corp.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should omit undefined user fields', async () => {
|
||||||
|
const token = await createJitsiJwt({
|
||||||
|
secret: 'secret',
|
||||||
|
roomName: 'room',
|
||||||
|
jitsiUrl: 'https://meet.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
expect(payload.context.user.name).toBeUndefined();
|
||||||
|
expect(payload.context.user.email).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce a different signature with different secrets', async () => {
|
||||||
|
const token1 = await createJitsiJwt({
|
||||||
|
secret: 'secret-one',
|
||||||
|
roomName: 'room',
|
||||||
|
jitsiUrl: 'https://meet.example.com',
|
||||||
|
});
|
||||||
|
const token2 = await createJitsiJwt({
|
||||||
|
secret: 'secret-two',
|
||||||
|
roomName: 'room',
|
||||||
|
jitsiUrl: 'https://meet.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(token1.split('.')[2]).not.toBe(token2.split('.')[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce a verifiable HMAC-SHA256 signature', async () => {
|
||||||
|
const secret = 'my-test-secret';
|
||||||
|
const token = await createJitsiJwt({
|
||||||
|
secret,
|
||||||
|
roomName: 'verify-room',
|
||||||
|
jitsiUrl: 'https://meet.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = token.split('.');
|
||||||
|
const signingInput = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
enc.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sigPadded = signatureB64.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const sigBinary = atob(sigPadded);
|
||||||
|
const sigBytes = new Uint8Array(sigBinary.length);
|
||||||
|
for (let i = 0; i < sigBinary.length; i++) {
|
||||||
|
sigBytes[i] = sigBinary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await crypto.subtle.verify('HMAC', key, sigBytes, enc.encode(signingInput));
|
||||||
|
expect(valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||||
|
import type { Mailbox } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
const makeMailbox = (overrides: Partial<Mailbox> = {}): Mailbox => ({
|
||||||
|
id: 'mb-1',
|
||||||
|
name: 'Test',
|
||||||
|
sortOrder: 0,
|
||||||
|
totalEmails: 0,
|
||||||
|
unreadEmails: 0,
|
||||||
|
totalThreads: 0,
|
||||||
|
unreadThreads: 0,
|
||||||
|
myRights: {
|
||||||
|
mayReadItems: true,
|
||||||
|
mayAddItems: true,
|
||||||
|
mayRemoveItems: true,
|
||||||
|
maySetSeen: true,
|
||||||
|
maySetKeywords: true,
|
||||||
|
mayCreateChild: true,
|
||||||
|
mayRename: true,
|
||||||
|
mayDelete: true,
|
||||||
|
maySubmit: true,
|
||||||
|
},
|
||||||
|
isSubscribed: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: walk the tree and collect { id, depth, parentId } for every node.
|
||||||
|
*/
|
||||||
|
function collectNodes(tree: MailboxNode[]): { id: string; depth: number; parentName?: string }[] {
|
||||||
|
const result: { id: string; depth: number; parentName?: string }[] = [];
|
||||||
|
const walk = (nodes: MailboxNode[], parentName?: string) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
result.push({ id: node.id, depth: node.depth, parentName });
|
||||||
|
if (node.children.length > 0) walk(node.children, node.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(tree);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mailbox deep nesting (depth 4+)', () => {
|
||||||
|
// Reproduce the exact scenario from the bug report:
|
||||||
|
// INBOX > PRIVAT > BOOKINGS > BOOKING1/BOOKING2/FLIGHTS/HOTEL1/HOTEL2/HOTEL3
|
||||||
|
// HOTEL2 > RESTAURANT (depth 4)
|
||||||
|
it('should correctly nest the reported folder structure (depth 4)', () => {
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
|
||||||
|
makeMailbox({ id: 'booking1', name: 'BOOKING1', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'booking2', name: 'BOOKING2', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'flights', name: 'FLIGHTS', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'hotel1', name: 'HOTEL1', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'hotel3', name: 'HOTEL3', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||||
|
|
||||||
|
// RESTAURANT should be at depth 4, NOT depth 0
|
||||||
|
expect(byId['restaurant'].depth).toBe(4);
|
||||||
|
// Verify the full chain of depths
|
||||||
|
expect(byId['inbox'].depth).toBe(0);
|
||||||
|
expect(byId['privat'].depth).toBe(1);
|
||||||
|
expect(byId['bookings'].depth).toBe(2);
|
||||||
|
expect(byId['hotel2'].depth).toBe(3);
|
||||||
|
expect(byId['restaurant'].depth).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not orphan depth-4 folders to root level', () => {
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
|
||||||
|
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
|
||||||
|
// RESTAURANT must NOT appear at root level
|
||||||
|
const rootNames = tree.map(n => n.name);
|
||||||
|
expect(rootNames).not.toContain('RESTAURANT');
|
||||||
|
|
||||||
|
// It must be nested under HOTEL2
|
||||||
|
const nodes = collectNodes(tree);
|
||||||
|
const restaurant = nodes.find(n => n.id === 'restaurant');
|
||||||
|
expect(restaurant).toBeDefined();
|
||||||
|
expect(restaurant!.depth).toBe(4);
|
||||||
|
expect(restaurant!.parentName).toBe('HOTEL2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nesting up to depth 6', () => {
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'l0', name: 'Level0', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l1', name: 'Level1', parentId: 'l0', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l2', name: 'Level2', parentId: 'l1', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l3', name: 'Level3', parentId: 'l2', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l4', name: 'Level4', parentId: 'l3', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l5', name: 'Level5', parentId: 'l4', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l6', name: 'Level6', parentId: 'l5', sortOrder: 0 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||||
|
|
||||||
|
// Only 1 root node
|
||||||
|
expect(tree).toHaveLength(1);
|
||||||
|
expect(tree[0].name).toBe('Level0');
|
||||||
|
|
||||||
|
// Verify all depths are correct
|
||||||
|
for (let i = 0; i <= 6; i++) {
|
||||||
|
expect(byId[`l${i}`].depth).toBe(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nesting up to depth 10 (Stalwart default max)', () => {
|
||||||
|
const mailboxes: Mailbox[] = [];
|
||||||
|
for (let i = 0; i <= 10; i++) {
|
||||||
|
mailboxes.push(
|
||||||
|
makeMailbox({
|
||||||
|
id: `level-${i}`,
|
||||||
|
name: `Folder${i}`,
|
||||||
|
parentId: i === 0 ? undefined : `level-${i - 1}`,
|
||||||
|
sortOrder: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
|
||||||
|
// Should have exactly 11 nodes total
|
||||||
|
expect(flat).toHaveLength(11);
|
||||||
|
// Only 1 root
|
||||||
|
expect(tree).toHaveLength(1);
|
||||||
|
|
||||||
|
// Each node at correct depth
|
||||||
|
for (let i = 0; i <= 10; i++) {
|
||||||
|
const node = flat.find(n => n.id === `level-${i}`);
|
||||||
|
expect(node).toBeDefined();
|
||||||
|
expect(node!.depth).toBe(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly count children at each level in deep trees', () => {
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'root', name: 'Root', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'a', name: 'A', parentId: 'root' }),
|
||||||
|
makeMailbox({ id: 'b', name: 'B', parentId: 'root' }),
|
||||||
|
makeMailbox({ id: 'a1', name: 'A1', parentId: 'a' }),
|
||||||
|
makeMailbox({ id: 'a2', name: 'A2', parentId: 'a' }),
|
||||||
|
makeMailbox({ id: 'a1x', name: 'A1X', parentId: 'a1' }),
|
||||||
|
makeMailbox({ id: 'a1y', name: 'A1Y', parentId: 'a1' }),
|
||||||
|
makeMailbox({ id: 'a1x_deep', name: 'A1X_DEEP', parentId: 'a1x' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||||
|
|
||||||
|
// Verify structure
|
||||||
|
expect(tree).toHaveLength(1); // just Root
|
||||||
|
expect(tree[0].children).toHaveLength(2); // A, B
|
||||||
|
|
||||||
|
// Check depths
|
||||||
|
expect(byId['root'].depth).toBe(0);
|
||||||
|
expect(byId['a'].depth).toBe(1);
|
||||||
|
expect(byId['b'].depth).toBe(1);
|
||||||
|
expect(byId['a1'].depth).toBe(2);
|
||||||
|
expect(byId['a2'].depth).toBe(2);
|
||||||
|
expect(byId['a1x'].depth).toBe(3);
|
||||||
|
expect(byId['a1y'].depth).toBe(3);
|
||||||
|
expect(byId['a1x_deep'].depth).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flatten deep trees in correct parent-before-child order', () => {
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'l0', name: 'L0', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l1', name: 'L1', parentId: 'l0', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l2', name: 'L2', parentId: 'l1', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l3', name: 'L3', parentId: 'l2', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l4', name: 'L4', parentId: 'l3', sortOrder: 0 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const ids = flat.map(n => n.id);
|
||||||
|
|
||||||
|
// Parent must always come before child in the flat list
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const parentIdx = ids.indexOf(`l${i}`);
|
||||||
|
const childIdx = ids.indexOf(`l${i + 1}`);
|
||||||
|
expect(parentIdx).toBeLessThan(childIdx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mailboxes provided in reverse order (children before parents)', () => {
|
||||||
|
// JMAP doesn't guarantee order - children might arrive before parents
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||||
|
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
|
||||||
|
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||||
|
|
||||||
|
// All nodes present
|
||||||
|
expect(flat).toHaveLength(5);
|
||||||
|
// Only 1 root
|
||||||
|
expect(tree).toHaveLength(1);
|
||||||
|
// Correct depths regardless of input order
|
||||||
|
expect(byId['inbox'].depth).toBe(0);
|
||||||
|
expect(byId['privat'].depth).toBe(1);
|
||||||
|
expect(byId['bookings'].depth).toBe(2);
|
||||||
|
expect(byId['hotel2'].depth).toBe(3);
|
||||||
|
expect(byId['restaurant'].depth).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle wide + deep trees without orphaning', () => {
|
||||||
|
// Mix of wide (many siblings) and deep nesting
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
|
// 5 children of inbox
|
||||||
|
...Array.from({ length: 5 }, (_, i) =>
|
||||||
|
makeMailbox({ id: `child-${i}`, name: `Child${i}`, parentId: 'inbox' })
|
||||||
|
),
|
||||||
|
// Each child has 2 sub-children
|
||||||
|
...Array.from({ length: 5 }, (_, i) => [
|
||||||
|
makeMailbox({ id: `gc-${i}-0`, name: `GC${i}-0`, parentId: `child-${i}` }),
|
||||||
|
makeMailbox({ id: `gc-${i}-1`, name: `GC${i}-1`, parentId: `child-${i}` }),
|
||||||
|
]).flat(),
|
||||||
|
// Some grandchildren have great-grandchildren (depth 3)
|
||||||
|
makeMailbox({ id: 'ggc-0', name: 'GGC0', parentId: 'gc-0-0' }),
|
||||||
|
makeMailbox({ id: 'ggc-1', name: 'GGC1', parentId: 'gc-2-1' }),
|
||||||
|
// depth 4
|
||||||
|
makeMailbox({ id: 'gggc-0', name: 'GGGC0', parentId: 'ggc-0' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const rootNames = tree.map(n => n.name);
|
||||||
|
|
||||||
|
// Only INBOX should be at root
|
||||||
|
expect(rootNames).toEqual(['INBOX']);
|
||||||
|
// Total nodes
|
||||||
|
expect(flat).toHaveLength(mailboxes.length);
|
||||||
|
|
||||||
|
// Verify depth-4 node
|
||||||
|
const gggc = flat.find(n => n.id === 'gggc-0');
|
||||||
|
expect(gggc).toBeDefined();
|
||||||
|
expect(gggc!.depth).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () => {
|
||||||
|
it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
|
||||||
|
// Reporter's exact scenario: two subfolders with the same name.
|
||||||
|
// The dedup uses substring matching and removes non-role folders whose name
|
||||||
|
// matches a role folder — even if they're deep in the tree with children.
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
||||||
|
// User-created subfolder also named "Sent" nested under Inbox
|
||||||
|
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
|
||||||
|
// Child of the custom "Sent" folder — becomes orphaned if parent is deduped
|
||||||
|
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const rootIds = tree.map(n => n.id);
|
||||||
|
|
||||||
|
// sent-custom MUST be kept because it has children — removing it orphans sent-child
|
||||||
|
const sentCustom = flat.find(n => n.id === 'sent-custom');
|
||||||
|
expect(sentCustom).toBeDefined();
|
||||||
|
expect(sentCustom!.depth).toBe(1); // nested under Inbox
|
||||||
|
|
||||||
|
const sentChild = flat.find(n => n.id === 'sent-child');
|
||||||
|
expect(sentChild).toBeDefined();
|
||||||
|
expect(sentChild!.depth).toBe(2); // nested under sent-custom
|
||||||
|
expect(rootIds).not.toContain('sent-child'); // must NOT be orphaned at root
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep nested folders when name is substring of a role name', () => {
|
||||||
|
// "Draft" is a substring of "Drafts" — dedup removes it, orphaning children
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
|
||||||
|
makeMailbox({ id: 'draft-folder', name: 'Draft', parentId: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'draft-child', name: 'Notes', parentId: 'draft-folder' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const rootIds = tree.map(n => n.id);
|
||||||
|
|
||||||
|
const draftChild = flat.find(n => n.id === 'draft-child');
|
||||||
|
expect(draftChild).toBeDefined();
|
||||||
|
expect(draftChild!.depth).toBe(2);
|
||||||
|
expect(rootIds).not.toContain('draft-child');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle the exact reported structure with duplicate names at different depths', () => {
|
||||||
|
// Stalwart allows creating subfolders with the same name at different levels.
|
||||||
|
// If any of those names match a role mailbox name, dedup could remove them.
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'trash-role', name: 'Trash', role: 'trash' }),
|
||||||
|
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
|
||||||
|
// User created a subfolder named "Trash" under BOOKINGS (e.g. for old bookings)
|
||||||
|
makeMailbox({ id: 'trash-custom', name: 'Trash', parentId: 'bookings' }),
|
||||||
|
// Depth 4: child of the custom Trash folder
|
||||||
|
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'trash-custom' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const rootIds = tree.map(n => n.id);
|
||||||
|
|
||||||
|
// RESTAURANT must be at depth 4, not orphaned at root
|
||||||
|
const restaurant = flat.find(n => n.id === 'restaurant');
|
||||||
|
expect(restaurant).toBeDefined();
|
||||||
|
expect(restaurant!.depth).toBe(4);
|
||||||
|
expect(rootIds).not.toContain('restaurant');
|
||||||
|
|
||||||
|
// Custom "Trash" must be kept as it has children
|
||||||
|
const trashCustom = flat.find(n => n.id === 'trash-custom');
|
||||||
|
expect(trashCustom).toBeDefined();
|
||||||
|
expect(trashCustom!.depth).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only dedup root-level non-role mailboxes that duplicate role mailboxes', () => {
|
||||||
|
// Dedup should only remove mailboxes that are BOTH:
|
||||||
|
// 1. At root level (no parentId) — same structural position as role mailbox
|
||||||
|
// 2. Name-matching a role mailbox
|
||||||
|
// Nested mailboxes with matching names should always be kept.
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
||||||
|
makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level duplicate — OK to remove
|
||||||
|
makeMailbox({ id: 'proj', name: 'Projects', parentId: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'sent-nested', name: 'Sent', parentId: 'proj' }), // nested — must keep
|
||||||
|
makeMailbox({ id: 'report', name: 'Report', parentId: 'sent-nested' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
|
||||||
|
// "Sent Mail" at root (no parentId) can be deduped — that's fine
|
||||||
|
// But "Sent" nested under Projects must be kept
|
||||||
|
const sentNested = flat.find(n => n.id === 'sent-nested');
|
||||||
|
expect(sentNested).toBeDefined();
|
||||||
|
expect(sentNested!.depth).toBe(2);
|
||||||
|
|
||||||
|
const report = flat.find(n => n.id === 'report');
|
||||||
|
expect(report).toBeDefined();
|
||||||
|
expect(report!.depth).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mailbox orphan behavior (missing parent)', () => {
|
||||||
|
it('should expose orphan-to-root behavior when parent is missing', () => {
|
||||||
|
// Simulates what happens if JMAP response is incomplete (e.g., truncated at 500 objects)
|
||||||
|
// Middle parent "bookings" is missing from the response
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||||
|
// 'bookings' is MISSING — simulating truncated JMAP response
|
||||||
|
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const rootIds = tree.map(n => n.id);
|
||||||
|
|
||||||
|
// hotel2's parent ("bookings") is missing from the response.
|
||||||
|
// Current behavior: hotel2 becomes a root node (orphaned).
|
||||||
|
// This means RESTAURANT also appears under hotel2 at root, but at depth 1 instead of depth 4.
|
||||||
|
const hotel2 = flat.find(n => n.id === 'hotel2');
|
||||||
|
expect(hotel2).toBeDefined();
|
||||||
|
|
||||||
|
// Diagnostic: document that orphaning DOES happen
|
||||||
|
if (rootIds.includes('hotel2')) {
|
||||||
|
expect(hotel2!.depth).toBe(0); // orphaned at root
|
||||||
|
const restaurant = flat.find(n => n.id === 'restaurant');
|
||||||
|
expect(restaurant!.depth).toBe(1); // child of orphaned root
|
||||||
|
console.warn(
|
||||||
|
'CONFIRMED: Missing intermediate parent causes orphan-to-root. ' +
|
||||||
|
'hotel2 (parentId: "bookings") is at root with depth 0 instead of depth 3.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expose orphan behavior with multiple missing parents in chain', () => {
|
||||||
|
// Deep chain where two intermediate parents are missing
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'root', name: 'Root', sortOrder: 0 }),
|
||||||
|
// 'level1' is MISSING
|
||||||
|
// 'level2' is MISSING
|
||||||
|
makeMailbox({ id: 'level3', name: 'Level3', parentId: 'level2' }),
|
||||||
|
makeMailbox({ id: 'level4', name: 'Level4', parentId: 'level3' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const rootIds = tree.map(n => n.id);
|
||||||
|
|
||||||
|
// level3's parent (level2) is missing → level3 becomes root
|
||||||
|
expect(rootIds).toContain('level3');
|
||||||
|
|
||||||
|
// level4 is correctly nested under level3 (since level3 IS in the map)
|
||||||
|
const level4 = flat.find(n => n.id === 'level4');
|
||||||
|
expect(level4).toBeDefined();
|
||||||
|
expect(level4!.depth).toBe(1); // child of orphaned level3 (depth 0)
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
'CONFIRMED: Missing parents in chain cause subtree to float to root. ' +
|
||||||
|
'Level3 (expected depth 2) is at depth 0, Level4 (expected depth 3) is at depth 1.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,6 +52,8 @@ function resetStore() {
|
|||||||
'settings-section': [],
|
'settings-section': [],
|
||||||
'context-menu-email': [],
|
'context-menu-email': [],
|
||||||
'navigation-rail-bottom': [],
|
'navigation-rail-bottom': [],
|
||||||
|
'calendar-event-actions': [],
|
||||||
|
'admin-plugin-page': [],
|
||||||
},
|
},
|
||||||
initialized: false,
|
initialized: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { expandRecurringEvents } from '../recurrence-expansion';
|
||||||
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
/** Helper: create a minimal CalendarEvent for testing recurrence */
|
||||||
|
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||||
|
return {
|
||||||
|
id: 'evt1',
|
||||||
|
uid: 'uid1',
|
||||||
|
calendarIds: { cal1: true },
|
||||||
|
start: '2025-01-06T09:00:00', // Monday
|
||||||
|
duration: 'PT1H',
|
||||||
|
title: 'Test Event',
|
||||||
|
showWithoutTime: false,
|
||||||
|
recurrenceRules: null,
|
||||||
|
recurrenceOverrides: null,
|
||||||
|
excludedRecurrenceRules: null,
|
||||||
|
...overrides,
|
||||||
|
} as CalendarEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expand(event: CalendarEvent, rangeStart: string, rangeEnd: string) {
|
||||||
|
return expandRecurringEvents([event], rangeStart, rangeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function starts(events: CalendarEvent[]) {
|
||||||
|
return events.map(e => e.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('expandRecurringEvents', () => {
|
||||||
|
it('passes through non-recurring events unchanged', () => {
|
||||||
|
const event = makeEvent();
|
||||||
|
const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe('evt1');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Daily
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('daily frequency', () => {
|
||||||
|
it('expands daily events within range', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
|
||||||
|
expect(starts(result)).toEqual([
|
||||||
|
'2025-01-06T09:00:00',
|
||||||
|
'2025-01-07T09:00:00',
|
||||||
|
'2025-01-08T09:00:00',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects interval', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', interval: 2 } as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-01-12T00:00:00');
|
||||||
|
expect(starts(result)).toEqual([
|
||||||
|
'2025-01-06T09:00:00',
|
||||||
|
'2025-01-08T09:00:00',
|
||||||
|
'2025-01-10T09:00:00',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects count', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 3 } as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects until', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', until: '2025-01-08T09:00:00' } as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Weekly
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('weekly frequency', () => {
|
||||||
|
it('expands weekly with implicit byDay (same weekday as start)', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||||
|
});
|
||||||
|
// Jan 6 is Monday, so every Monday
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
|
||||||
|
expect(starts(result)).toEqual([
|
||||||
|
'2025-01-06T09:00:00',
|
||||||
|
'2025-01-13T09:00:00',
|
||||||
|
'2025-01-20T09:00:00',
|
||||||
|
'2025-01-27T09:00:00',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands weekly with explicit byDay (MWF)', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{
|
||||||
|
'@type': 'RecurrenceRule',
|
||||||
|
frequency: 'weekly',
|
||||||
|
byDay: [{ day: 'mo' }, { day: 'we' }, { day: 'fr' }],
|
||||||
|
} as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
|
||||||
|
expect(starts(result)).toEqual([
|
||||||
|
'2025-01-06T09:00:00',
|
||||||
|
'2025-01-08T09:00:00',
|
||||||
|
'2025-01-10T09:00:00',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands weekly with interval=2', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{
|
||||||
|
'@type': 'RecurrenceRule',
|
||||||
|
frequency: 'weekly',
|
||||||
|
interval: 2,
|
||||||
|
byDay: [{ day: 'mo' }],
|
||||||
|
} as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-02-03T00:00:00');
|
||||||
|
expect(starts(result)).toEqual([
|
||||||
|
'2025-01-06T09:00:00',
|
||||||
|
'2025-01-20T09:00:00',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Monthly
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('monthly frequency', () => {
|
||||||
|
it('expands monthly with implicit byMonthDay', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2025-01-15T10:00:00',
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'monthly' } as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
|
||||||
|
expect(starts(result)).toEqual([
|
||||||
|
'2025-01-15T10:00:00',
|
||||||
|
'2025-02-15T10:00:00',
|
||||||
|
'2025-03-15T10:00:00',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands monthly with byMonthDay', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2025-01-01T08:00:00',
|
||||||
|
recurrenceRules: [{
|
||||||
|
'@type': 'RecurrenceRule',
|
||||||
|
frequency: 'monthly',
|
||||||
|
byMonthDay: [1, 15],
|
||||||
|
} as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-01T00:00:00', '2025-02-28T00:00:00');
|
||||||
|
expect(starts(result)).toEqual([
|
||||||
|
'2025-01-01T08:00:00',
|
||||||
|
'2025-01-15T08:00:00',
|
||||||
|
'2025-02-01T08:00:00',
|
||||||
|
'2025-02-15T08:00:00',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands monthly with negative byMonthDay (-1 = last day)', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2025-01-31T08:00:00',
|
||||||
|
recurrenceRules: [{
|
||||||
|
'@type': 'RecurrenceRule',
|
||||||
|
frequency: 'monthly',
|
||||||
|
byMonthDay: [-1],
|
||||||
|
} as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
|
||||||
|
const days = result.map(e => e.start.substring(0, 10));
|
||||||
|
expect(days).toEqual(['2025-01-31', '2025-02-28', '2025-03-31']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands monthly with byDay + nthOfPeriod (2nd Tuesday)', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2025-01-14T09:00:00', // 2nd Tuesday
|
||||||
|
recurrenceRules: [{
|
||||||
|
'@type': 'RecurrenceRule',
|
||||||
|
frequency: 'monthly',
|
||||||
|
byDay: [{ day: 'tu', nthOfPeriod: 2 }],
|
||||||
|
} as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
|
||||||
|
const days = result.map(e => e.start.substring(0, 10));
|
||||||
|
expect(days).toEqual(['2025-01-14', '2025-02-11', '2025-03-11']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands monthly with byDay nthOfPeriod=-1 (last Friday)', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2025-01-31T09:00:00', // last Friday of Jan
|
||||||
|
recurrenceRules: [{
|
||||||
|
'@type': 'RecurrenceRule',
|
||||||
|
frequency: 'monthly',
|
||||||
|
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
|
||||||
|
} as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
|
||||||
|
const days = result.map(e => e.start.substring(0, 10));
|
||||||
|
expect(days).toEqual(['2025-01-31', '2025-02-28', '2025-03-28']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Yearly
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('yearly frequency', () => {
|
||||||
|
it('expands yearly on the same date', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2023-03-15T12:00:00',
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'yearly' } as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2023-01-01T00:00:00', '2026-01-01T00:00:00');
|
||||||
|
expect(starts(result)).toEqual([
|
||||||
|
'2023-03-15T12:00:00',
|
||||||
|
'2024-03-15T12:00:00',
|
||||||
|
'2025-03-15T12:00:00',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands yearly with byMonth and byDay (last Friday of November = Thanksgiving-ish)', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2025-11-28T09:00:00', // last Friday of Nov 2025
|
||||||
|
recurrenceRules: [{
|
||||||
|
'@type': 'RecurrenceRule',
|
||||||
|
frequency: 'yearly',
|
||||||
|
byMonth: ['11'],
|
||||||
|
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
|
||||||
|
} as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
|
||||||
|
const days = result.map(e => e.start.substring(0, 10));
|
||||||
|
// Last Friday of November: 2025-11-28, 2026-11-27, 2027-11-26
|
||||||
|
expect(days).toEqual(['2025-11-28', '2026-11-27', '2027-11-26']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands yearly with byMonth + byMonthDay', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2025-07-04T00:00:00',
|
||||||
|
showWithoutTime: true,
|
||||||
|
recurrenceRules: [{
|
||||||
|
'@type': 'RecurrenceRule',
|
||||||
|
frequency: 'yearly',
|
||||||
|
byMonth: ['7'],
|
||||||
|
byMonthDay: [4],
|
||||||
|
} as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// bySetPosition
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('bySetPosition', () => {
|
||||||
|
it('selects first and last from monthly byDay expansion', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2025-01-06T10:00:00',
|
||||||
|
recurrenceRules: [{
|
||||||
|
'@type': 'RecurrenceRule',
|
||||||
|
frequency: 'monthly',
|
||||||
|
byDay: [{ day: 'mo' }, { day: 'tu' }, { day: 'we' }, { day: 'th' }, { day: 'fr' }],
|
||||||
|
bySetPosition: [1, -1], // first and last weekday of month
|
||||||
|
} as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-01T00:00:00', '2025-03-01T00:00:00');
|
||||||
|
const days = result.map(e => e.start.substring(0, 10));
|
||||||
|
// Jan: first weekday = Jan 1 (Wed), last weekday = Jan 31 (Fri)
|
||||||
|
// Feb: first weekday = Feb 3 (Mon), last weekday = Feb 28 (Fri)
|
||||||
|
// But event starts Jan 6, so Jan 1 is before start → filtered out
|
||||||
|
expect(days).toContain('2025-01-31');
|
||||||
|
expect(days).toContain('2025-02-03');
|
||||||
|
expect(days).toContain('2025-02-28');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Recurrence overrides
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('recurrenceOverrides', () => {
|
||||||
|
it('applies overrides to matching occurrences', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||||
|
recurrenceOverrides: {
|
||||||
|
'2025-01-07T09:00:00': { title: 'Modified' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
|
||||||
|
const modified = result.find(e => e.recurrenceId === '2025-01-07T09:00:00');
|
||||||
|
expect(modified?.title).toBe('Modified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes occurrences marked as excluded', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||||
|
recurrenceOverrides: {
|
||||||
|
'2025-01-07T09:00:00': { excluded: true } as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(starts(result)).toEqual(['2025-01-06T09:00:00', '2025-01-08T09:00:00']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds RDATE-style overrides not generated by rules', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||||
|
recurrenceOverrides: {
|
||||||
|
'2025-01-08T09:00:00': { title: 'Extra Wednesday' }, // Not a Monday
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-01-14T00:00:00');
|
||||||
|
expect(result.some(e => e.recurrenceId === '2025-01-08T09:00:00')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// All-day events
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('all-day events', () => {
|
||||||
|
it('expands all-day weekly events', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2025-01-06T00:00:00',
|
||||||
|
showWithoutTime: true,
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
|
||||||
|
expect(result).toHaveLength(4); // 4 Mondays: 6, 13, 20, 27
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Edge cases
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('does not exceed 500 occurrences', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-01T00:00:00', '2030-01-01T00:00:00');
|
||||||
|
expect(result.length).toBeLessThanOrEqual(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles invalid start date gracefully', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: 'invalid',
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00');
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates synthetic IDs for occurrences', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
|
||||||
|
expect(result[0].id).toBe('evt1:2025-01-06T09:00:00');
|
||||||
|
expect(result[1].id).toBe('evt1:2025-01-07T09:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves originalId pointing to master', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||||
|
});
|
||||||
|
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
|
||||||
|
expect(result[0].originalId).toBe('evt1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { findReplyIdentityId } from '../reply-identity';
|
||||||
|
import type { Identity } from '../jmap/types';
|
||||||
|
|
||||||
|
const identities: Identity[] = [
|
||||||
|
{
|
||||||
|
id: 'primary',
|
||||||
|
name: 'Harry Primary',
|
||||||
|
email: 'harry@primary.com',
|
||||||
|
mayDelete: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'secondary',
|
||||||
|
name: 'Harry Secondary',
|
||||||
|
email: 'harry@secondary.com',
|
||||||
|
mayDelete: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('findReplyIdentityId', () => {
|
||||||
|
it('matches the identity that received the original message', () => {
|
||||||
|
const selected = findReplyIdentityId(identities, {
|
||||||
|
to: [{ email: 'harry@secondary.com' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selected).toBe('secondary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches case-insensitively across recipients', () => {
|
||||||
|
const selected = findReplyIdentityId(identities, {
|
||||||
|
cc: [{ email: 'HARRY@PRIMARY.COM' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selected).toBe('primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to sub-address matching when needed', () => {
|
||||||
|
const selected = findReplyIdentityId(identities, {
|
||||||
|
to: [{ email: 'harry+news@secondary.com' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selected).toBe('secondary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no reply recipient matches an identity', () => {
|
||||||
|
const selected = findReplyIdentityId(identities, {
|
||||||
|
to: [{ email: 'other@example.com' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selected).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { useFilterStore } from '@/stores/filter-store';
|
|||||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||||
import { useIdentityStore } from '@/stores/identity-store';
|
import { useIdentityStore } from '@/stores/identity-store';
|
||||||
import { useVacationStore } from '@/stores/vacation-store';
|
import { useVacationStore } from '@/stores/vacation-store';
|
||||||
|
import { useSmimeStore } from '@/stores/smime-store';
|
||||||
|
|
||||||
// Minimal snapshot shapes — we only capture what we need
|
// Minimal snapshot shapes — we only capture what we need
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -117,6 +118,7 @@ export function clearAllStores(): void {
|
|||||||
useVacationStore.getState().clearState();
|
useVacationStore.getState().clearState();
|
||||||
useCalendarStore.getState().clearState();
|
useCalendarStore.getState().clearState();
|
||||||
useFilterStore.getState().clearState();
|
useFilterStore.getState().clearState();
|
||||||
|
useSmimeStore.getState().clearState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Evict cached state for one account */
|
/** Evict cached state for one account */
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
function getAdminDir(): string {
|
||||||
|
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPluginConfigDir(): string {
|
||||||
|
return path.join(getAdminDir(), 'plugin-config');
|
||||||
|
}
|
||||||
|
|
||||||
|
function configPath(pluginId: string): string {
|
||||||
|
return path.join(getPluginConfigDir(), `${pluginId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(dir: string): Promise<void> {
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all config for a plugin.
|
||||||
|
*/
|
||||||
|
export async function getPluginConfig(pluginId: string): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(configPath(pluginId), 'utf-8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
||||||
|
logger.warn(`Failed to read plugin config for ${pluginId}`, {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a single config key for a plugin.
|
||||||
|
*/
|
||||||
|
export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise<void> {
|
||||||
|
const dir = getPluginConfigDir();
|
||||||
|
await ensureDir(dir);
|
||||||
|
|
||||||
|
const config = await getPluginConfig(pluginId);
|
||||||
|
config[key] = value;
|
||||||
|
|
||||||
|
const filePath = configPath(pluginId);
|
||||||
|
const tmpPath = filePath + '.tmp';
|
||||||
|
await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||||
|
await rename(tmpPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single config key for a plugin.
|
||||||
|
*/
|
||||||
|
export async function deletePluginConfigKey(pluginId: string, key: string): Promise<void> {
|
||||||
|
const config = await getPluginConfig(pluginId);
|
||||||
|
delete config[key];
|
||||||
|
|
||||||
|
if (Object.keys(config).length === 0) {
|
||||||
|
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = getPluginConfigDir();
|
||||||
|
await ensureDir(dir);
|
||||||
|
const filePath = configPath(pluginId);
|
||||||
|
const tmpPath = filePath + '.tmp';
|
||||||
|
await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||||
|
await rename(tmpPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all config for a plugin (used when uninstalling).
|
||||||
|
*/
|
||||||
|
export async function deleteAllPluginConfig(pluginId: string): Promise<void> {
|
||||||
|
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
|
||||||
|
}
|
||||||
@@ -17,6 +17,16 @@ function getThemesDir(): string {
|
|||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PluginConfigField {
|
||||||
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerPlugin {
|
export interface ServerPlugin {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -28,6 +38,7 @@ export interface ServerPlugin {
|
|||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
forceEnabled?: boolean;
|
forceEnabled?: boolean;
|
||||||
|
configSchema?: Record<string, PluginConfigField>;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-1
@@ -8,9 +8,17 @@ const ALGORITHM = 'aes-256-gcm';
|
|||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const TAG_LENGTH = 16;
|
const TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET;
|
const secret = process.env.SESSION_SECRET;
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||||
|
if (secret.length < MIN_SECRET_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
|
||||||
|
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
||||||
|
);
|
||||||
|
}
|
||||||
return createHash('sha256').update(secret).digest();
|
return createHash('sha256').update(secret).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,11 +124,24 @@ export async function clearAdminSessionCookie(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the client IP from the request headers.
|
* Get the client IP from the request headers.
|
||||||
|
*
|
||||||
|
* Proxies typically *append* to X-Forwarded-For, so the last entry
|
||||||
|
* before our trusted proxy is the most reliable client IP. When a
|
||||||
|
* single reverse proxy sits in front of the app the rightmost entry
|
||||||
|
* is the one added by that proxy. We take the rightmost entry to
|
||||||
|
* avoid trusting attacker-controlled values prepended to the header.
|
||||||
|
*
|
||||||
|
* If you run behind multiple trusted proxies, set TRUSTED_PROXY_DEPTH
|
||||||
|
* to the number of trusted proxies (default 1).
|
||||||
*/
|
*/
|
||||||
export function getClientIP(request: Request): string {
|
export function getClientIP(request: Request): string {
|
||||||
const forwarded = request.headers.get('x-forwarded-for');
|
const forwarded = request.headers.get('x-forwarded-for');
|
||||||
if (forwarded) {
|
if (forwarded) {
|
||||||
return forwarded.split(',')[0].trim();
|
const parts = forwarded.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const depth = Math.max(1, parseInt(process.env.TRUSTED_PROXY_DEPTH || '1', 10));
|
||||||
|
// Take the entry at position (length - depth), clamped to 0
|
||||||
|
const index = Math.max(0, parts.length - depth);
|
||||||
|
return parts[index] || '0.0.0.0';
|
||||||
}
|
}
|
||||||
return request.headers.get('x-real-ip') || '0.0.0.0';
|
return request.headers.get('x-real-ip') || '0.0.0.0';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface FeatureGates {
|
|||||||
debugModeEnabled: boolean;
|
debugModeEnabled: boolean;
|
||||||
folderIconsEnabled: boolean;
|
folderIconsEnabled: boolean;
|
||||||
hoverActionsConfigEnabled: boolean;
|
hoverActionsConfigEnabled: boolean;
|
||||||
|
filesEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||||
@@ -54,6 +55,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
|||||||
debugModeEnabled: true,
|
debugModeEnabled: true,
|
||||||
folderIconsEnabled: true,
|
folderIconsEnabled: true,
|
||||||
hoverActionsConfigEnabled: true,
|
hoverActionsConfigEnabled: true,
|
||||||
|
filesEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ThemePolicy {
|
export interface ThemePolicy {
|
||||||
@@ -120,6 +122,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
|
|||||||
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
||||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
|
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
|
||||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||||
|
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||||||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useAccountStore } from '@/stores/account-store';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
export function getActiveAccountSlot(): number | null {
|
||||||
|
const authState = useAuthStore.getState();
|
||||||
|
const accountState = useAccountStore.getState();
|
||||||
|
const activeAccountId = authState.activeAccountId ?? accountState.activeAccountId;
|
||||||
|
const activeAccount = activeAccountId
|
||||||
|
? accountState.getAccountById(activeAccountId)
|
||||||
|
: accountState.getActiveAccount();
|
||||||
|
|
||||||
|
return typeof activeAccount?.cookieSlot === 'number' ? activeAccount.cookieSlot : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveAccountSlotHeaders(): Record<string, string> {
|
||||||
|
const slot = getActiveAccountSlot();
|
||||||
|
return slot === null ? {} : { 'X-JMAP-Cookie-Slot': String(slot) };
|
||||||
|
}
|
||||||
@@ -5,9 +5,17 @@ const ALGORITHM = 'aes-256-gcm';
|
|||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const TAG_LENGTH = 16;
|
const TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET;
|
const secret = process.env.SESSION_SECRET;
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||||
|
if (secret.length < MIN_SECRET_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
|
||||||
|
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
||||||
|
);
|
||||||
|
}
|
||||||
return createHash('sha256').update(secret).digest();
|
return createHash('sha256').update(secret).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const VERIFY_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
export class JmapAuthVerificationError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'JmapAuthVerificationError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedProtocol(protocol: string): boolean {
|
||||||
|
return protocol === 'http:' || protocol === 'https:';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeJmapServerUrl(serverUrl: string): string {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(serverUrl);
|
||||||
|
} catch {
|
||||||
|
throw new JmapAuthVerificationError('Invalid server URL', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupportedProtocol(url.protocol)) {
|
||||||
|
throw new JmapAuthVerificationError('Unsupported server URL protocol', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
url.hash = '';
|
||||||
|
url.search = '';
|
||||||
|
return url.toString().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateProxyAuthHeader(authHeader: string): void {
|
||||||
|
if (!/^(?:Basic|Bearer)\s+\S+$/i.test(authHeader)) {
|
||||||
|
throw new JmapAuthVerificationError('Invalid Authorization header', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise<string> {
|
||||||
|
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
|
||||||
|
validateProxyAuthHeader(authHeader);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: authHeader },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new JmapAuthVerificationError(
|
||||||
|
response.status === 401 || response.status === 403
|
||||||
|
? 'Authentication failed'
|
||||||
|
: 'Failed to verify JMAP session',
|
||||||
|
response.status === 401 || response.status === 403 ? 401 : 502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await response.json().catch(() => null) as { apiUrl?: unknown; accounts?: unknown } | null;
|
||||||
|
if (!session || typeof session.apiUrl !== 'string' || typeof session.accounts !== 'object' || session.accounts === null) {
|
||||||
|
throw new JmapAuthVerificationError('Invalid JMAP session response', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedServerUrl;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new JmapAuthVerificationError('JMAP session verification timed out', 504);
|
||||||
|
}
|
||||||
|
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,15 +74,57 @@ export function isAllDayEventLike(event: Pick<Partial<CalendarEvent>, 'start' |
|
|||||||
&& end.getMilliseconds() === 0;
|
&& end.getMilliseconds() === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart returns "recurrenceRule" (singular) instead of RFC 8984 "recurrenceRules" (plural).
|
||||||
|
* Normalize server responses to match the client's internal type.
|
||||||
|
*/
|
||||||
|
function normalizeStalwartPropertyNames<T extends Partial<CalendarEvent>>(event: T): T {
|
||||||
|
const raw = event as Record<string, unknown>;
|
||||||
|
let patched = false;
|
||||||
|
const updates: Partial<CalendarEvent> = {};
|
||||||
|
|
||||||
|
if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) {
|
||||||
|
// JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object,
|
||||||
|
// but Stalwart may also return it as an array (for JMAP-created events).
|
||||||
|
// Normalize both forms to our internal array type.
|
||||||
|
const val = raw.recurrenceRule;
|
||||||
|
if (val != null && !Array.isArray(val) && typeof val === 'object') {
|
||||||
|
updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules'];
|
||||||
|
} else {
|
||||||
|
updates.recurrenceRules = val as CalendarEvent['recurrenceRules'];
|
||||||
|
}
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) {
|
||||||
|
const val = raw.excludedRecurrenceRule;
|
||||||
|
if (val != null && !Array.isArray(val) && typeof val === 'object') {
|
||||||
|
updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules'];
|
||||||
|
} else {
|
||||||
|
updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules'];
|
||||||
|
}
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!patched) return event;
|
||||||
|
|
||||||
|
const result = { ...event, ...updates } as T;
|
||||||
|
delete (result as Record<string, unknown>).recurrenceRule;
|
||||||
|
delete (result as Record<string, unknown>).excludedRecurrenceRule;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeCalendarEventLike<T extends Partial<CalendarEvent>>(event: T): T {
|
export function normalizeCalendarEventLike<T extends Partial<CalendarEvent>>(event: T): T {
|
||||||
if (!isAllDayEventLike(event)) {
|
// First normalize Stalwart's singular property names to RFC 8984 plural forms
|
||||||
return event;
|
const normalized = normalizeStalwartPropertyNames(event);
|
||||||
|
|
||||||
|
if (!isAllDayEventLike(normalized)) {
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...event,
|
...normalized,
|
||||||
showWithoutTime: true,
|
showWithoutTime: true,
|
||||||
duration: normalizeAllDayDurationValue(event.duration),
|
duration: normalizeAllDayDurationValue(normalized.duration),
|
||||||
} as T;
|
} as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+149
-38
@@ -1,4 +1,4 @@
|
|||||||
import { differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
|
import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
|
||||||
import { parseDuration } from "@/components/calendar/event-card";
|
import { parseDuration } from "@/components/calendar/event-card";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -11,15 +11,63 @@ export interface CalendarWeekSegment {
|
|||||||
continuesAfter: boolean;
|
continuesAfter: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TimedEventLayout {
|
||||||
|
event: CalendarEvent;
|
||||||
|
column: number;
|
||||||
|
totalColumns: number;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
continuesBefore: boolean;
|
||||||
|
continuesAfter: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventStartDate(
|
||||||
|
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
|
||||||
|
): Date {
|
||||||
|
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
|
||||||
|
return parseISO(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
|
||||||
|
rawSegments.sort((left, right) => {
|
||||||
|
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
|
||||||
|
if (left.span !== right.span) return right.span - left.span;
|
||||||
|
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
|
||||||
|
return left.event.showWithoutTime ? -1 : 1;
|
||||||
|
}
|
||||||
|
const timeDiff = getEventStartDate(left.event).getTime() - getEventStartDate(right.event).getTime();
|
||||||
|
if (timeDiff !== 0) return timeDiff;
|
||||||
|
return (left.event.title || "").localeCompare(right.event.title || "");
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowEndIndices: number[] = [];
|
||||||
|
return rawSegments.map((segment) => {
|
||||||
|
const segmentEndIndex = segment.startIndex + segment.span - 1;
|
||||||
|
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
|
||||||
|
if (row === -1) {
|
||||||
|
row = rowEndIndices.length;
|
||||||
|
rowEndIndices.push(segmentEndIndex);
|
||||||
|
} else {
|
||||||
|
rowEndIndices[row] = segmentEndIndex;
|
||||||
|
}
|
||||||
|
return { ...segment, row };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getEventEndDate(event: CalendarEvent): Date {
|
export function getEventEndDate(event: CalendarEvent): Date {
|
||||||
const start = parseISO(event.start);
|
if (!event.showWithoutTime && event.utcEnd) {
|
||||||
|
return parseISO(event.utcEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = getEventStartDate(event);
|
||||||
if (!event.duration) return start;
|
if (!event.duration) return start;
|
||||||
return new Date(start.getTime() + parseDuration(event.duration) * 60000);
|
return new Date(start.getTime() + parseDuration(event.duration) * 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
||||||
const end = getEventEndDate(event);
|
const end = getEventEndDate(event);
|
||||||
if (!event.showWithoutTime || end.getTime() <= parseISO(event.start).getTime()) {
|
const start = getEventStartDate(event);
|
||||||
|
if (!event.showWithoutTime || end.getTime() <= start.getTime()) {
|
||||||
return end;
|
return end;
|
||||||
}
|
}
|
||||||
return subMilliseconds(end, 1);
|
return subMilliseconds(end, 1);
|
||||||
@@ -27,11 +75,44 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
|||||||
|
|
||||||
export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } {
|
export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } {
|
||||||
return {
|
return {
|
||||||
startDay: startOfDay(parseISO(event.start)),
|
startDay: startOfDay(getEventStartDate(event)),
|
||||||
endDay: startOfDay(getEventDisplayEndDate(event)),
|
endDay: startOfDay(getEventDisplayEndDate(event)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTimedEventBoundsForDay(
|
||||||
|
event: CalendarEvent,
|
||||||
|
day: Date,
|
||||||
|
): { startMinutes: number; endMinutes: number; continuesBefore: boolean; continuesAfter: boolean } | null {
|
||||||
|
if (event.showWithoutTime) return null;
|
||||||
|
|
||||||
|
const eventStart = getEventStartDate(event);
|
||||||
|
const eventEnd = getEventEndDate(event);
|
||||||
|
const dayStart = startOfDay(day);
|
||||||
|
const nextDayStart = addDays(dayStart, 1);
|
||||||
|
|
||||||
|
if (eventEnd <= dayStart || eventStart >= nextDayStart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clippedStart = eventStart > dayStart ? eventStart : dayStart;
|
||||||
|
const clippedEnd = eventEnd < nextDayStart ? eventEnd : nextDayStart;
|
||||||
|
const startMinutes = Math.max(0, Math.floor((clippedStart.getTime() - dayStart.getTime()) / 60000));
|
||||||
|
const endMinutes = Math.min(1440, Math.ceil((clippedEnd.getTime() - dayStart.getTime()) / 60000));
|
||||||
|
|
||||||
|
return {
|
||||||
|
startMinutes,
|
||||||
|
endMinutes,
|
||||||
|
continuesBefore: eventStart < dayStart,
|
||||||
|
continuesAfter: eventEnd > nextDayStart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
|
||||||
|
const bounds = getTimedEventBoundsForDay(event, day);
|
||||||
|
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeAllDayDuration(duration: string | undefined): string | undefined {
|
export function normalizeAllDayDuration(duration: string | undefined): string | undefined {
|
||||||
if (!duration) return undefined;
|
if (!duration) return undefined;
|
||||||
const totalMinutes = parseDuration(duration);
|
const totalMinutes = parseDuration(duration);
|
||||||
@@ -44,7 +125,7 @@ export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
|
|||||||
return `P${dayCount}D`;
|
return `P${dayCount}D`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
export function buildWeekSegmentsRaw(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
||||||
if (weekDays.length === 0) return [];
|
if (weekDays.length === 0) return [];
|
||||||
|
|
||||||
const weekStart = startOfDay(weekDays[0]);
|
const weekStart = startOfDay(weekDays[0]);
|
||||||
@@ -71,59 +152,89 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca
|
|||||||
} satisfies CalendarWeekSegment];
|
} satisfies CalendarWeekSegment];
|
||||||
});
|
});
|
||||||
|
|
||||||
rawSegments.sort((left, right) => {
|
return rawSegments;
|
||||||
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
|
}
|
||||||
if (left.span !== right.span) return right.span - left.span;
|
|
||||||
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
|
export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
||||||
return left.event.showWithoutTime ? -1 : 1;
|
return packWeekSegments(buildWeekSegmentsRaw(events, weekDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimedFullDayWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
||||||
|
if (weekDays.length === 0) return [];
|
||||||
|
|
||||||
|
const rawSegments = events.flatMap((event) => {
|
||||||
|
const fullDayIndices = weekDays
|
||||||
|
.map((day, index) => (isTimedEventFullDayOnDate(event, day) ? index : -1))
|
||||||
|
.filter((index) => index >= 0);
|
||||||
|
|
||||||
|
if (fullDayIndices.length === 0) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
const timeDiff = parseISO(left.event.start).getTime() - parseISO(right.event.start).getTime();
|
|
||||||
if (timeDiff !== 0) return timeDiff;
|
const segments: CalendarWeekSegment[] = [];
|
||||||
return (left.event.title || "").localeCompare(right.event.title || "");
|
let rangeStart = fullDayIndices[0];
|
||||||
|
let previousIndex = fullDayIndices[0];
|
||||||
|
|
||||||
|
const pushSegment = (startIndex: number, endIndex: number) => {
|
||||||
|
const startDay = weekDays[startIndex];
|
||||||
|
const endDay = weekDays[endIndex];
|
||||||
|
segments.push({
|
||||||
|
event,
|
||||||
|
startIndex,
|
||||||
|
span: endIndex - startIndex + 1,
|
||||||
|
row: -1,
|
||||||
|
continuesBefore: isTimedEventFullDayOnDate(event, addDays(startDay, -1)),
|
||||||
|
continuesAfter: isTimedEventFullDayOnDate(event, addDays(endDay, 1)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 1; index < fullDayIndices.length; index++) {
|
||||||
|
const currentIndex = fullDayIndices[index];
|
||||||
|
if (currentIndex !== previousIndex + 1) {
|
||||||
|
pushSegment(rangeStart, previousIndex);
|
||||||
|
rangeStart = currentIndex;
|
||||||
|
}
|
||||||
|
previousIndex = currentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushSegment(rangeStart, previousIndex);
|
||||||
|
return segments;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowEndIndices: number[] = [];
|
return packWeekSegments(rawSegments);
|
||||||
return rawSegments.map((segment) => {
|
|
||||||
const segmentEndIndex = segment.startIndex + segment.span - 1;
|
|
||||||
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
|
|
||||||
if (row === -1) {
|
|
||||||
row = rowEndIndices.length;
|
|
||||||
rowEndIndices.push(segmentEndIndex);
|
|
||||||
} else {
|
|
||||||
rowEndIndices[row] = segmentEndIndex;
|
|
||||||
}
|
|
||||||
return { ...segment, row };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function layoutOverlappingEvents(
|
export function layoutOverlappingEvents(
|
||||||
events: CalendarEvent[],
|
events: CalendarEvent[],
|
||||||
): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
day: Date,
|
||||||
const sorted = [...events].sort((a, b) => {
|
): TimedEventLayout[] {
|
||||||
const diff = parseISO(a.start).getTime() - parseISO(b.start).getTime();
|
const layoutInputs = events.flatMap((event) => {
|
||||||
|
const bounds = getTimedEventBoundsForDay(event, day);
|
||||||
|
return bounds ? [{ event, ...bounds }] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = layoutInputs.sort((a, b) => {
|
||||||
|
const diff = a.startMinutes - b.startMinutes;
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
return parseDuration(b.duration) - parseDuration(a.duration);
|
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns: { event: CalendarEvent; end: number }[][] = [];
|
const columns: { event: CalendarEvent; end: number }[][] = [];
|
||||||
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
|
const result: TimedEventLayout[] = [];
|
||||||
|
|
||||||
for (const event of sorted) {
|
for (const event of sorted) {
|
||||||
const start = parseISO(event.start);
|
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
|
||||||
const endMin = Math.min(1440, startMin + Math.max(15, parseDuration(event.duration)));
|
|
||||||
let placed = false;
|
let placed = false;
|
||||||
for (let col = 0; col < columns.length; col++) {
|
for (let col = 0; col < columns.length; col++) {
|
||||||
if (columns[col].every(e => e.end <= startMin)) {
|
if (columns[col].every(e => e.end <= event.startMinutes)) {
|
||||||
columns[col].push({ event, end: endMin });
|
columns[col].push({ event: event.event, end: event.endMinutes });
|
||||||
result.push({ event, column: col, totalColumns: 0 });
|
result.push({ ...event, column: col, totalColumns: 0 });
|
||||||
placed = true;
|
placed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!placed) {
|
if (!placed) {
|
||||||
columns.push([{ event, end: endMin }]);
|
columns.push([{ event: event.event, end: event.endMinutes }]);
|
||||||
result.push({ event, column: columns.length - 1, totalColumns: 0 });
|
result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-24
@@ -1,25 +1,53 @@
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import type { DebugCategory } from '@/stores/settings-store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debug logger that respects the debugMode setting.
|
* Check if debug logging is enabled, optionally for a specific category.
|
||||||
|
* When a category is provided, both debugMode AND that category must be enabled.
|
||||||
|
*/
|
||||||
|
function isEnabled(category?: DebugCategory): boolean {
|
||||||
|
const state = useSettingsStore.getState();
|
||||||
|
if (!state.debugMode) return false;
|
||||||
|
if (!category) return true;
|
||||||
|
return state.debugCategories?.[category] !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug logger that respects the debugMode setting and category filters.
|
||||||
* Use this instead of console.log for conditional debug output.
|
* Use this instead of console.log for conditional debug output.
|
||||||
|
*
|
||||||
|
* Each method accepts an optional category as the first argument.
|
||||||
|
* When a category is provided, the message only logs if that category is enabled
|
||||||
|
* in Settings > Advanced > Debug Categories.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* debug.log('calendar', 'Event created', event); // Only logs when 'calendar' category is on
|
||||||
|
* debug.log('Uncategorized message'); // Logs whenever debugMode is on
|
||||||
*/
|
*/
|
||||||
export const debug = {
|
export const debug = {
|
||||||
/**
|
/**
|
||||||
* Log a debug message (only when debugMode is enabled)
|
* Log a debug message (only when debugMode is enabled and category is active)
|
||||||
*/
|
*/
|
||||||
log: (...args: unknown[]) => {
|
log: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
|
||||||
console.log('[DEBUG]', ...args);
|
if (isEnabled(categoryOrMsg)) {
|
||||||
|
console.log(`[DEBUG:${categoryOrMsg}]`, ...args);
|
||||||
|
}
|
||||||
|
} else if (isEnabled()) {
|
||||||
|
console.log('[DEBUG]', categoryOrMsg, ...args);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a warning message (only when debugMode is enabled)
|
* Log a warning message (only when debugMode is enabled and category is active)
|
||||||
*/
|
*/
|
||||||
warn: (...args: unknown[]) => {
|
warn: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
|
||||||
console.warn('[DEBUG]', ...args);
|
if (isEnabled(categoryOrMsg)) {
|
||||||
|
console.warn(`[DEBUG:${categoryOrMsg}]`, ...args);
|
||||||
|
}
|
||||||
|
} else if (isEnabled()) {
|
||||||
|
console.warn('[DEBUG]', categoryOrMsg, ...args);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -31,11 +59,11 @@ export const debug = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a collapsed console group (only when debugMode is enabled)
|
* Start a collapsed console group (only when debugMode is enabled and category is active)
|
||||||
*/
|
*/
|
||||||
group: (label: string) => {
|
group: (label: string, category?: DebugCategory) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (isEnabled(category)) {
|
||||||
console.group(`[DEBUG] ${label}`);
|
console.group(`[DEBUG${category ? ':' + category : ''}] ${label}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -43,35 +71,40 @@ export const debug = {
|
|||||||
* End a console group (only when debugMode is enabled)
|
* End a console group (only when debugMode is enabled)
|
||||||
*/
|
*/
|
||||||
groupEnd: () => {
|
groupEnd: () => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (isEnabled()) {
|
||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a performance timer (only when debugMode is enabled)
|
* Start a performance timer (only when debugMode is enabled and category is active)
|
||||||
*/
|
*/
|
||||||
time: (label: string) => {
|
time: (label: string, category?: DebugCategory) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (isEnabled(category)) {
|
||||||
console.time(`[DEBUG] ${label}`);
|
console.time(`[DEBUG${category ? ':' + category : ''}] ${label}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* End a performance timer (only when debugMode is enabled)
|
* End a performance timer (only when debugMode is enabled)
|
||||||
*/
|
*/
|
||||||
timeEnd: (label: string) => {
|
timeEnd: (label: string, category?: DebugCategory) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (isEnabled(category)) {
|
||||||
console.timeEnd(`[DEBUG] ${label}`);
|
console.timeEnd(`[DEBUG${category ? ':' + category : ''}] ${label}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a table (only when debugMode is enabled)
|
* Log a table (only when debugMode is enabled and category is active)
|
||||||
*/
|
*/
|
||||||
table: (data: unknown) => {
|
table: (data: unknown, category?: DebugCategory) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (isEnabled(category)) {
|
||||||
console.table(data);
|
console.table(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']);
|
||||||
|
function isCategoryKey(value: string): value is DebugCategory {
|
||||||
|
return CATEGORY_KEYS.has(value);
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
getServerUrl(): string { return 'https://demo.example.com'; }
|
getServerUrl(): string { return 'https://demo.example.com'; }
|
||||||
getAuthHeader(): string { return 'Bearer demo-token'; }
|
getAuthHeader(): string { return 'Bearer demo-token'; }
|
||||||
updateAccessToken(): void { /* no-op */ }
|
updateAccessToken(): void { /* no-op */ }
|
||||||
|
upgradeToBearer(): void { /* no-op */ }
|
||||||
|
enableTotpReauth(): void { /* no-op */ }
|
||||||
|
updateBasicAuth(): void { /* no-op */ }
|
||||||
getAccountId(): string { return 'demo-account'; }
|
getAccountId(): string { return 'demo-account'; }
|
||||||
getUsername(): string { return 'demo@example.com'; }
|
getUsername(): string { return 'demo@example.com'; }
|
||||||
|
|
||||||
@@ -597,6 +600,15 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
return full;
|
return full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchCreateCalendarEvents(events: Partial<CalendarEvent>[]): Promise<{ created: CalendarEvent[]; failed: string[] }> {
|
||||||
|
const created: CalendarEvent[] = [];
|
||||||
|
for (const event of events) {
|
||||||
|
const full = await this.createCalendarEvent(event);
|
||||||
|
created.push(full);
|
||||||
|
}
|
||||||
|
return { created, failed: [] };
|
||||||
|
}
|
||||||
|
|
||||||
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
|
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
|
||||||
const event = this.data.calendarEvents.find(e => e.id === eventId);
|
const event = this.data.calendarEvents.find(e => e.id === eventId);
|
||||||
if (!event) throw new Error('Event not found');
|
if (!event) throw new Error('Event not found');
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ export const EMAIL_SANITIZE_CONFIG = {
|
|||||||
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
||||||
ALLOW_DATA_ATTR: false,
|
ALLOW_DATA_ATTR: false,
|
||||||
FORCE_BODY: true,
|
FORCE_BODY: true,
|
||||||
// Allow blob: URIs so authenticated inline images (CID) are not stripped
|
// Allow blob: URIs so authenticated inline images (CID) are not stripped.
|
||||||
|
// data: is restricted to image/* MIME types to prevent SVG script injection.
|
||||||
// eslint-disable-next-line no-useless-escape
|
// eslint-disable-next-line no-useless-escape
|
||||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
FORBID_TAGS: [
|
FORBID_TAGS: [
|
||||||
'script', 'iframe', 'object', 'embed', 'form',
|
'script', 'iframe', 'object', 'embed', 'form',
|
||||||
'input', 'button', 'meta', 'link', 'base',
|
'input', 'button', 'meta', 'link', 'base',
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export interface IJMAPClient {
|
|||||||
getServerUrl(): string;
|
getServerUrl(): string;
|
||||||
getAuthHeader(): string;
|
getAuthHeader(): string;
|
||||||
updateAccessToken(token: string): void;
|
updateAccessToken(token: string): void;
|
||||||
|
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void;
|
||||||
|
enableTotpReauth(basePassword: string, callback: () => Promise<string | null>): void;
|
||||||
|
updateBasicAuth(newPassword: string): void;
|
||||||
getAccountId(): string;
|
getAccountId(): string;
|
||||||
getUsername(): string;
|
getUsername(): string;
|
||||||
|
|
||||||
@@ -193,6 +196,7 @@ export interface IJMAPClient {
|
|||||||
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
|
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
|
||||||
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
|
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
|
||||||
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
|
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
|
||||||
|
batchCreateCalendarEvents(events: Partial<CalendarEvent>[], targetAccountId?: string): Promise<{ created: CalendarEvent[]; failed: string[] }>;
|
||||||
updateCalendarEvent(
|
updateCalendarEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
updates: Partial<CalendarEvent>,
|
updates: Partial<CalendarEvent>,
|
||||||
|
|||||||
+635
-62
File diff suppressed because it is too large
Load Diff
@@ -660,6 +660,7 @@ export interface CalendarEventFilter {
|
|||||||
attendee?: string;
|
attendee?: string;
|
||||||
participationStatus?: string;
|
participationStatus?: string;
|
||||||
uid?: string;
|
uid?: string;
|
||||||
|
types?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// JMAP Push Notification Types (RFC 8620 Section 7)
|
// JMAP Push Notification Types (RFC 8620 Section 7)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function playFile(file: string) {
|
|||||||
const audio = new Audio(file);
|
const audio = new Audio(file);
|
||||||
audio.volume = 0.3;
|
audio.volume = 0.3;
|
||||||
audio.play().catch((e) => {
|
audio.play().catch((e) => {
|
||||||
debug.log('Could not play audio file, falling back to beep:', e);
|
debug.log('push', 'Could not play audio file, falling back to beep:', e);
|
||||||
playBeep();
|
playBeep();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,6 @@ export function playNotificationSound(sound?: NotificationSoundChoice) {
|
|||||||
playBeep();
|
playBeep();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debug.log('Could not play notification sound:', e);
|
debug.log('push', 'Could not play notification sound:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-1
@@ -11,11 +11,13 @@ import type {
|
|||||||
SidebarWidget,
|
SidebarWidget,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
KeyboardShortcut,
|
KeyboardShortcut,
|
||||||
|
AdminPageSection,
|
||||||
|
CalendarEventAction,
|
||||||
SlotName,
|
SlotName,
|
||||||
} from './plugin-types';
|
} from './plugin-types';
|
||||||
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
|
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
|
||||||
import {
|
import {
|
||||||
emailHooks, calendarHooks, contactHooks, fileHooks,
|
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
|
||||||
authHooks, settingsHooks, identityHooks, filterHooks,
|
authHooks, settingsHooks, identityHooks, filterHooks,
|
||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
@@ -116,6 +118,8 @@ export interface PluginAPI {
|
|||||||
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
|
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
|
||||||
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
|
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
|
||||||
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
|
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
|
||||||
|
registerCalendarEventAction: (action: CalendarEventAction) => Disposable;
|
||||||
|
registerAdminPage: (page: AdminPageSection) => Disposable;
|
||||||
};
|
};
|
||||||
hooks: PluginHooksAPI;
|
hooks: PluginHooksAPI;
|
||||||
toast: {
|
toast: {
|
||||||
@@ -126,6 +130,12 @@ export interface PluginAPI {
|
|||||||
};
|
};
|
||||||
storage: ReturnType<typeof createPluginStorage>;
|
storage: ReturnType<typeof createPluginStorage>;
|
||||||
log: ReturnType<typeof createPluginLogger>;
|
log: ReturnType<typeof createPluginLogger>;
|
||||||
|
admin: {
|
||||||
|
getConfig: (key: string) => Promise<unknown>;
|
||||||
|
getAllConfig: () => Promise<Record<string, unknown>>;
|
||||||
|
setConfig: (key: string, value: unknown) => Promise<void>;
|
||||||
|
deleteConfig: (key: string) => Promise<void>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simplified hooks API type (all hooks return Disposable)
|
// Simplified hooks API type (all hooks return Disposable)
|
||||||
@@ -176,6 +186,9 @@ export interface PluginHooksAPI {
|
|||||||
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
|
// Calendar Form
|
||||||
|
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
|
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
// Contacts
|
// Contacts
|
||||||
onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -321,6 +334,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
|
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
|
||||||
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
|
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
|
||||||
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
|
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
|
||||||
|
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
|
||||||
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
|
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
|
||||||
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
|
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
|
||||||
onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write',
|
onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write',
|
||||||
@@ -407,6 +421,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
|
|||||||
...Object.fromEntries(Object.entries(emailHooks)),
|
...Object.fromEntries(Object.entries(emailHooks)),
|
||||||
// Calendar
|
// Calendar
|
||||||
...Object.fromEntries(Object.entries(calendarHooks)),
|
...Object.fromEntries(Object.entries(calendarHooks)),
|
||||||
|
// Calendar Form
|
||||||
|
...Object.fromEntries(Object.entries(calendarFormHooks)),
|
||||||
// Contacts
|
// Contacts
|
||||||
...Object.fromEntries(Object.entries(contactHooks)),
|
...Object.fromEntries(Object.entries(contactHooks)),
|
||||||
// Files
|
// Files
|
||||||
@@ -581,6 +597,38 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
|||||||
requirePermission(plugin, 'ui:navigation-rail');
|
requirePermission(plugin, 'ui:navigation-rail');
|
||||||
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100);
|
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
registerCalendarEventAction: (action: CalendarEventAction) => {
|
||||||
|
requirePermission(plugin, 'ui:calendar-action');
|
||||||
|
const Component = (props: Record<string, unknown>) => {
|
||||||
|
const externals = getPluginExternals();
|
||||||
|
const React = externals?.React;
|
||||||
|
if (!React) return null;
|
||||||
|
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
|
||||||
|
const iconSpan = createElement('span', {
|
||||||
|
'aria-hidden': 'true',
|
||||||
|
style: { display: 'contents' },
|
||||||
|
dangerouslySetInnerHTML: {
|
||||||
|
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="8" width="14" height="12" rx="2"/></svg>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return createElement('button', {
|
||||||
|
onClick: () => action.onClick(
|
||||||
|
props.eventData as import('./plugin-types').CalendarEventFormView,
|
||||||
|
{ setVirtualLocation: props.setVirtualLocation as (url: string) => void },
|
||||||
|
),
|
||||||
|
className: 'inline-flex items-center gap-1.5 h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background cursor-pointer',
|
||||||
|
title: action.label,
|
||||||
|
type: 'button',
|
||||||
|
}, iconSpan, action.label);
|
||||||
|
};
|
||||||
|
return registerSlot(plugin.id, 'calendar-event-actions', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
registerAdminPage: (page: AdminPageSection) => {
|
||||||
|
requirePermission(plugin, 'ui:admin-page');
|
||||||
|
return registerSlot(plugin.id, 'admin-plugin-page', page.render as React.ComponentType<Record<string, unknown>>, 100);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
hooks,
|
hooks,
|
||||||
@@ -594,5 +642,37 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
|||||||
|
|
||||||
storage: createPluginStorage(plugin.id),
|
storage: createPluginStorage(plugin.id),
|
||||||
log: createPluginLogger(plugin.id),
|
log: createPluginLogger(plugin.id),
|
||||||
|
|
||||||
|
admin: {
|
||||||
|
getConfig: async (key: string) => {
|
||||||
|
requirePermission(plugin, 'admin:config');
|
||||||
|
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return data[key] ?? null;
|
||||||
|
},
|
||||||
|
getAllConfig: async () => {
|
||||||
|
requirePermission(plugin, 'admin:config');
|
||||||
|
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
|
||||||
|
if (!res.ok) return {};
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
setConfig: async (key: string, value: unknown) => {
|
||||||
|
requirePermission(plugin, 'admin:config');
|
||||||
|
await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteConfig: async (key: string) => {
|
||||||
|
requirePermission(plugin, 'admin:config');
|
||||||
|
await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -218,6 +218,12 @@ export const calendarHooks = {
|
|||||||
onCalendarAlertAcknowledge: new HookBus(),
|
onCalendarAlertAcknowledge: new HookBus(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// §7.2b Calendar Form Hooks (UI integration)
|
||||||
|
export const calendarFormHooks = {
|
||||||
|
onCalendarEventFormOpen: new HookBus(),
|
||||||
|
onCalendarEventFormSave: new HookBus(),
|
||||||
|
};
|
||||||
|
|
||||||
// §7.3 Contact Hooks
|
// §7.3 Contact Hooks
|
||||||
export const contactHooks = {
|
export const contactHooks = {
|
||||||
onContactOpen: new HookBus(),
|
onContactOpen: new HookBus(),
|
||||||
@@ -396,7 +402,7 @@ export const sidebarAppHooks = {
|
|||||||
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
||||||
|
|
||||||
const allHookGroups = [
|
const allHookGroups = [
|
||||||
emailHooks, calendarHooks, contactHooks, fileHooks,
|
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
|
||||||
authHooks, settingsHooks, identityHooks, filterHooks,
|
authHooks, settingsHooks, identityHooks, filterHooks,
|
||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
|
|||||||
+32
-1
@@ -94,7 +94,9 @@ export type SlotName =
|
|||||||
| 'email-detail-sidebar'
|
| 'email-detail-sidebar'
|
||||||
| 'settings-section'
|
| 'settings-section'
|
||||||
| 'context-menu-email'
|
| 'context-menu-email'
|
||||||
| 'navigation-rail-bottom';
|
| 'navigation-rail-bottom'
|
||||||
|
| 'calendar-event-actions'
|
||||||
|
| 'admin-plugin-page';
|
||||||
|
|
||||||
export interface SlotRegistration {
|
export interface SlotRegistration {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@@ -147,6 +149,32 @@ export interface ContextMenuItem {
|
|||||||
order?: number;
|
order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminPageSection {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
render: React.ComponentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEventAction {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
onClick: (eventData: CalendarEventFormView, helpers: { setVirtualLocation: (url: string) => void }) => void;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEventFormView {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
isAllDay: boolean;
|
||||||
|
location: string;
|
||||||
|
virtualLocation: string;
|
||||||
|
calendarId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KeyboardShortcut {
|
export interface KeyboardShortcut {
|
||||||
id: string;
|
id: string;
|
||||||
keys: string;
|
keys: string;
|
||||||
@@ -377,6 +405,8 @@ export const ALL_PERMISSIONS = [
|
|||||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
||||||
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
||||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||||
|
'ui:calendar-action', 'ui:admin-page',
|
||||||
|
'admin:config',
|
||||||
'app:lifecycle',
|
'app:lifecycle',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -397,6 +427,7 @@ export const ALLOWED_PLUGIN_FILES = new Set([
|
|||||||
export const DISALLOWED_CSS_PATTERNS = [
|
export const DISALLOWED_CSS_PATTERNS = [
|
||||||
/@import\b/i,
|
/@import\b/i,
|
||||||
/url\s*\(\s*['"]?https?:/i,
|
/url\s*\(\s*['"]?https?:/i,
|
||||||
|
/url\s*\(\s*['"]?data:/i,
|
||||||
/expression\s*\(/i,
|
/expression\s*\(/i,
|
||||||
/javascript\s*:/i,
|
/javascript\s*:/i,
|
||||||
/-moz-binding/i,
|
/-moz-binding/i,
|
||||||
|
|||||||
@@ -0,0 +1,659 @@
|
|||||||
|
/**
|
||||||
|
* Client-side recurrence expansion for JSCalendar events.
|
||||||
|
*
|
||||||
|
* Implements JSCalendar 2.0 (draft-ietf-calext-jscalendarbis-15) §3.3.3.1
|
||||||
|
* recurrence rule interpretation algorithm with full byX filtering,
|
||||||
|
* implicit byX property addition, and bySetPosition support.
|
||||||
|
*
|
||||||
|
* Stalwart does not yet support mutations on the synthetic IDs produced by
|
||||||
|
* CalendarEvent/query?expandRecurrences=true, so we fetch raw events (with
|
||||||
|
* real, mutable IDs) and expand recurring series into individual occurrences
|
||||||
|
* in the browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseISO, format, addDays, addWeeks, addMonths, addYears } from 'date-fns';
|
||||||
|
import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
|
||||||
|
const INDEX_TO_DAY: string[] = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a master event and a date range, return an array of "virtual" occurrence
|
||||||
|
* events. Each occurrence carries a synthetic `id` (for dedup in React) and a
|
||||||
|
* `masterEventId` field that points back to the real server-side ID so the
|
||||||
|
* store can use it for mutations.
|
||||||
|
*
|
||||||
|
* Non-recurring events are returned as-is. For recurring events the master is
|
||||||
|
* **not** returned — only expanded instances within the range.
|
||||||
|
*/
|
||||||
|
export function expandRecurringEvents(
|
||||||
|
events: CalendarEvent[],
|
||||||
|
rangeStart: string,
|
||||||
|
rangeEnd: string,
|
||||||
|
): CalendarEvent[] {
|
||||||
|
const start = parseISO(rangeStart);
|
||||||
|
const end = parseISO(rangeEnd);
|
||||||
|
const result: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event.recurrenceRules?.length) {
|
||||||
|
result.push(event);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurrences = expandEvent(event, start, end);
|
||||||
|
result.push(...occurrences);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandEvent(
|
||||||
|
master: CalendarEvent,
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
): CalendarEvent[] {
|
||||||
|
const eventStart = parseISO(master.start);
|
||||||
|
if (isNaN(eventStart.getTime())) return [];
|
||||||
|
|
||||||
|
const rules = master.recurrenceRules || [];
|
||||||
|
const overrides = master.recurrenceOverrides || {};
|
||||||
|
const occurrences: CalendarEvent[] = [];
|
||||||
|
const seenDates = new Set<string>();
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
|
||||||
|
for (const date of dates) {
|
||||||
|
const dateKey = master.showWithoutTime
|
||||||
|
? format(date, 'yyyy-MM-dd')
|
||||||
|
: date.toISOString();
|
||||||
|
|
||||||
|
if (seenDates.has(dateKey)) continue;
|
||||||
|
seenDates.add(dateKey);
|
||||||
|
|
||||||
|
const recurrenceId = master.showWithoutTime
|
||||||
|
? format(date, "yyyy-MM-dd'T'00:00:00")
|
||||||
|
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
|
||||||
|
const override = overrides[recurrenceId] as (Partial<CalendarEvent> & { excluded?: boolean }) | undefined;
|
||||||
|
if (override?.excluded) continue;
|
||||||
|
|
||||||
|
occurrences.push(createOccurrence(master, date, recurrenceId, override));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add overrides that define new dates not generated by rules (RDATE equivalent)
|
||||||
|
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
|
||||||
|
const override = rawOverride as Partial<CalendarEvent> & { excluded?: boolean };
|
||||||
|
if (override.excluded) continue;
|
||||||
|
const overrideDate = parseISO(recurrenceId);
|
||||||
|
if (isNaN(overrideDate.getTime())) continue;
|
||||||
|
if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue;
|
||||||
|
|
||||||
|
const dateKey = master.showWithoutTime
|
||||||
|
? format(overrideDate, 'yyyy-MM-dd')
|
||||||
|
: overrideDate.toISOString();
|
||||||
|
if (seenDates.has(dateKey)) continue;
|
||||||
|
seenDates.add(dateKey);
|
||||||
|
|
||||||
|
occurrences.push(createOccurrence(master, overrideDate, recurrenceId, override));
|
||||||
|
}
|
||||||
|
|
||||||
|
return occurrences;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOccurrence(
|
||||||
|
master: CalendarEvent,
|
||||||
|
date: Date,
|
||||||
|
recurrenceId: string,
|
||||||
|
override?: Partial<CalendarEvent>,
|
||||||
|
): CalendarEvent {
|
||||||
|
const startStr = master.showWithoutTime
|
||||||
|
? format(date, "yyyy-MM-dd'T'00:00:00")
|
||||||
|
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...master,
|
||||||
|
...(override || {}),
|
||||||
|
id: `${master.id}:${recurrenceId}`,
|
||||||
|
originalId: master.originalId || master.id,
|
||||||
|
uid: master.uid,
|
||||||
|
calendarIds: master.calendarIds,
|
||||||
|
start: (override?.start) || startStr,
|
||||||
|
recurrenceId,
|
||||||
|
recurrenceRules: master.recurrenceRules,
|
||||||
|
recurrenceOverrides: master.recurrenceOverrides,
|
||||||
|
excludedRecurrenceRules: master.excludedRecurrenceRules,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// §3.3.3.1 — Implicit byX property addition
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function addImplicitByX(
|
||||||
|
rule: CalendarRecurrenceRule,
|
||||||
|
eventStart: Date,
|
||||||
|
): CalendarRecurrenceRule {
|
||||||
|
const r = { ...rule };
|
||||||
|
const freq = r.frequency;
|
||||||
|
|
||||||
|
// bySecond
|
||||||
|
if (freq !== 'secondly' && !r.bySecond?.length) {
|
||||||
|
r.bySecond = [eventStart.getSeconds()];
|
||||||
|
}
|
||||||
|
// byMinute
|
||||||
|
if (freq !== 'secondly' && freq !== 'minutely' && !r.byMinute?.length) {
|
||||||
|
r.byMinute = [eventStart.getMinutes()];
|
||||||
|
}
|
||||||
|
// byHour
|
||||||
|
if (freq !== 'secondly' && freq !== 'minutely' && freq !== 'hourly' && !r.byHour?.length) {
|
||||||
|
r.byHour = [eventStart.getHours()];
|
||||||
|
}
|
||||||
|
// weekly: implicit byDay
|
||||||
|
if (freq === 'weekly' && !r.byDay?.length) {
|
||||||
|
r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
|
||||||
|
}
|
||||||
|
// monthly: implicit byMonthDay
|
||||||
|
if (freq === 'monthly' && !r.byDay?.length && !r.byMonthDay?.length) {
|
||||||
|
r.byMonthDay = [eventStart.getDate()];
|
||||||
|
}
|
||||||
|
// yearly: implicit byMonth / byMonthDay / byDay
|
||||||
|
if (freq === 'yearly' && !r.byYearDay?.length) {
|
||||||
|
if (!r.byMonth?.length && !r.byWeekNo?.length && (r.byMonthDay?.length || !r.byDay?.length)) {
|
||||||
|
r.byMonth = [String(eventStart.getMonth() + 1)];
|
||||||
|
}
|
||||||
|
if (!r.byMonthDay?.length && !r.byWeekNo?.length && !r.byDay?.length) {
|
||||||
|
r.byMonthDay = [eventStart.getDate()];
|
||||||
|
}
|
||||||
|
if (r.byWeekNo?.length && !r.byMonthDay?.length && !r.byDay?.length) {
|
||||||
|
r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// §3.3.3.1 — Generate occurrence dates
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function generateDates(
|
||||||
|
eventStart: Date,
|
||||||
|
rawRule: CalendarRecurrenceRule,
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
): Date[] {
|
||||||
|
const rule = addImplicitByX(rawRule, eventStart);
|
||||||
|
const dates: Date[] = [];
|
||||||
|
const interval = rule.interval || 1;
|
||||||
|
const countLimit = rule.count || Infinity;
|
||||||
|
const until = rule.until ? parseISO(rule.until) : null;
|
||||||
|
let totalCount = 0;
|
||||||
|
let current = new Date(eventStart);
|
||||||
|
|
||||||
|
const maxIterations = 2000;
|
||||||
|
const maxOccurrences = 500;
|
||||||
|
let iterations = 0;
|
||||||
|
|
||||||
|
while (iterations++ < maxIterations) {
|
||||||
|
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
|
||||||
|
if (until && current > until) break;
|
||||||
|
// For frequencies that produce one candidate per iteration at a time,
|
||||||
|
// we can stop when we pass rangeEnd. But for frequencies that expand
|
||||||
|
// into multiple candidates per period, we need the candidate generation.
|
||||||
|
if (current >= rangeEnd && rule.frequency !== 'yearly' && rule.frequency !== 'monthly'
|
||||||
|
&& rule.frequency !== 'weekly') break;
|
||||||
|
|
||||||
|
// Generate candidates for the current period, then filter
|
||||||
|
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
|
||||||
|
|
||||||
|
// Apply bySetPosition if present
|
||||||
|
const filtered = rule.bySetPosition?.length
|
||||||
|
? applyBySetPosition(candidates, rule.bySetPosition)
|
||||||
|
: candidates;
|
||||||
|
|
||||||
|
for (const d of filtered) {
|
||||||
|
if (until && d > until) break;
|
||||||
|
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
|
||||||
|
|
||||||
|
// Spec rule 4: eliminate dates before event start
|
||||||
|
if (d < eventStart) continue;
|
||||||
|
|
||||||
|
totalCount++;
|
||||||
|
if (d >= rangeStart && d < rangeEnd) {
|
||||||
|
dates.push(d);
|
||||||
|
}
|
||||||
|
if (d >= rangeEnd) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
|
||||||
|
|
||||||
|
current = advancePeriod(current, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
|
||||||
|
if (current <= eventStart && iterations === 1) {
|
||||||
|
// Safety: ensure we don't go backward
|
||||||
|
current = advancePeriod(eventStart, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spec rule 1: the initial start date-time is ALWAYS the first occurrence
|
||||||
|
if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
|
||||||
|
if (eventStart >= rangeStart && eventStart < rangeEnd) {
|
||||||
|
// Check it's not already in the list
|
||||||
|
if (!dates.some(d => d.getTime() === eventStart.getTime())) {
|
||||||
|
dates.unshift(eventStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generate all candidates within one period, filtered by byX properties
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function generateCandidatesForPeriod(
|
||||||
|
periodStart: Date,
|
||||||
|
rule: CalendarRecurrenceRule,
|
||||||
|
eventStart: Date,
|
||||||
|
): Date[] {
|
||||||
|
const freq = rule.frequency;
|
||||||
|
let candidates: Date[];
|
||||||
|
|
||||||
|
// Step 1: Generate base candidates based on frequency + expansion byX
|
||||||
|
switch (freq) {
|
||||||
|
case 'yearly':
|
||||||
|
candidates = expandYearly(periodStart, rule, eventStart);
|
||||||
|
break;
|
||||||
|
case 'monthly':
|
||||||
|
candidates = expandMonthly(periodStart, rule, eventStart);
|
||||||
|
break;
|
||||||
|
case 'weekly':
|
||||||
|
candidates = expandWeekly(periodStart, rule, eventStart);
|
||||||
|
break;
|
||||||
|
case 'daily':
|
||||||
|
case 'hourly':
|
||||||
|
case 'minutely':
|
||||||
|
case 'secondly':
|
||||||
|
candidates = [new Date(periodStart)];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
candidates = [new Date(periodStart)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Filter candidates by all applicable byX constraints
|
||||||
|
candidates = candidates.filter(d => matchesByX(d, rule));
|
||||||
|
|
||||||
|
candidates.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Yearly expansion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function expandYearly(
|
||||||
|
periodStart: Date,
|
||||||
|
rule: CalendarRecurrenceRule,
|
||||||
|
eventStart: Date,
|
||||||
|
): Date[] {
|
||||||
|
const year = periodStart.getFullYear();
|
||||||
|
const h = eventStart.getHours();
|
||||||
|
const m = eventStart.getMinutes();
|
||||||
|
const s = eventStart.getSeconds();
|
||||||
|
let dates: Date[] = [];
|
||||||
|
|
||||||
|
// Determine which months to iterate
|
||||||
|
const months = rule.byMonth?.length
|
||||||
|
? rule.byMonth.map(ms => parseInt(ms.replace('L', ''), 10) - 1)
|
||||||
|
: [eventStart.getMonth()];
|
||||||
|
|
||||||
|
if (rule.byWeekNo?.length) {
|
||||||
|
// Expand by ISO week numbers
|
||||||
|
for (const wn of rule.byWeekNo) {
|
||||||
|
const weekDates = datesInISOWeek(year, wn, rule.firstDayOfWeek || 'mo');
|
||||||
|
dates.push(...weekDates.map(d => { d.setHours(h, m, s, 0); return d; }));
|
||||||
|
}
|
||||||
|
} else if (rule.byYearDay?.length) {
|
||||||
|
// Expand by day-of-year
|
||||||
|
for (const yd of rule.byYearDay) {
|
||||||
|
const d = dayOfYear(year, yd);
|
||||||
|
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
|
||||||
|
}
|
||||||
|
} else if (rule.byDay?.length && rule.byMonthDay?.length) {
|
||||||
|
// Both byDay and byMonthDay: expand byMonthDay in each month, then byDay filters later
|
||||||
|
for (const mo of months) {
|
||||||
|
for (const md of rule.byMonthDay) {
|
||||||
|
const d = resolveMonthDay(year, mo, md);
|
||||||
|
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (rule.byDay?.length) {
|
||||||
|
// byDay with nthOfPeriod in yearly context = nth weekday of year or month
|
||||||
|
for (const mo of months) {
|
||||||
|
const expanded = expandByDayInMonth(year, mo, rule.byDay, h, m, s);
|
||||||
|
dates.push(...expanded);
|
||||||
|
}
|
||||||
|
} else if (rule.byMonthDay?.length) {
|
||||||
|
for (const mo of months) {
|
||||||
|
for (const md of rule.byMonthDay) {
|
||||||
|
const d = resolveMonthDay(year, mo, md);
|
||||||
|
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simple yearly: same date each year
|
||||||
|
for (const mo of months) {
|
||||||
|
const d = new Date(year, mo, eventStart.getDate(), h, m, s, 0);
|
||||||
|
if (d.getMonth() === mo) dates.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Monthly expansion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function expandMonthly(
|
||||||
|
periodStart: Date,
|
||||||
|
rule: CalendarRecurrenceRule,
|
||||||
|
eventStart: Date,
|
||||||
|
): Date[] {
|
||||||
|
const year = periodStart.getFullYear();
|
||||||
|
const month = periodStart.getMonth();
|
||||||
|
const h = eventStart.getHours();
|
||||||
|
const m = eventStart.getMinutes();
|
||||||
|
const s = eventStart.getSeconds();
|
||||||
|
const dates: Date[] = [];
|
||||||
|
|
||||||
|
if (rule.byDay?.length) {
|
||||||
|
const expanded = expandByDayInMonth(year, month, rule.byDay, h, m, s);
|
||||||
|
dates.push(...expanded);
|
||||||
|
} else if (rule.byMonthDay?.length) {
|
||||||
|
for (const md of rule.byMonthDay) {
|
||||||
|
const d = resolveMonthDay(year, month, md);
|
||||||
|
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Implicit byMonthDay already added, but as fallback:
|
||||||
|
const d = new Date(year, month, eventStart.getDate(), h, m, s, 0);
|
||||||
|
if (d.getMonth() === month) dates.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Weekly expansion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function expandWeekly(
|
||||||
|
periodStart: Date,
|
||||||
|
rule: CalendarRecurrenceRule,
|
||||||
|
eventStart: Date,
|
||||||
|
): Date[] {
|
||||||
|
const dates: Date[] = [];
|
||||||
|
const baseDay = periodStart.getDay();
|
||||||
|
|
||||||
|
const byDay = rule.byDay?.length ? rule.byDay : [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
|
||||||
|
|
||||||
|
for (const { day } of byDay) {
|
||||||
|
const targetDay = DAY_INDEX[day];
|
||||||
|
if (targetDay === undefined) continue;
|
||||||
|
let diff = targetDay - baseDay;
|
||||||
|
if (diff < 0) diff += 7;
|
||||||
|
const d = addDays(periodStart, diff);
|
||||||
|
d.setHours(eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), 0);
|
||||||
|
dates.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// byX matching (Step 2 of §3.3.3.1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function matchesByX(date: Date, rule: CalendarRecurrenceRule): boolean {
|
||||||
|
if (rule.byMonth?.length) {
|
||||||
|
const month = String(date.getMonth() + 1);
|
||||||
|
if (!rule.byMonth.some(m => m.replace('L', '') === month)) return false;
|
||||||
|
}
|
||||||
|
if (rule.byWeekNo?.length) {
|
||||||
|
const wn = getISOWeekNumber(date);
|
||||||
|
const weeksInYear = getISOWeeksInYear(date.getFullYear());
|
||||||
|
if (!rule.byWeekNo.some(w => (w > 0 ? w : weeksInYear + 1 + w) === wn)) return false;
|
||||||
|
}
|
||||||
|
if (rule.byYearDay?.length) {
|
||||||
|
const yd = getDayOfYear(date);
|
||||||
|
const daysInYear = isLeapYear(date.getFullYear()) ? 366 : 365;
|
||||||
|
if (!rule.byYearDay.some(d => (d > 0 ? d : daysInYear + 1 + d) === yd)) return false;
|
||||||
|
}
|
||||||
|
if (rule.byMonthDay?.length) {
|
||||||
|
const md = date.getDate();
|
||||||
|
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||||
|
if (!rule.byMonthDay.some(d => (d > 0 ? d : daysInMonth + 1 + d) === md)) return false;
|
||||||
|
}
|
||||||
|
if (rule.byDay?.length) {
|
||||||
|
const dayName = INDEX_TO_DAY[date.getDay()];
|
||||||
|
const freq = rule.frequency;
|
||||||
|
if (!rule.byDay.some(nd => {
|
||||||
|
if (nd.day !== dayName) return false;
|
||||||
|
if (nd.nthOfPeriod == null) return true;
|
||||||
|
if (freq === 'monthly') {
|
||||||
|
return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod);
|
||||||
|
}
|
||||||
|
if (freq === 'yearly') {
|
||||||
|
// When byMonth is present, nthOfPeriod scopes to the month (iCalendar semantics)
|
||||||
|
if (rule.byMonth?.length) {
|
||||||
|
return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod);
|
||||||
|
}
|
||||||
|
return nd.nthOfPeriod === nthWeekdayInYear(date, nd.nthOfPeriod);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})) return false;
|
||||||
|
}
|
||||||
|
if (rule.byHour?.length) {
|
||||||
|
if (!rule.byHour.includes(date.getHours())) return false;
|
||||||
|
}
|
||||||
|
if (rule.byMinute?.length) {
|
||||||
|
if (!rule.byMinute.includes(date.getMinutes())) return false;
|
||||||
|
}
|
||||||
|
if (rule.bySecond?.length) {
|
||||||
|
if (!rule.bySecond.includes(date.getSeconds())) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// bySetPosition (Step 3 of §3.3.3.1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function applyBySetPosition(dates: Date[], positions: number[]): Date[] {
|
||||||
|
if (!dates.length) return dates;
|
||||||
|
const result: Date[] = [];
|
||||||
|
const len = dates.length;
|
||||||
|
for (const pos of positions) {
|
||||||
|
const idx = pos > 0 ? pos - 1 : len + pos;
|
||||||
|
if (idx >= 0 && idx < len) {
|
||||||
|
result.push(dates[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Advance to the start of the next period
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function advancePeriod(
|
||||||
|
date: Date,
|
||||||
|
frequency: CalendarRecurrenceRule['frequency'],
|
||||||
|
interval: number,
|
||||||
|
_firstDayOfWeek: string,
|
||||||
|
): Date {
|
||||||
|
switch (frequency) {
|
||||||
|
case 'daily': return addDays(date, interval);
|
||||||
|
case 'weekly': return addWeeks(date, interval);
|
||||||
|
case 'monthly': return addMonths(date, interval);
|
||||||
|
case 'yearly': return addYears(date, interval);
|
||||||
|
case 'hourly': return new Date(date.getTime() + interval * 3600000);
|
||||||
|
case 'minutely': return new Date(date.getTime() + interval * 60000);
|
||||||
|
case 'secondly': return new Date(date.getTime() + interval * 1000);
|
||||||
|
default: return addDays(date, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: expand byDay within a specific month (monthly or yearly context)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function expandByDayInMonth(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
byDay: CalendarNDay[],
|
||||||
|
h: number, m: number, s: number,
|
||||||
|
): Date[] {
|
||||||
|
const dates: Date[] = [];
|
||||||
|
|
||||||
|
for (const { day, nthOfPeriod } of byDay) {
|
||||||
|
const targetDay = DAY_INDEX[day];
|
||||||
|
if (targetDay === undefined) continue;
|
||||||
|
|
||||||
|
if (nthOfPeriod != null && nthOfPeriod !== 0) {
|
||||||
|
const d = nthWeekdayOfMonth(year, month, targetDay, nthOfPeriod);
|
||||||
|
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
|
||||||
|
} else {
|
||||||
|
// Every occurrence of this weekday in the month
|
||||||
|
let d = new Date(year, month, 1);
|
||||||
|
while (d.getDay() !== targetDay) d = addDays(d, 1);
|
||||||
|
while (d.getMonth() === month) {
|
||||||
|
const occ = new Date(d);
|
||||||
|
occ.setHours(h, m, s, 0);
|
||||||
|
dates.push(occ);
|
||||||
|
d = addDays(d, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: find nth weekday of month
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function nthWeekdayOfMonth(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
weekday: number,
|
||||||
|
nth: number,
|
||||||
|
): Date | null {
|
||||||
|
if (nth > 0) {
|
||||||
|
let d = new Date(year, month, 1);
|
||||||
|
while (d.getDay() !== weekday) d = addDays(d, 1);
|
||||||
|
d = addDays(d, (nth - 1) * 7);
|
||||||
|
return d.getMonth() === month ? d : null;
|
||||||
|
} else {
|
||||||
|
let d = new Date(year, month + 1, 0); // last day of month
|
||||||
|
while (d.getDay() !== weekday) d = addDays(d, -1);
|
||||||
|
if (nth < -1) d = addDays(d, (nth + 1) * 7);
|
||||||
|
return d.getMonth() === month ? d : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: check if date is the nth (or nth-last) weekday in its month
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function nthWeekdayInMonth(date: Date, nth: number): number {
|
||||||
|
if (nth > 0) {
|
||||||
|
// Count from start: which occurrence of this weekday is it?
|
||||||
|
return Math.floor((date.getDate() - 1) / 7) + 1;
|
||||||
|
} else {
|
||||||
|
// Count from end
|
||||||
|
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||||
|
return -(Math.floor((daysInMonth - date.getDate()) / 7) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: check if date is the nth weekday in its year
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function nthWeekdayInYear(date: Date, nth: number): number {
|
||||||
|
const yd = getDayOfYear(date);
|
||||||
|
const weekday = date.getDay();
|
||||||
|
if (nth > 0) {
|
||||||
|
// Find first occurrence of this weekday in the year
|
||||||
|
const jan1 = new Date(date.getFullYear(), 0, 1);
|
||||||
|
let first = jan1;
|
||||||
|
while (first.getDay() !== weekday) first = addDays(first, 1);
|
||||||
|
const firstYd = getDayOfYear(first);
|
||||||
|
return Math.floor((yd - firstYd) / 7) + 1;
|
||||||
|
} else {
|
||||||
|
const daysInYear = isLeapYear(date.getFullYear()) ? 366 : 365;
|
||||||
|
const dec31 = new Date(date.getFullYear(), 11, 31);
|
||||||
|
let last = dec31;
|
||||||
|
while (last.getDay() !== weekday) last = addDays(last, -1);
|
||||||
|
const lastYd = getDayOfYear(last);
|
||||||
|
return -(Math.floor((lastYd - yd) / 7) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: resolve negative/positive byMonthDay to a real date
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function resolveMonthDay(year: number, month: number, day: number): Date | null {
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const actualDay = day > 0 ? day : daysInMonth + 1 + day;
|
||||||
|
if (actualDay < 1 || actualDay > daysInMonth) return null;
|
||||||
|
return new Date(year, month, actualDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: day of year (1-indexed)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function getDayOfYear(date: Date): number {
|
||||||
|
const start = new Date(date.getFullYear(), 0, 0);
|
||||||
|
const diff = date.getTime() - start.getTime();
|
||||||
|
return Math.floor(diff / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: day-of-year to Date
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function dayOfYear(year: number, yd: number): Date | null {
|
||||||
|
const daysInYear = isLeapYear(year) ? 366 : 365;
|
||||||
|
const actual = yd > 0 ? yd : daysInYear + 1 + yd;
|
||||||
|
if (actual < 1 || actual > daysInYear) return null;
|
||||||
|
const d = new Date(year, 0, actual);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLeapYear(year: number): boolean {
|
||||||
|
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ISO week helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function getISOWeekNumber(date: Date): number {
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getISOWeeksInYear(year: number): number {
|
||||||
|
const dec28 = new Date(Date.UTC(year, 11, 28));
|
||||||
|
return getISOWeekNumber(dec28);
|
||||||
|
}
|
||||||
|
|
||||||
|
function datesInISOWeek(year: number, weekNo: number, _firstDayOfWeek: string): Date[] {
|
||||||
|
const weeksInYear = getISOWeeksInYear(year);
|
||||||
|
const actual = weekNo > 0 ? weekNo : weeksInYear + 1 + weekNo;
|
||||||
|
if (actual < 1 || actual > weeksInYear) return [];
|
||||||
|
|
||||||
|
// Find Monday of ISO week 1
|
||||||
|
const jan4 = new Date(year, 0, 4);
|
||||||
|
const dayOfWeek = jan4.getDay() || 7; // Monday=1 ... Sunday=7
|
||||||
|
const week1Monday = addDays(jan4, 1 - dayOfWeek);
|
||||||
|
const targetMonday = addDays(week1Monday, (actual - 1) * 7);
|
||||||
|
|
||||||
|
const dates: Date[] = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
dates.push(addDays(targetMonday, i));
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Identity } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
interface ReplyRecipient {
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReplyRecipients {
|
||||||
|
to?: ReplyRecipient[];
|
||||||
|
cc?: ReplyRecipient[];
|
||||||
|
bcc?: ReplyRecipient[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmailAddress(email: string): string {
|
||||||
|
return email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseEmailAddress(email: string): string {
|
||||||
|
const normalized = normalizeEmailAddress(email);
|
||||||
|
const atIndex = normalized.indexOf('@');
|
||||||
|
|
||||||
|
if (atIndex <= 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPart = normalized.slice(0, atIndex);
|
||||||
|
const domain = normalized.slice(atIndex + 1);
|
||||||
|
const plusIndex = localPart.indexOf('+');
|
||||||
|
|
||||||
|
return `${plusIndex >= 0 ? localPart.slice(0, plusIndex) : localPart}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findReplyIdentityId(
|
||||||
|
identities: Identity[],
|
||||||
|
recipients?: ReplyRecipients,
|
||||||
|
): string | null {
|
||||||
|
if (identities.length === 0 || !recipients) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const receivedAddresses = [
|
||||||
|
...(recipients.to || []),
|
||||||
|
...(recipients.cc || []),
|
||||||
|
...(recipients.bcc || []),
|
||||||
|
]
|
||||||
|
.map((recipient) => recipient.email?.trim())
|
||||||
|
.filter((email): email is string => Boolean(email));
|
||||||
|
|
||||||
|
if (receivedAddresses.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactMatches = new Set(receivedAddresses.map(normalizeEmailAddress));
|
||||||
|
const exactIdentity = identities.find((identity) => exactMatches.has(normalizeEmailAddress(identity.email)));
|
||||||
|
if (exactIdentity) {
|
||||||
|
return exactIdentity.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMatches = new Set(receivedAddresses.map(normalizeBaseEmailAddress));
|
||||||
|
const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email)));
|
||||||
|
|
||||||
|
return baseIdentity?.id ?? null;
|
||||||
|
}
|
||||||
@@ -147,7 +147,7 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
|
|||||||
|
|
||||||
for (const rule of enabledRules) {
|
for (const rule of enabledRules) {
|
||||||
if (rule.conditions.length === 0 || rule.actions.length === 0) {
|
if (rule.conditions.length === 0 || rule.actions.length === 0) {
|
||||||
debug.warn(`Skipping rule "${rule.name}": empty conditions or actions`);
|
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ export function parseScript(content: string): ParseResult {
|
|||||||
try {
|
try {
|
||||||
metadata = JSON.parse(jsonStr);
|
metadata = JSON.parse(jsonStr);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debug.warn('Failed to parse Sieve metadata JSON:', e);
|
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
|
||||||
return OPAQUE;
|
return OPAQUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,4 +148,50 @@ describe('key-storage', () => {
|
|||||||
expect(certs.find(c => c.id === id)).toBeUndefined();
|
expect(certs.find(c => c.id === id)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('accountId filtering', () => {
|
||||||
|
it('listKeyRecords filters by accountId', async () => {
|
||||||
|
const id1 = uid();
|
||||||
|
const id2 = uid();
|
||||||
|
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
|
||||||
|
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
|
||||||
|
|
||||||
|
const acct1Records = await listKeyRecords('acct-1');
|
||||||
|
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
|
||||||
|
expect(acct1Records.find(r => r.id === id2)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listKeyRecords includes records without accountId when filtering', async () => {
|
||||||
|
const id1 = uid();
|
||||||
|
const id2 = uid();
|
||||||
|
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com` }));
|
||||||
|
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
|
||||||
|
|
||||||
|
const acct1Records = await listKeyRecords('acct-1');
|
||||||
|
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
|
||||||
|
expect(acct1Records.find(r => r.id === id2)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listPublicCerts filters by accountId', async () => {
|
||||||
|
const id1 = uid();
|
||||||
|
const id2 = uid();
|
||||||
|
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
|
||||||
|
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
|
||||||
|
|
||||||
|
const acct1Certs = await listPublicCerts('acct-1');
|
||||||
|
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
|
||||||
|
expect(acct1Certs.find(c => c.id === id2)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listPublicCerts includes certs without accountId when filtering', async () => {
|
||||||
|
const id1 = uid();
|
||||||
|
const id2 = uid();
|
||||||
|
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com` }));
|
||||||
|
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
|
||||||
|
|
||||||
|
const acct1Certs = await listPublicCerts('acct-1');
|
||||||
|
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
|
||||||
|
expect(acct1Certs.find(c => c.id === id2)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ beforeEach(() => {
|
|||||||
defaultSignIdentity: {},
|
defaultSignIdentity: {},
|
||||||
defaultEncrypt: false,
|
defaultEncrypt: false,
|
||||||
rememberUnlockedKeys: false,
|
rememberUnlockedKeys: false,
|
||||||
autoImportSignerCerts: false,
|
autoImportSignerCerts: true,
|
||||||
|
accountPreferences: {},
|
||||||
|
currentAccountId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -274,6 +274,12 @@ export function getLinerCryptoEngine(): pkijs.CryptoEngine {
|
|||||||
return linerEngine!;
|
return linerEngine!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the webcrypto-liner Crypto instance (for importKey with legacy algorithms). */
|
||||||
|
export function getLinerCrypto(): Crypto {
|
||||||
|
ensureLiner();
|
||||||
|
return linerCryptoInstance!;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run an async operation with the global PKI.js engine set to webcrypto-liner,
|
* Run an async operation with the global PKI.js engine set to webcrypto-liner,
|
||||||
* then restore the previous engine afterwards.
|
* then restore the previous engine afterwards.
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
import type { SmimeKeyRecord, SmimePublicCert } from './types';
|
import type { SmimeKeyRecord, SmimePublicCert } from './types';
|
||||||
|
|
||||||
const DB_NAME = 'smime-store';
|
const DB_NAME = 'smime-store';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 2;
|
||||||
const KEY_RECORDS_STORE = 'key-records';
|
const KEY_RECORDS_STORE = 'key-records';
|
||||||
const PUBLIC_CERTS_STORE = 'public-certs';
|
const PUBLIC_CERTS_STORE = 'public-certs';
|
||||||
|
|
||||||
function openDB(): Promise<IDBDatabase> {
|
function openDB(): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = (event) => {
|
||||||
const db = request.result;
|
const db = request.result;
|
||||||
if (!db.objectStoreNames.contains(KEY_RECORDS_STORE)) {
|
const oldVersion = event.oldVersion;
|
||||||
|
if (oldVersion < 1) {
|
||||||
const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
|
const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
|
||||||
keyStore.createIndex('email', 'email', { unique: false });
|
keyStore.createIndex('email', 'email', { unique: false });
|
||||||
}
|
keyStore.createIndex('accountId', 'accountId', { unique: false });
|
||||||
if (!db.objectStoreNames.contains(PUBLIC_CERTS_STORE)) {
|
|
||||||
const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
|
const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
|
||||||
certStore.createIndex('email', 'email', { unique: false });
|
certStore.createIndex('email', 'email', { unique: false });
|
||||||
|
certStore.createIndex('accountId', 'accountId', { unique: false });
|
||||||
|
}
|
||||||
|
if (oldVersion >= 1 && oldVersion < 2) {
|
||||||
|
// Add accountId index to existing stores
|
||||||
|
const tx = request.transaction!;
|
||||||
|
const keyStore = tx.objectStore(KEY_RECORDS_STORE);
|
||||||
|
if (!keyStore.indexNames.contains('accountId')) {
|
||||||
|
keyStore.createIndex('accountId', 'accountId', { unique: false });
|
||||||
|
}
|
||||||
|
const certStore = tx.objectStore(PUBLIC_CERTS_STORE);
|
||||||
|
if (!certStore.indexNames.contains('accountId')) {
|
||||||
|
certStore.createIndex('accountId', 'accountId', { unique: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
@@ -62,9 +75,11 @@ export async function getKeyRecordForEmail(email: string): Promise<SmimeKeyRecor
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listKeyRecords(): Promise<SmimeKeyRecord[]> {
|
export async function listKeyRecords(accountId?: string): Promise<SmimeKeyRecord[]> {
|
||||||
const db = await openDB();
|
const db = await openDB();
|
||||||
return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
|
const all = await txPromise<SmimeKeyRecord[]>(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
|
||||||
|
if (!accountId) return all;
|
||||||
|
return all.filter((r) => r.accountId === accountId || !r.accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteKeyRecord(id: string): Promise<void> {
|
export async function deleteKeyRecord(id: string): Promise<void> {
|
||||||
@@ -90,9 +105,11 @@ export async function getPublicCertForEmail(email: string): Promise<SmimePublicC
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPublicCerts(): Promise<SmimePublicCert[]> {
|
export async function listPublicCerts(accountId?: string): Promise<SmimePublicCert[]> {
|
||||||
const db = await openDB();
|
const db = await openDB();
|
||||||
return txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
|
const all = await txPromise<SmimePublicCert[]>(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
|
||||||
|
if (!accountId) return all;
|
||||||
|
return all.filter((c) => c.accountId === accountId || !c.accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePublicCert(id: string): Promise<void> {
|
export async function deletePublicCert(id: string): Promise<void> {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
classifyCapabilities,
|
classifyCapabilities,
|
||||||
} from './certificate-utils';
|
} from './certificate-utils';
|
||||||
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
|
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
|
||||||
import { withLinerEngine } from './crypto-engine';
|
import { withLinerEngine, getLinerCrypto } from './crypto-engine';
|
||||||
|
|
||||||
const KDF_ITERATIONS = 600_000;
|
const KDF_ITERATIONS = 600_000;
|
||||||
const AES_KEY_LENGTH = 256;
|
const AES_KEY_LENGTH = 256;
|
||||||
@@ -217,6 +217,8 @@ async function encryptPrivateKey(
|
|||||||
export interface UnlockedKeyPair {
|
export interface UnlockedKeyPair {
|
||||||
signingKey: CryptoKey;
|
signingKey: CryptoKey;
|
||||||
decryptionKey?: CryptoKey;
|
decryptionKey?: CryptoKey;
|
||||||
|
/** Key imported via webcrypto-liner as RSAES-PKCS1-v1_5 for legacy S/MIME (3DES) messages */
|
||||||
|
legacyDecryptionKey?: CryptoKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys for signing and decryption. */
|
/** Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys for signing and decryption. */
|
||||||
@@ -257,7 +259,22 @@ export async function unlockPrivateKey(
|
|||||||
} catch {
|
} catch {
|
||||||
// Key may only support decryption (key-encipherment-only cert)
|
// Key may only support decryption (key-encipherment-only cert)
|
||||||
const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
|
const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
|
||||||
return { signingKey: decryptionKey, decryptionKey };
|
let legacyDecryptionKey: CryptoKey | undefined;
|
||||||
|
if (!isEcdsa) {
|
||||||
|
try {
|
||||||
|
const linerCrypto = getLinerCrypto();
|
||||||
|
legacyDecryptionKey = await linerCrypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
pkcs8Bytes,
|
||||||
|
{ name: 'RSAES-PKCS1-v1_5' },
|
||||||
|
false,
|
||||||
|
['decrypt'],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// webcrypto-liner may not be available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { signingKey: decryptionKey, decryptionKey, legacyDecryptionKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also import for decryption (separate CryptoKey handle required by Web Crypto)
|
// Also import for decryption (separate CryptoKey handle required by Web Crypto)
|
||||||
@@ -268,7 +285,29 @@ export async function unlockPrivateKey(
|
|||||||
// Key may only support signing (digitalSignature-only cert)
|
// Key may only support signing (digitalSignature-only cert)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { signingKey, decryptionKey };
|
// Import a legacy decryption key via webcrypto-liner for RSAES-PKCS1-v1_5 key transport
|
||||||
|
// (used by older S/MIME messages encrypted with 3DES, RC2, etc.)
|
||||||
|
let legacyDecryptionKey: CryptoKey | undefined;
|
||||||
|
if (!isEcdsa) {
|
||||||
|
try {
|
||||||
|
const linerCrypto = getLinerCrypto();
|
||||||
|
legacyDecryptionKey = await linerCrypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
pkcs8Bytes,
|
||||||
|
{ name: 'RSAES-PKCS1-v1_5' },
|
||||||
|
false,
|
||||||
|
['decrypt'],
|
||||||
|
);
|
||||||
|
console.debug('[S/MIME] legacy RSAES-PKCS1-v1_5 key imported successfully:', {
|
||||||
|
algorithm: legacyDecryptionKey.algorithm,
|
||||||
|
usages: legacyDecryptionKey.usages,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[S/MIME] legacy RSAES-PKCS1-v1_5 key import failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { signingKey, decryptionKey, legacyDecryptionKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get decrypted PKCS#8 bytes (for export flow). */
|
/** Get decrypted PKCS#8 bytes (for export flow). */
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ export interface DecryptionInput {
|
|||||||
cmsBytes: ArrayBuffer;
|
cmsBytes: ArrayBuffer;
|
||||||
/** All imported key records to try matching against */
|
/** All imported key records to try matching against */
|
||||||
keyRecords: SmimeKeyRecord[];
|
keyRecords: SmimeKeyRecord[];
|
||||||
/** Unlocked CryptoKey map: keyRecordId → CryptoKey */
|
/** Unlocked CryptoKey map: keyRecordId → CryptoKey (RSA-OAEP) */
|
||||||
unlockedKeys: Map<string, CryptoKey>;
|
unlockedKeys: Map<string, CryptoKey>;
|
||||||
|
/** Unlocked legacy CryptoKey map: keyRecordId → CryptoKey (RSAES-PKCS1-v1_5 via webcrypto-liner) */
|
||||||
|
legacyUnlockedKeys?: Map<string, CryptoKey>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecryptionResult {
|
export interface DecryptionResult {
|
||||||
@@ -34,12 +36,24 @@ export interface DecryptionResult {
|
|||||||
* @throws Error if no matching key is found, key is locked, or decryption fails
|
* @throws Error if no matching key is found, key is locked, or decryption fails
|
||||||
*/
|
*/
|
||||||
export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> {
|
export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> {
|
||||||
const { cmsBytes, keyRecords, unlockedKeys } = input;
|
const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input;
|
||||||
|
|
||||||
// Parse the CMS ContentInfo wrapper
|
// Parse the CMS ContentInfo wrapper
|
||||||
const contentInfo = parseContentInfo(cmsBytes);
|
const contentInfo = parseContentInfo(cmsBytes);
|
||||||
const envelopedData = extractEnvelopedData(contentInfo);
|
const envelopedData = extractEnvelopedData(contentInfo);
|
||||||
|
|
||||||
|
// Log CMS algorithm details for diagnostics
|
||||||
|
const contentEncOid = envelopedData.encryptedContentInfo?.contentEncryptionAlgorithm?.algorithmId;
|
||||||
|
const recipientAlgs = envelopedData.recipientInfos?.map((ri) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(ri as any).value?.keyEncryptionAlgorithm?.algorithmId as string | undefined,
|
||||||
|
);
|
||||||
|
console.debug('[S/MIME] CMS algorithms:', {
|
||||||
|
contentEncryption: contentEncOid,
|
||||||
|
keyTransport: recipientAlgs,
|
||||||
|
legacyKeysAvailable: legacyUnlockedKeys?.size ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
// Find matching key records
|
// Find matching key records
|
||||||
const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords);
|
const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords);
|
||||||
|
|
||||||
@@ -51,6 +65,19 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
|
|||||||
for (const { keyRecord, recipientIndex } of matchedRecords) {
|
for (const { keyRecord, recipientIndex } of matchedRecords) {
|
||||||
const privateKey = unlockedKeys.get(keyRecord.id);
|
const privateKey = unlockedKeys.get(keyRecord.id);
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
|
// Try legacy key (RSAES-PKCS1-v1_5) if no RSA-OAEP key
|
||||||
|
const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
|
||||||
|
if (legacyKey) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
|
||||||
|
return {
|
||||||
|
mimeBytes: new Uint8Array(decrypted),
|
||||||
|
keyRecordId: keyRecord.id,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
continue; // Key exists but isn't unlocked — skip, caller should unlock first
|
continue; // Key exists but isn't unlocked — skip, caller should unlock first
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,16 +87,32 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
|
|||||||
mimeBytes: new Uint8Array(decrypted),
|
mimeBytes: new Uint8Array(decrypted),
|
||||||
keyRecordId: keyRecord.id,
|
keyRecordId: keyRecord.id,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (oaepError) {
|
||||||
// This key didn't work, try the next one
|
// RSA-OAEP key didn't work, try legacy RSAES-PKCS1-v1_5 key
|
||||||
|
console.debug('[S/MIME] RSA-OAEP decrypt failed:', oaepError instanceof Error ? oaepError.message : oaepError);
|
||||||
|
const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
|
||||||
|
console.debug('[S/MIME] legacy key available:', !!legacyKey, legacyKey ? { algorithm: (legacyKey as CryptoKey).algorithm } : undefined);
|
||||||
|
if (legacyKey) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
|
||||||
|
return {
|
||||||
|
mimeBytes: new Uint8Array(decrypted),
|
||||||
|
keyRecordId: keyRecord.id,
|
||||||
|
};
|
||||||
|
} catch (legacyError) {
|
||||||
|
// Legacy key also didn't work, try the next record
|
||||||
|
console.debug('[S/MIME] RSAES-PKCS1-v1_5 decrypt also failed:', legacyError instanceof Error ? legacyError.message : legacyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we had matching records but none were unlocked
|
// Check if we had matching records but none were unlocked
|
||||||
const hasLockedMatch = matchedRecords.some(m => !unlockedKeys.has(m.keyRecord.id));
|
const isUnlocked = (id: string) => unlockedKeys.has(id) || (legacyUnlockedKeys?.has(id) ?? false);
|
||||||
|
const hasLockedMatch = matchedRecords.some(m => !isUnlocked(m.keyRecord.id));
|
||||||
if (hasLockedMatch) {
|
if (hasLockedMatch) {
|
||||||
const lockedRecord = matchedRecords.find(m => !unlockedKeys.has(m.keyRecord.id))!;
|
const lockedRecord = matchedRecords.find(m => !isUnlocked(m.keyRecord.id))!;
|
||||||
throw new SmimeKeyLockedError(
|
throw new SmimeKeyLockedError(
|
||||||
'S/MIME key is locked. Unlock it to decrypt this message.',
|
'S/MIME key is locked. Unlock it to decrypt this message.',
|
||||||
lockedRecord.keyRecord.id,
|
lockedRecord.keyRecord.id,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Verify CMS SignedData (opaque signed) and extract the inner content.
|
* Verify CMS SignedData (opaque signed) and extract the inner content.
|
||||||
*
|
*
|
||||||
* v1 performs cryptographic signature validation and cert validity checks
|
* Performs cryptographic signature validation, cert validity checks,
|
||||||
* but does NOT implement full trust-chain or revocation validation.
|
* and trust-chain verification.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as pkijs from 'pkijs';
|
import * as pkijs from 'pkijs';
|
||||||
@@ -61,7 +61,7 @@ export async function smimeVerify(
|
|||||||
const verifyResult = await signedData.verify(
|
const verifyResult = await signedData.verify(
|
||||||
{
|
{
|
||||||
signer: 0,
|
signer: 0,
|
||||||
checkChain: false, // v1: no trust-chain validation
|
checkChain: true,
|
||||||
},
|
},
|
||||||
cryptoEngine,
|
cryptoEngine,
|
||||||
);
|
);
|
||||||
@@ -108,6 +108,11 @@ export async function smimeVerify(
|
|||||||
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
|
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect self-signed certificates (issuer === subject)
|
||||||
|
const issuerDer = new Uint8Array(signerCert.issuer.toSchema().toBER(false));
|
||||||
|
const subjectDer = new Uint8Array(signerCert.subject.toSchema().toBER(false));
|
||||||
|
const selfSigned = arraysEqual(issuerDer, subjectDer);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mimeBytes: innerContent,
|
mimeBytes: innerContent,
|
||||||
status: {
|
status: {
|
||||||
@@ -117,6 +122,7 @@ export async function smimeVerify(
|
|||||||
signatureError,
|
signatureError,
|
||||||
signerCert: signerPublicCert,
|
signerCert: signerPublicCert,
|
||||||
signerEmailMatch,
|
signerEmailMatch,
|
||||||
|
selfSigned,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/** Stored record for an imported S/MIME private key + certificate. */
|
/** Stored record for an imported S/MIME private key + certificate. */
|
||||||
export interface SmimeKeyRecord {
|
export interface SmimeKeyRecord {
|
||||||
id: string;
|
id: string;
|
||||||
|
accountId?: string;
|
||||||
email: string;
|
email: string;
|
||||||
certificate: ArrayBuffer; // DER-encoded X.509 leaf cert
|
certificate: ArrayBuffer; // DER-encoded X.509 leaf cert
|
||||||
certificateChain: ArrayBuffer[]; // DER-encoded intermediates
|
certificateChain: ArrayBuffer[]; // DER-encoded intermediates
|
||||||
@@ -34,6 +35,7 @@ export interface SmimeUnlockedKey {
|
|||||||
/** A recipient or contact public certificate. */
|
/** A recipient or contact public certificate. */
|
||||||
export interface SmimePublicCert {
|
export interface SmimePublicCert {
|
||||||
id: string;
|
id: string;
|
||||||
|
accountId?: string;
|
||||||
email: string;
|
email: string;
|
||||||
certificate: ArrayBuffer; // DER-encoded X.509
|
certificate: ArrayBuffer; // DER-encoded X.509
|
||||||
issuer: string;
|
issuer: string;
|
||||||
@@ -53,6 +55,8 @@ export interface SmimeStatus {
|
|||||||
signatureError?: string;
|
signatureError?: string;
|
||||||
signerCert?: SmimePublicCert;
|
signerCert?: SmimePublicCert;
|
||||||
signerEmailMatch?: boolean;
|
signerEmailMatch?: boolean;
|
||||||
|
/** True when the signer certificate is self-signed (not chained to a trusted CA). */
|
||||||
|
selfSigned?: boolean;
|
||||||
decryptionSuccess?: boolean;
|
decryptionSuccess?: boolean;
|
||||||
decryptionError?: string;
|
decryptionError?: string;
|
||||||
unsupportedReason?: string;
|
unsupportedReason?: string;
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
|
const STALWART_AUTH_CONTEXT_COOKIE = 'jmap_stalwart_ctx';
|
||||||
|
|
||||||
|
export interface StalwartAuthContext {
|
||||||
|
serverUrl: string;
|
||||||
|
username: string;
|
||||||
|
authHeader: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieStore = Awaited<ReturnType<typeof cookies>>;
|
||||||
|
|
||||||
|
export function stalwartAuthContextCookieName(slot: number): string {
|
||||||
|
return slot === 0 ? STALWART_AUTH_CONTEXT_COOKIE : `${STALWART_AUTH_CONTEXT_COOKIE}_${slot}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidContext(payload: unknown): payload is StalwartAuthContext {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = payload as Record<string, unknown>;
|
||||||
|
return typeof candidate.serverUrl === 'string'
|
||||||
|
&& typeof candidate.username === 'string'
|
||||||
|
&& typeof candidate.authHeader === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionCookieOptions() {
|
||||||
|
const { maxAge: _maxAge, ...cookieOptions } = getCookieOptions();
|
||||||
|
return cookieOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStalwartAuthContextFromStore(
|
||||||
|
cookieStore: CookieStore,
|
||||||
|
slot: number,
|
||||||
|
): StalwartAuthContext | null {
|
||||||
|
const token = cookieStore.get(stalwartAuthContextCookieName(slot))?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const payload = decryptPayload(token);
|
||||||
|
return isValidContext(payload) ? payload : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readStalwartAuthContext(slot: number): Promise<StalwartAuthContext | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
return readStalwartAuthContextFromStore(cookieStore, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStalwartAuthContextInStore(
|
||||||
|
cookieStore: CookieStore,
|
||||||
|
slot: number,
|
||||||
|
context: StalwartAuthContext,
|
||||||
|
): void {
|
||||||
|
cookieStore.set(
|
||||||
|
stalwartAuthContextCookieName(slot),
|
||||||
|
encryptPayload(context as unknown as Record<string, unknown>),
|
||||||
|
getSessionCookieOptions(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStalwartAuthContext(slot: number, context: StalwartAuthContext): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStalwartAuthContextInStore(cookieStore: CookieStore, slot: number): void {
|
||||||
|
cookieStore.delete(stalwartAuthContextCookieName(slot));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearStalwartAuthContext(slot: number): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
|
}
|
||||||
+30
-30
@@ -1,7 +1,7 @@
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { decryptSession } from '@/lib/auth/crypto';
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
|
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||||
|
|
||||||
export interface StalwartCredentials {
|
export interface StalwartCredentials {
|
||||||
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
|
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
|
||||||
@@ -11,6 +11,7 @@ export interface StalwartCredentials {
|
|||||||
authHeader: string;
|
authHeader: string;
|
||||||
username: string;
|
username: string;
|
||||||
hasSessionCookie: boolean;
|
hasSessionCookie: boolean;
|
||||||
|
slot: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,39 +31,38 @@ function getStalwartApiUrl(jmapServerUrl: string): string {
|
|||||||
/**
|
/**
|
||||||
* Extract credentials from the incoming request.
|
* Extract credentials from the incoming request.
|
||||||
*
|
*
|
||||||
* Tries the explicit headers first (`Authorization`, `X-JMAP-Server-URL`,
|
* Credentials are read from a verified, httpOnly auth-context cookie that is
|
||||||
* `X-JMAP-Username`), then falls back to the encrypted session cookie.
|
* populated after a successful JMAP login or token refresh.
|
||||||
*/
|
*/
|
||||||
|
function parseSlot(raw: string | null): number | null {
|
||||||
|
if (raw === null) return null;
|
||||||
|
const slot = parseInt(raw, 10);
|
||||||
|
return Number.isNaN(slot) || slot < 0 || slot > 4 ? null : slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCandidateSlots(request: NextRequest): number[] {
|
||||||
|
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
|
||||||
|
?? parseSlot(request.nextUrl.searchParams.get('slot'));
|
||||||
|
|
||||||
|
return requestedSlot === null ? [0, 1, 2, 3, 4] : [requestedSlot];
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
|
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
|
||||||
const authHeader = request.headers.get('Authorization');
|
const cookieStore = await cookies();
|
||||||
const serverUrl = request.headers.get('X-JMAP-Server-URL');
|
|
||||||
const username = request.headers.get('X-JMAP-Username');
|
for (const slot of getCandidateSlots(request)) {
|
||||||
|
const context = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||||
|
if (!context) continue;
|
||||||
|
|
||||||
if (authHeader && serverUrl && username) {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const hasSessionCookie = !!cookieStore.get(SESSION_COOKIE)?.value;
|
|
||||||
return {
|
return {
|
||||||
apiUrl: getStalwartApiUrl(serverUrl),
|
apiUrl: getStalwartApiUrl(context.serverUrl),
|
||||||
serverUrl,
|
serverUrl: context.serverUrl,
|
||||||
authHeader,
|
authHeader: context.authHeader,
|
||||||
username,
|
username: context.username,
|
||||||
hasSessionCookie,
|
hasSessionCookie: !!cookieStore.get(sessionCookieName(slot))?.value,
|
||||||
|
slot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
return null;
|
||||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
const credentials = decryptSession(token);
|
|
||||||
if (!credentials) return null;
|
|
||||||
|
|
||||||
const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
|
|
||||||
return {
|
|
||||||
apiUrl: getStalwartApiUrl(credentials.serverUrl),
|
|
||||||
serverUrl: credentials.serverUrl,
|
|
||||||
authHeader: basic,
|
|
||||||
username: credentials.username,
|
|
||||||
hasSessionCookie: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-27
@@ -236,11 +236,11 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
attachments: [],
|
attachments: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
debug.group('TNEF Parser');
|
debug.group('TNEF Parser', 'email');
|
||||||
debug.log('Input data size:', data.byteLength, 'bytes');
|
debug.log('email', 'Input data size:', data.byteLength, 'bytes');
|
||||||
|
|
||||||
if (data.byteLength < 6) {
|
if (data.byteLength < 6) {
|
||||||
debug.warn('TNEF data too small (< 6 bytes), skipping');
|
debug.warn('email', 'TNEF data too small (< 6 bytes), skipping');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -249,11 +249,11 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
|
|
||||||
const signature = r.readUint32LE();
|
const signature = r.readUint32LE();
|
||||||
if (signature !== TNEF_SIGNATURE) {
|
if (signature !== TNEF_SIGNATURE) {
|
||||||
debug.warn('Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
|
debug.warn('email', 'Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
debug.log('TNEF signature valid');
|
debug.log('email', 'TNEF signature valid');
|
||||||
|
|
||||||
r.skip(2); // legacy key
|
r.skip(2); // legacy key
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
attrCount++;
|
attrCount++;
|
||||||
|
|
||||||
if (attrLen > r.remaining - 2) {
|
if (attrLen > r.remaining - 2) {
|
||||||
debug.warn('Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
|
debug.warn('email', 'Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,17 +276,17 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
r.skip(2); // checksum
|
r.skip(2); // checksum
|
||||||
|
|
||||||
const levelName = level === LVL_MESSAGE ? 'MESSAGE' : level === LVL_ATTACHMENT ? 'ATTACHMENT' : 'UNKNOWN(' + level + ')';
|
const levelName = level === LVL_MESSAGE ? 'MESSAGE' : level === LVL_ATTACHMENT ? 'ATTACHMENT' : 'UNKNOWN(' + level + ')';
|
||||||
debug.log('Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
|
debug.log('email', 'Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
|
||||||
|
|
||||||
if (level === LVL_MESSAGE) {
|
if (level === LVL_MESSAGE) {
|
||||||
if (attrID === attBody) {
|
if (attrID === attBody) {
|
||||||
result.body = new TextDecoder('utf-8').decode(attrData);
|
result.body = new TextDecoder('utf-8').decode(attrData);
|
||||||
debug.log(' → Extracted plain text body (' + result.body.length + ' chars)');
|
debug.log('email', ' → Extracted plain text body (' + result.body.length + ' chars)');
|
||||||
} else if (attrID === attMAPIProps) {
|
} else if (attrID === attMAPIProps) {
|
||||||
const props = parseMAPIProps(attrData);
|
const props = parseMAPIProps(attrData);
|
||||||
debug.log(' → Parsed', props.size, 'MAPI properties from message');
|
debug.log('email', ' → Parsed', props.size, 'MAPI properties from message');
|
||||||
props.forEach((val, propID) => {
|
props.forEach((val, propID) => {
|
||||||
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
// HTML body
|
// HTML body
|
||||||
@@ -298,9 +298,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
} else {
|
} else {
|
||||||
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
|
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
|
||||||
}
|
}
|
||||||
debug.log(' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
|
debug.log('email', ' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
|
||||||
} else {
|
} else {
|
||||||
debug.log(' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
|
debug.log('email', ' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain text body from MAPI props (fallback)
|
// Plain text body from MAPI props (fallback)
|
||||||
@@ -308,9 +308,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
const bodyProp = props.get(PR_BODY);
|
const bodyProp = props.get(PR_BODY);
|
||||||
if (bodyProp?.value instanceof Uint8Array) {
|
if (bodyProp?.value instanceof Uint8Array) {
|
||||||
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
|
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
|
||||||
debug.log(' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
|
debug.log('email', ' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
|
||||||
} else {
|
} else {
|
||||||
debug.log(' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
|
debug.log('email', ' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +318,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
if (attrID === attAttachRenddata) {
|
if (attrID === attAttachRenddata) {
|
||||||
// Start of a new attachment — flush previous
|
// Start of a new attachment — flush previous
|
||||||
if (curAttach?.data) {
|
if (curAttach?.data) {
|
||||||
debug.log(' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
debug.log('email', ' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||||
result.attachments.push({
|
result.attachments.push({
|
||||||
name: curAttach.name,
|
name: curAttach.name,
|
||||||
mimeType: curAttach.mimeType,
|
mimeType: curAttach.mimeType,
|
||||||
@@ -326,40 +326,40 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
|
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
|
||||||
debug.log(' → New attachment started');
|
debug.log('email', ' → New attachment started');
|
||||||
} else if (attrID === attAttachTitle && curAttach) {
|
} else if (attrID === attAttachTitle && curAttach) {
|
||||||
let len = attrData.byteLength;
|
let len = attrData.byteLength;
|
||||||
if (len > 0 && attrData[len - 1] === 0) len--;
|
if (len > 0 && attrData[len - 1] === 0) len--;
|
||||||
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
|
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
|
||||||
debug.log(' → Attachment short name:', curAttach.name);
|
debug.log('email', ' → Attachment short name:', curAttach.name);
|
||||||
} else if (attrID === attAttachData && curAttach) {
|
} else if (attrID === attAttachData && curAttach) {
|
||||||
curAttach.data = attrData;
|
curAttach.data = attrData;
|
||||||
debug.log(' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
|
debug.log('email', ' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
|
||||||
} else if (attrID === attAttachment && curAttach) {
|
} else if (attrID === attAttachment && curAttach) {
|
||||||
const props = parseMAPIProps(attrData);
|
const props = parseMAPIProps(attrData);
|
||||||
debug.log(' → Parsed', props.size, 'MAPI properties from attachment');
|
debug.log('email', ' → Parsed', props.size, 'MAPI properties from attachment');
|
||||||
props.forEach((val, propID) => {
|
props.forEach((val, propID) => {
|
||||||
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
const longName = props.get(PR_ATTACH_LONG_FILENAME);
|
const longName = props.get(PR_ATTACH_LONG_FILENAME);
|
||||||
if (longName?.value instanceof Uint8Array) {
|
if (longName?.value instanceof Uint8Array) {
|
||||||
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
|
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
|
||||||
debug.log(' → Attachment long filename:', curAttach.name);
|
debug.log('email', ' → Attachment long filename:', curAttach.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
|
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
|
||||||
if (mimeTag?.value instanceof Uint8Array) {
|
if (mimeTag?.value instanceof Uint8Array) {
|
||||||
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
|
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
|
||||||
debug.log(' → Attachment MIME type:', curAttach.mimeType);
|
debug.log('email', ' → Attachment MIME type:', curAttach.mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
||||||
if (attachData?.value instanceof Uint8Array) {
|
if (attachData?.value instanceof Uint8Array) {
|
||||||
curAttach.data = attachData.value;
|
curAttach.data = attachData.value;
|
||||||
debug.log(' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
|
debug.log('email', ' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
|
||||||
} else {
|
} else {
|
||||||
debug.log(' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
|
debug.log('email', ' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,7 +367,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
|
|
||||||
// Flush last attachment
|
// Flush last attachment
|
||||||
if (curAttach?.data) {
|
if (curAttach?.data) {
|
||||||
debug.log('Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
debug.log('email', 'Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||||
result.attachments.push({
|
result.attachments.push({
|
||||||
name: curAttach.name,
|
name: curAttach.name,
|
||||||
mimeType: curAttach.mimeType,
|
mimeType: curAttach.mimeType,
|
||||||
@@ -375,9 +375,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
|
debug.log('email', 'TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
|
||||||
if (result.attachments.length > 0) {
|
if (result.attachments.length > 0) {
|
||||||
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })));
|
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })), 'email');
|
||||||
}
|
}
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
|
|
||||||
|
|||||||
+63
-2
@@ -1,6 +1,7 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { Mailbox } from "./jmap/types";
|
import { Mailbox } from "./jmap/types";
|
||||||
|
import { debug } from "./debug";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
@@ -98,6 +99,7 @@ const ROLE_PRIORITY: Record<string, number> = {
|
|||||||
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
|
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
|
||||||
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
||||||
const result: Mailbox[] = [];
|
const result: Mailbox[] = [];
|
||||||
|
const removed: { id: string; name: string; matchedRole: string; parentId?: string }[] = [];
|
||||||
|
|
||||||
// Group role mailboxes by account so deduplication is scoped per-account
|
// Group role mailboxes by account so deduplication is scoped per-account
|
||||||
const rolesByAccount = new Map<string, Mailbox[]>();
|
const rolesByAccount = new Map<string, Mailbox[]>();
|
||||||
@@ -109,6 +111,12 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build a set of IDs that are referenced as parents
|
||||||
|
const referencedParentIds = new Set<string>();
|
||||||
|
mailboxes.forEach(mb => {
|
||||||
|
if (mb.parentId) referencedParentIds.add(mb.parentId);
|
||||||
|
});
|
||||||
|
|
||||||
// Filter out duplicates scoped to the same account
|
// Filter out duplicates scoped to the same account
|
||||||
mailboxes.forEach(mb => {
|
mailboxes.forEach(mb => {
|
||||||
// If this mailbox has a role, always keep it
|
// If this mailbox has a role, always keep it
|
||||||
@@ -117,30 +125,59 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a duplicate of a role-based mailbox in the SAME account
|
// Never deduplicate nested mailboxes — only root-level folders can be
|
||||||
|
// duplicates of role-based mailboxes. Removing a nested folder that happens
|
||||||
|
// to share a name with a role folder (e.g. a subfolder named "Sent") would
|
||||||
|
// orphan its children to root level. (GitHub #118)
|
||||||
|
if (mb.parentId) {
|
||||||
|
result.push(mb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this root-level mailbox is a duplicate of a role-based mailbox in the SAME account
|
||||||
const accountKey = mb.accountId || '';
|
const accountKey = mb.accountId || '';
|
||||||
const accountRoles = rolesByAccount.get(accountKey) || [];
|
const accountRoles = rolesByAccount.get(accountKey) || [];
|
||||||
const lowerName = mb.name.toLowerCase();
|
const lowerName = mb.name.toLowerCase();
|
||||||
const isDuplicate = accountRoles.some(roleMb => {
|
const matchedRole = accountRoles.find(roleMb => {
|
||||||
const roleLowerName = roleMb.name.toLowerCase();
|
const roleLowerName = roleMb.name.toLowerCase();
|
||||||
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
|
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
|
||||||
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
|
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
|
||||||
});
|
});
|
||||||
|
const isDuplicate = !!matchedRole;
|
||||||
|
|
||||||
// Only keep if not a duplicate
|
// Only keep if not a duplicate
|
||||||
if (!isDuplicate) {
|
if (!isDuplicate) {
|
||||||
result.push(mb);
|
result.push(mb);
|
||||||
|
} else {
|
||||||
|
removed.push({ id: mb.id, name: mb.name, matchedRole: matchedRole!.name, parentId: mb.parentId });
|
||||||
|
// Warn if this removed mailbox is a parent of other mailboxes (orphan risk)
|
||||||
|
if (referencedParentIds.has(mb.id)) {
|
||||||
|
debug.warn('jmap', `[Mailbox Tree] Deduplication removed mailbox "${mb.name}" (id: ${mb.id}) which is a parent of other mailboxes. ` +
|
||||||
|
`Matched role mailbox: "${matchedRole!.name}" (role: ${matchedRole!.role}). ` +
|
||||||
|
`Children referencing parentId "${mb.id}" will be orphaned to root level.`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (removed.length > 0) {
|
||||||
|
debug.log('jmap', `[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a hierarchical tree structure from flat mailbox array
|
// Build a hierarchical tree structure from flat mailbox array
|
||||||
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||||
|
debug.log('jmap', `[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`);
|
||||||
|
|
||||||
// Deduplicate mailboxes first
|
// Deduplicate mailboxes first
|
||||||
const deduplicated = deduplicateMailboxes(mailboxes);
|
const deduplicated = deduplicateMailboxes(mailboxes);
|
||||||
|
|
||||||
|
if (deduplicated.length !== mailboxes.length) {
|
||||||
|
debug.log('jmap', `[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
// Separate own and shared mailboxes
|
// Separate own and shared mailboxes
|
||||||
const ownMailboxes = deduplicated.filter(mb => !mb.isShared);
|
const ownMailboxes = deduplicated.filter(mb => !mb.isShared);
|
||||||
const sharedMailboxes = deduplicated.filter(mb => mb.isShared);
|
const sharedMailboxes = deduplicated.filter(mb => mb.isShared);
|
||||||
@@ -168,6 +205,7 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Second pass: build tree structure for own mailboxes
|
// Second pass: build tree structure for own mailboxes
|
||||||
|
const orphanedMailboxes: { id: string; name: string; parentId: string }[] = [];
|
||||||
ownMailboxes.forEach(mailbox => {
|
ownMailboxes.forEach(mailbox => {
|
||||||
const node = mailboxMap.get(mailbox.id)!;
|
const node = mailboxMap.get(mailbox.id)!;
|
||||||
|
|
||||||
@@ -176,13 +214,36 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
|||||||
parent.children.push(node);
|
parent.children.push(node);
|
||||||
} else {
|
} else {
|
||||||
// Root level mailbox or orphaned mailbox
|
// Root level mailbox or orphaned mailbox
|
||||||
|
if (mailbox.parentId) {
|
||||||
|
orphanedMailboxes.push({ id: mailbox.id, name: mailbox.name, parentId: mailbox.parentId });
|
||||||
|
}
|
||||||
rootMailboxes.push(node);
|
rootMailboxes.push(node);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (orphanedMailboxes.length > 0) {
|
||||||
|
debug.warn('jmap', `[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`,
|
||||||
|
orphanedMailboxes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Third pass: correctly calculate depths from the root down
|
// Third pass: correctly calculate depths from the root down
|
||||||
recalculateDepths(rootMailboxes, 0);
|
recalculateDepths(rootMailboxes, 0);
|
||||||
|
|
||||||
|
// Log tree depth statistics
|
||||||
|
const maxDepth = (nodes: MailboxNode[]): number => {
|
||||||
|
let max = 0;
|
||||||
|
for (const node of nodes) {
|
||||||
|
max = Math.max(max, node.depth);
|
||||||
|
if (node.children.length > 0) max = Math.max(max, maxDepth(node.children));
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
};
|
||||||
|
debug.log('jmap', `[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` +
|
||||||
|
`max depth: ${maxDepth(rootMailboxes)}, ` +
|
||||||
|
`total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}`
|
||||||
|
);
|
||||||
|
|
||||||
// If we have shared mailboxes, create a virtual "Shared Folders" parent
|
// If we have shared mailboxes, create a virtual "Shared Folders" parent
|
||||||
if (sharedMailboxes.length > 0) {
|
if (sharedMailboxes.length > 0) {
|
||||||
// Group shared mailboxes by account
|
// Group shared mailboxes by account
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user