Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c5daa3918 | ||
|
|
23870801fc | ||
|
|
ae793551ba | ||
|
|
f19aaf6207 | ||
|
|
d0cc439fd1 | ||
|
|
f42f57b8d0 | ||
|
|
0d4b588fd0 | ||
|
|
6a725dde58 | ||
|
|
6499da6281 | ||
|
|
b2379fb03f | ||
|
|
b29e71124d | ||
|
|
63593e2146 | ||
|
|
10cbe7a637 | ||
|
|
79418f013a | ||
|
|
4024732696 | ||
|
|
ffad3ea78b | ||
|
|
a9b9aeb44d | ||
|
|
3d76fe6d75 | ||
|
|
58cafe28ad | ||
|
|
60f9a3dc4d | ||
|
|
11d9db5580 | ||
|
|
05d3f0b469 | ||
|
|
c3f60448ad | ||
|
|
523711cca3 | ||
|
|
bcb487810a | ||
|
|
7e1d644f7d | ||
|
|
4aac65d7d2 | ||
|
|
14ecae61dc | ||
|
|
c53ff5a30a | ||
|
|
5aad97d64e | ||
|
|
081c865018 | ||
|
|
050f38b1fa | ||
|
|
d657bdfa75 | ||
|
|
bbf724d9e1 | ||
|
|
9d8c6044e3 | ||
|
|
2d17ca71e3 | ||
|
|
d77dd1e3e1 | ||
|
|
9d97b74684 | ||
|
|
c4673acb65 | ||
|
|
1bdc51dcc7 | ||
|
|
6ee0f6a40a | ||
|
|
9ee25c930e | ||
|
|
1b2c70a90b | ||
|
|
cdea876992 | ||
|
|
16557830ae | ||
|
|
8836f9cdca | ||
|
|
26bfe9cb6c | ||
|
|
05eaaad61f | ||
|
|
2e0852b272 | ||
|
|
6173a9ad13 | ||
|
|
e3560d9cb4 | ||
|
|
52326326e2 | ||
|
|
2734fa08b7 | ||
|
|
67a0d622bc | ||
|
|
58968a1cbf | ||
|
|
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=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
|
||||
|
||||
# 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
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Bulwark Webmail – Funding configuration
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
github: [bulwarkmail]
|
||||
@@ -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
|
||||
+116
@@ -1,5 +1,121 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.12 (2026-04-09)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
**One-time**
|
||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||
|
||||
**Monthly**
|
||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||
|
||||
### Features
|
||||
|
||||
- **PWA**: Add PWA support with service worker and install prompt
|
||||
- **Calendar**: Add birthday calendar feature with settings and localization
|
||||
- **Calendar**: Clamp February 29 birthdays in non-leap years
|
||||
- **Identity**: Add automatic identity synchronization (#167)
|
||||
- **Plugins**: Disable plugins by default and require admin approval
|
||||
- **Plugins**: Replace auth header exposure with a secure HTTP proxy API for plugins
|
||||
- **Auth**: Add configurable OAuth scopes and cookie security via environment variables
|
||||
- **Email**: Sync mail view to browser history for back/forward navigation
|
||||
- **Contacts**: Add ability to rename address books (#152)
|
||||
- **UI**: Add version badge in settings
|
||||
- **i18n**: Add Latvian (lv) locale support
|
||||
- **i18n**: Add Polish language support
|
||||
- **i18n**: Add Korean language support
|
||||
- **i18n**: Add Simplified Chinese (zh_CN) locale support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Email**: Show recipient instead of sender in Sent and Drafts folder lists
|
||||
- **Email**: Embed dropped images as data URLs and prevent duplicate attachments (#163)
|
||||
- **Email**: Fix logic for marking email as read in EmailViewer
|
||||
- **Email**: Fix archive action passing MouseEvent as argument
|
||||
- **Mailbox**: Preserve search filters on push-triggered mailbox refresh (#164)
|
||||
- **Mailbox**: Align shared account folders with primary folders (#151)
|
||||
- **Mailbox**: Fetch mailboxes on mount in FolderSettings when store is empty
|
||||
- **Mailbox**: Improve mailbox deletion error handling
|
||||
- **Calendar**: Improve calendar event retrieval by batching requests to avoid server limits (#141)
|
||||
- **Calendar**: Compute per-occurrence UTC start/end in recurrence expansion (#116)
|
||||
- **Calendar**: Guard against undefined trigger in calendar event alert popover (#143)
|
||||
- **Files**: Stream WebDAV PUT uploads to avoid buffering in memory (#162)
|
||||
- **Files**: Prune recent files against server nodes on refresh (#146)
|
||||
- **Files**: Fix file deletion logic to update recent files and handle errors (#146)
|
||||
- **Files**: Extend file drop zone to fill remaining viewport height
|
||||
- **Files**: Fallback to application/octet-stream for long MIME types
|
||||
- **Security**: Replace unguarded crypto.randomUUID() with safe generateUUID() utility
|
||||
- **Security**: Validate plugin HTTP post URL against origin with regression tests
|
||||
- **Security**: Allow blob images in CSP for inline drag-and-drop (#163)
|
||||
- **Auth**: Resolve settings sync identity mismatch for OAuth/SSO sessions (#127)
|
||||
- **Contacts**: Fix address book ID namespacing for shared contacts in create and update operations (#133)
|
||||
- **UI**: Fix focused mode expanding beyond screen bounds (#156)
|
||||
- **API**: Handle 403 on principal fetch without console error
|
||||
- **API**: Enhance error handling in Stalwart API responses
|
||||
|
||||
## 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)
|
||||
|
||||
### Features
|
||||
|
||||
+15
-4
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" />
|
||||
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
</picture>
|
||||
|
||||
</div>
|
||||
@@ -12,6 +12,17 @@
|
||||
|
||||
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
|
||||
|
||||
## Join our Community
|
||||
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
|
||||
|
||||
* **Get Support:** Get real-time help with development hurdles.
|
||||
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
|
||||
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
|
||||
|
||||
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development Setup
|
||||
@@ -195,7 +206,7 @@ webmail/
|
||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||
- **Sanitize user input** and email content
|
||||
- **Block external content** by default for privacy
|
||||
- Report security vulnerabilities privately
|
||||
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -39,10 +39,13 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { debug } from "@/lib/debug";
|
||||
|
||||
type PendingScopeAction =
|
||||
@@ -66,12 +69,13 @@ export default function CalendarPage() {
|
||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||
refreshAllSubscriptions,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
const taskStore = useTaskStore();
|
||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||
const { identities } = useIdentityStore();
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||
|
||||
const currentUserEmails = useMemo(() =>
|
||||
@@ -82,6 +86,7 @@ export default function CalendarPage() {
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||
@@ -146,6 +151,13 @@ export default function CalendarPage() {
|
||||
return () => clearInterval(interval);
|
||||
}, [client, refreshAllSubscriptions]);
|
||||
|
||||
// Auto-add birthday calendar to selected IDs when enabled
|
||||
useEffect(() => {
|
||||
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
|
||||
}
|
||||
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const dateRange = useMemo(() => {
|
||||
const d = selectedDate;
|
||||
switch (normalizedViewMode) {
|
||||
@@ -337,18 +349,28 @@ export default function CalendarPage() {
|
||||
|
||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||
if (isMobile) return;
|
||||
if (calendarHoverPreview === 'off') return;
|
||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||
// Don't show hover popover if the sidebar is already open for this event
|
||||
if (showEventModal && editEvent?.id === event.id) return;
|
||||
setDetailEvent(event);
|
||||
setDetailAnchorRect(anchorRect);
|
||||
}, [isMobile, showEventModal, editEvent]);
|
||||
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
|
||||
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setDetailEvent(event);
|
||||
setDetailAnchorRect(anchorRect);
|
||||
}, ms);
|
||||
} else {
|
||||
setDetailEvent(event);
|
||||
setDetailAnchorRect(anchorRect);
|
||||
}
|
||||
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
|
||||
|
||||
const handleHoverLeave = useCallback(() => {
|
||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setDetailEvent(null);
|
||||
setDetailAnchorRect(null);
|
||||
}, 200);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleEditFromDetail = useCallback(() => {
|
||||
@@ -385,12 +407,16 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [client, fetchEvents]);
|
||||
|
||||
const focusCalendarOnEvent = useCallback((event: Pick<CalendarEvent, "start">) => {
|
||||
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||
if (!event.start) {
|
||||
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())) {
|
||||
return;
|
||||
}
|
||||
@@ -727,14 +753,31 @@ export default function CalendarPage() {
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
|
||||
|
||||
const visibleEvents = useMemo(() =>
|
||||
events.filter((e) => {
|
||||
const birthdayEvents = useMemo(() => {
|
||||
if (!showBirthdayCalendar || !dateRange) return [];
|
||||
return generateBirthdayEvents(contacts, dateRange.start, dateRange.end);
|
||||
}, [showBirthdayCalendar, contacts, dateRange]);
|
||||
|
||||
const birthdayCalendarName = (() => {
|
||||
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
||||
})();
|
||||
|
||||
const allCalendars = useMemo(() => {
|
||||
if (!showBirthdayCalendar) return calendars;
|
||||
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const filtered = events.filter((e) => {
|
||||
if (!e.start || !e.calendarIds) return false;
|
||||
const calIds = Object.keys(e.calendarIds);
|
||||
return calIds.some((id) => selectedCalendarIds.includes(id));
|
||||
}),
|
||||
[events, selectedCalendarIds]
|
||||
);
|
||||
});
|
||||
if (showBirthdayCalendar && selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||
return [...filtered, ...birthdayEvents];
|
||||
}
|
||||
return filtered;
|
||||
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
const hiddenEvents = events.filter((event) => {
|
||||
@@ -749,7 +792,7 @@ export default function CalendarPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
debug.log('Calendar visibility summary', {
|
||||
debug.log('calendar', 'Calendar visibility summary', {
|
||||
totalEvents: events.length,
|
||||
visibleEvents: visibleEvents.length,
|
||||
hiddenEvents: hiddenEvents.length,
|
||||
@@ -783,7 +826,7 @@ export default function CalendarPage() {
|
||||
<CalendarMonthView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
@@ -799,7 +842,7 @@ export default function CalendarPage() {
|
||||
<CalendarWeekView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
@@ -818,7 +861,7 @@ export default function CalendarPage() {
|
||||
<CalendarDayView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
@@ -835,7 +878,7 @@ export default function CalendarPage() {
|
||||
<CalendarAgendaView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
@@ -873,7 +916,7 @@ export default function CalendarPage() {
|
||||
})();
|
||||
|
||||
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}
|
||||
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||
@@ -926,13 +969,18 @@ export default function CalendarPage() {
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
/>
|
||||
<CalendarSidebarPanel
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
onColorChange={client ? (calendarId, color) => {
|
||||
if (calendarId === BIRTHDAY_CALENDAR_ID) {
|
||||
updateSetting('birthdayCalendarColor', color);
|
||||
return;
|
||||
}
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : undefined}
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||
client={client}
|
||||
/>
|
||||
</div>
|
||||
@@ -1099,6 +1147,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} />
|
||||
<RecurrenceScopeDialog
|
||||
isOpen={!!pendingScopeAction}
|
||||
|
||||
@@ -13,12 +13,13 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, generateUUID } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
@@ -72,12 +73,16 @@ export default function ContactsPage() {
|
||||
bulkDeleteContacts,
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
renameAddressBook,
|
||||
renameKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
|
||||
const [view, setView] = useState<View>("list");
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
@@ -274,7 +279,7 @@ export default function ContactsPage() {
|
||||
toast.success(t("toast.created"));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
id: `local-${crypto.randomUUID()}`,
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: {},
|
||||
...data,
|
||||
};
|
||||
@@ -631,6 +636,8 @@ export default function ContactsPage() {
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
@@ -725,6 +732,46 @@ export default function ContactsPage() {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
{renamingKeyword !== null && (
|
||||
<RenameDialog
|
||||
currentName={renamingKeyword}
|
||||
title={t("rename_category")}
|
||||
label={t("category_name_label")}
|
||||
onCancel={() => setRenamingKeyword(null)}
|
||||
onConfirm={async (newName) => {
|
||||
try {
|
||||
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
|
||||
toast.success(t("category_renamed"));
|
||||
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
|
||||
setActiveCategory({ keyword: newName.trim() });
|
||||
}
|
||||
setRenamingKeyword(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename category:", err);
|
||||
toast.error(t("category_rename_failed"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{renamingAddressBook && (
|
||||
<RenameDialog
|
||||
currentName={renamingAddressBook.name}
|
||||
title={t("address_books.rename")}
|
||||
label={t("address_books.name_label")}
|
||||
onCancel={() => setRenamingAddressBook(null)}
|
||||
onConfirm={async (newName) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await renameAddressBook(client, renamingAddressBook, newName);
|
||||
toast.success(t("address_books.renamed"));
|
||||
setRenamingAddressBook(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename address book:", err);
|
||||
toast.error(t("address_books.rename_failed"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showImportDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
|
||||
@@ -17,15 +17,18 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { FileBrowser } from "@/components/files/file-browser";
|
||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
export default function FilesPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("files");
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
@@ -392,12 +395,25 @@ export default function FilesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{supportsFiles === false ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{!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">
|
||||
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
||||
</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
|
||||
currentPath={currentPath}
|
||||
resources={resources}
|
||||
@@ -442,6 +458,7 @@ export default function FilesPage() {
|
||||
onToggleDetails={handleToggleDetails}
|
||||
detailResource={detailResource}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+85
-13
@@ -11,12 +11,13 @@ import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||
|
||||
const APP_VERSION = "1.4.9";
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||
@@ -24,6 +25,41 @@ const THEME_OPTIONS = [
|
||||
{ value: "system" as const, icon: Monitor, label: "System" },
|
||||
];
|
||||
|
||||
function VersionBadge() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(versionInfo).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex justify-center">
|
||||
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
|
||||
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label="Copy version info"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
@@ -32,13 +68,14 @@ export default function LoginPage() {
|
||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||
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 { 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 [formData, setFormData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [showTotpField, setShowTotpField] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
@@ -73,6 +110,12 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [appName, serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverUrl && !jmapEndpoint) {
|
||||
setJmapEndpoint(serverUrl);
|
||||
}
|
||||
}, [serverUrl, jmapEndpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||
@@ -91,6 +134,14 @@ export default function LoginPage() {
|
||||
prevError.current = 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(() => {
|
||||
if (!serverUrl) return;
|
||||
const saved = localStorage.getItem("webmail_usernames");
|
||||
@@ -264,7 +315,7 @@ export default function LoginPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!serverUrl && !demoMode) {
|
||||
if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
|
||||
return (
|
||||
<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">
|
||||
@@ -362,7 +413,7 @@ export default function LoginPage() {
|
||||
|
||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||
sessionStorage.setItem("oauth_state", state);
|
||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
||||
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
|
||||
if (isAddAccountMode) {
|
||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||
}
|
||||
@@ -382,8 +433,9 @@ export default function LoginPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
|
||||
const success = await login(
|
||||
serverUrl,
|
||||
effectiveServerUrl,
|
||||
formData.username,
|
||||
formData.password,
|
||||
totpCode || undefined,
|
||||
@@ -563,9 +615,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center text-xs text-muted-foreground/40">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
<VersionBadge />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -749,6 +799,27 @@ export default function LoginPage() {
|
||||
/* Login Form */
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<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 */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
||||
@@ -861,7 +932,10 @@ export default function LoginPage() {
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
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")}
|
||||
autoComplete="one-time-code"
|
||||
aria-label={t("totp_label")}
|
||||
@@ -1035,9 +1109,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center text-xs text-muted-foreground/40">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
<VersionBadge />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+165
-39
@@ -19,6 +19,7 @@ import { useUIStore } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
ComposerErrorFallback,
|
||||
} from "@/components/error";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
@@ -37,6 +39,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIdentitySync } from "@/hooks/use-identity-sync";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
@@ -53,6 +56,7 @@ export default function Home() {
|
||||
const t = useTranslations();
|
||||
const tCommon = useTranslations('common');
|
||||
const { appName } = useConfig();
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
@@ -74,6 +78,7 @@ export default function Home() {
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||
const { identities } = useIdentityStore();
|
||||
useIdentitySync();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRateLimited || !rateLimitUntil) {
|
||||
@@ -141,6 +146,102 @@ export default function Home() {
|
||||
fetchEmailContent,
|
||||
} = useEmailStore();
|
||||
|
||||
// Browser back / forward integration. The restore handler reads the
|
||||
// latest values from a ref so we don't have to recreate the callback on
|
||||
// every render (and so the popstate listener is never stale).
|
||||
const navRestoreStateRef = useRef({
|
||||
client,
|
||||
emails,
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
selectedEmailId: selectedEmail?.id ?? null,
|
||||
conversationThreadId: null as string | null,
|
||||
});
|
||||
navRestoreStateRef.current.client = client;
|
||||
navRestoreStateRef.current.emails = emails;
|
||||
navRestoreStateRef.current.mailboxes = mailboxes;
|
||||
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
|
||||
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
|
||||
navRestoreStateRef.current.conversationThreadId = conversationThread?.threadId ?? null;
|
||||
|
||||
const handleNavRestore = useCallback(async (state: NavSnapshot) => {
|
||||
const ctx = navRestoreStateRef.current;
|
||||
|
||||
// Restore sidebar overlay state.
|
||||
setSidebarOpen(state.sidebarOpen);
|
||||
|
||||
// Restore composer visibility.
|
||||
if (!state.composerOpen) {
|
||||
setShowComposer(false);
|
||||
}
|
||||
|
||||
// Derive the mobile view from the saved snapshot. The view is a
|
||||
// function of which content the user is looking at: an email, a
|
||||
// thread, the composer, or the bare list.
|
||||
const derivedView: "list" | "viewer" =
|
||||
state.emailId || state.threadId || state.composerOpen ? "viewer" : "list";
|
||||
setActiveView(derivedView);
|
||||
|
||||
// Restore mailbox selection. selectMailbox clears the current email,
|
||||
// which is fine because we re-apply the saved email below.
|
||||
if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
|
||||
selectMailbox(state.mailboxId);
|
||||
if (ctx.client) {
|
||||
try {
|
||||
await fetchEmails(ctx.client, state.mailboxId);
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch emails on history restore:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore conversation thread (mobile only). We can clear it directly,
|
||||
// but reopening requires the thread group; if the user pressed forward
|
||||
// to return to a thread, we silently skip — back navigation always works.
|
||||
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
||||
if (state.threadId === null) {
|
||||
setConversationThread(null);
|
||||
setConversationEmails([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore email selection.
|
||||
if (state.emailId !== ctx.selectedEmailId) {
|
||||
if (state.emailId === null) {
|
||||
selectEmail(null);
|
||||
} else {
|
||||
// Try the in-memory list first; the existing useEffect will fetch
|
||||
// body content if it's missing.
|
||||
const found = ctx.emails.find(e => e.id === state.emailId);
|
||||
if (found) {
|
||||
selectEmail(found);
|
||||
} else if (ctx.client) {
|
||||
// Email isn't in the current list (e.g. mailbox just changed).
|
||||
// Fetch it directly.
|
||||
try {
|
||||
const mailbox = ctx.mailboxes.find(mb => mb.id === state.mailboxId);
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
const fullEmail = await ctx.client.getEmail(state.emailId, accountId);
|
||||
if (fullEmail) selectEmail(fullEmail);
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch email on history restore:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useBrowserNavigation({
|
||||
mailboxId: selectedMailbox,
|
||||
emailId: selectedEmail?.id ?? null,
|
||||
threadId: conversationThread?.threadId ?? null,
|
||||
composerOpen: showComposer,
|
||||
sidebarOpen,
|
||||
onRestore: handleNavRestore,
|
||||
enabled: isAuthenticated && mailboxes.length > 0,
|
||||
});
|
||||
|
||||
// Keyboard shortcuts handlers
|
||||
const keyboardHandlers = useMemo(() => ({
|
||||
onNextEmail: () => {
|
||||
@@ -350,13 +451,13 @@ export default function Home() {
|
||||
|
||||
if (pushEnabled) {
|
||||
setPushConnected(true);
|
||||
debug.log('[Push] Push notifications successfully enabled');
|
||||
debug.log('push', '[Push] Push notifications successfully enabled');
|
||||
} else {
|
||||
debug.log('[Push] Push notifications not available on this server');
|
||||
debug.log('push', '[Push] Push notifications not available on this server');
|
||||
}
|
||||
} catch (error) {
|
||||
// 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) {
|
||||
console.error('Error loading email data:', error);
|
||||
@@ -390,7 +491,7 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
// Clear any existing timeout when email changes
|
||||
if (markAsReadTimeoutRef.current) {
|
||||
debug.log('[Mark as Read] Clearing previous timeout');
|
||||
debug.log('email', '[Mark as Read] Clearing previous timeout');
|
||||
clearTimeout(markAsReadTimeoutRef.current);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}
|
||||
@@ -402,20 +503,20 @@ export default function Home() {
|
||||
|
||||
// Get current setting value
|
||||
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) {
|
||||
// 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) {
|
||||
// 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);
|
||||
} else {
|
||||
// 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(() => {
|
||||
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);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}, markAsReadDelay);
|
||||
@@ -424,7 +525,7 @@ export default function Home() {
|
||||
// Cleanup on unmount or when dependencies change
|
||||
return () => {
|
||||
if (markAsReadTimeoutRef.current) {
|
||||
debug.log('[Mark as Read] Cleanup - clearing timeout');
|
||||
debug.log('email', '[Mark as Read] Cleanup - clearing timeout');
|
||||
clearTimeout(markAsReadTimeoutRef.current);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}
|
||||
@@ -439,7 +540,7 @@ export default function Home() {
|
||||
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||
playNotificationSound(notificationSoundChoice);
|
||||
}
|
||||
debug.log('New email received:', newEmailNotification.subject);
|
||||
debug.log('email', 'New email received:', newEmailNotification.subject);
|
||||
clearNewEmailNotification();
|
||||
}
|
||||
}, [newEmailNotification, clearNewEmailNotification]);
|
||||
@@ -519,7 +620,10 @@ export default function Home() {
|
||||
|
||||
const handleEditDraft = async (email?: Email) => {
|
||||
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;
|
||||
|
||||
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
|
||||
@@ -967,6 +1071,10 @@ export default function Home() {
|
||||
|
||||
// Get current mailbox name for mobile header
|
||||
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
|
||||
const handleEmailSelect = async (email: { id: string }) => {
|
||||
@@ -1009,14 +1117,24 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle back navigation from viewer on mobile
|
||||
// Handle back navigation from viewer on mobile.
|
||||
// Delegate to the browser history stack so this button is equivalent to
|
||||
// the OS back button / mouse back button — popstate then restores the
|
||||
// previous snapshot via handleNavRestore. The viewer is only reachable
|
||||
// from a state that pushed history, so back() always lands on an app entry.
|
||||
const handleMobileBack = () => {
|
||||
// If in conversation view, clear it
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
if (conversationThread) {
|
||||
setConversationThread(null);
|
||||
setConversationEmails([]);
|
||||
}
|
||||
selectEmail(null);
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
setActiveView("list");
|
||||
};
|
||||
|
||||
@@ -1197,12 +1315,12 @@ export default function Home() {
|
||||
"max-md:flex-1 max-md:border-r-0",
|
||||
isMobile && activeView !== "list" && "max-md:hidden",
|
||||
// Tablet/Desktop: fixed width with collapse animation
|
||||
"md:flex-shrink-0 md:shadow-sm",
|
||||
shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0",
|
||||
"md:shadow-sm",
|
||||
!isResizing && "transition-all duration-200 ease-out",
|
||||
// Tablet: collapse when email selected
|
||||
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||
)}
|
||||
style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined}
|
||||
style={!isMobile && !shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined}
|
||||
>
|
||||
{/* Mobile Header for List View */}
|
||||
<MobileHeader
|
||||
@@ -1487,24 +1605,27 @@ export default function Home() {
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Floating Compose Button (mobile) */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
setActiveView('viewer');
|
||||
}}
|
||||
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
|
||||
aria-label={t('sidebar.compose')}
|
||||
>
|
||||
<PenSquare className="h-6 w-6" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Floating Compose Button */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
}}
|
||||
className={cn(
|
||||
"absolute z-40 rounded-full shadow-lg",
|
||||
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
|
||||
)}
|
||||
aria-label={t('sidebar.compose')}
|
||||
title={t('sidebar.compose_hint')}
|
||||
data-tour="compose-button"
|
||||
>
|
||||
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Email list resize handle (desktop only) */}
|
||||
{!isMobile && !isTablet && (
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||
@@ -1521,7 +1642,8 @@ export default function Home() {
|
||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||
// Tablet/Desktop: relative
|
||||
"md:relative"
|
||||
"md:relative",
|
||||
shouldHideViewerPane && "md:hidden"
|
||||
)}
|
||||
>
|
||||
{/* Inline Composer - shown in viewer pane */}
|
||||
@@ -1537,8 +1659,10 @@ export default function Home() {
|
||||
mode={pendingDraft?.mode ?? composerMode}
|
||||
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
@@ -1636,7 +1760,7 @@ export default function Home() {
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onArchive={() => handleArchive()}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onMarkAsSpam={handleMarkAsSpam}
|
||||
@@ -1648,14 +1772,15 @@ export default function Home() {
|
||||
}}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
onBack={() => {
|
||||
setTabletListVisible(true);
|
||||
selectEmail(null);
|
||||
}}
|
||||
onBack={handleMobileBack}
|
||||
onNavigateNext={handleNavigateNext}
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onEditDraft={handleEditDraft}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
}}
|
||||
currentUserEmail={client?.getUsername()}
|
||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||
@@ -1709,6 +1834,7 @@ export default function Home() {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<TotpReauthDialog />
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
||||
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
|
||||
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
@@ -211,7 +212,7 @@ export default function SettingsPage() {
|
||||
{activeTab === 'encryption' && <SmimeSettings />}
|
||||
{activeTab === 'vacation' && <VacationSettings />}
|
||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{activeTab === 'contacts' && <ContactsSettings />}
|
||||
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{activeTab === 'filters' && <FilterSettings />}
|
||||
{activeTab === 'templates' && <TemplateSettings />}
|
||||
{activeTab === 'folders' && <FolderSettings />}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
@@ -78,13 +79,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
}, [pathname]);
|
||||
|
||||
function getJmapHeaders(): Record<string, string> {
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return {};
|
||||
return {
|
||||
'Authorization': client.getAuthHeader(),
|
||||
'X-JMAP-Server-URL': client.getServerUrl(),
|
||||
'X-JMAP-Username': client.getUsername(),
|
||||
};
|
||||
return getActiveAccountSlotHeaders();
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
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 { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
|
||||
@@ -59,6 +60,15 @@ export default function AdminPluginsPage() {
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleRequirePluginApproval() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSavePolicy() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
@@ -247,6 +257,7 @@ export default function AdminPluginsPage() {
|
||||
|
||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -319,6 +330,17 @@ export default function AdminPluginsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Require Admin Approval</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
|
||||
</div>
|
||||
<button onClick={toggleRequirePluginApproval}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Force enable / disable all */}
|
||||
{plugins.length > 0 && (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
@@ -396,6 +418,13 @@ export default function AdminPluginsPage() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||
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' },
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
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 = [
|
||||
@@ -28,10 +29,12 @@ const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ 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: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||
{ 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: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||
|
||||
@@ -112,6 +112,14 @@ export default function AdminSettingsPage() {
|
||||
<SettingsSection title="General">
|
||||
<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" />
|
||||
<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} />
|
||||
<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} />
|
||||
|
||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/auth
|
||||
* Proxy to Stalwart GET /api/account/auth
|
||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart auth info failed', { status: response.status });
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart auth info failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch auth info', details: text },
|
||||
{ error: detail || 'Failed to fetch auth info' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/crypto
|
||||
* Proxy to Stalwart GET /api/account/crypto
|
||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart crypto info failed', { status: response.status });
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch crypto info', details: text },
|
||||
{ error: detail || 'Failed to fetch crypto info' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
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 { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
@@ -69,10 +70,19 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// If session cookie exists, update it with the new password
|
||||
const cookieStore = await cookies();
|
||||
|
||||
if (creds.hasSessionCookie) {
|
||||
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
|
||||
cookieStore.set(sessionCookieName(creds.slot), 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 });
|
||||
|
||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/principal
|
||||
* Proxy to Stalwart GET /api/principal/{username}
|
||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart principal fetch failed', { status: response.status });
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch principal', details: text },
|
||||
{ error: detail || 'Failed to fetch principal' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
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
|
||||
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as 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');
|
||||
|
||||
// Security warnings (logged but not blocking for admin)
|
||||
// Security: block plugins containing dangerous JS patterns
|
||||
const warnings: string[] = [];
|
||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||
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 plugin: ServerPlugin = {
|
||||
@@ -157,14 +163,17 @@ export async function POST(request: NextRequest) {
|
||||
permissions: (manifest.permissions as string[]) || [],
|
||||
entrypoint: manifest.entrypoint as string,
|
||||
enabled: true,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
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) {
|
||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
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 { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
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 = {
|
||||
...getCookieOptions(),
|
||||
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
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();
|
||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||
setStalwartAuthContextInStore(cookieStore, 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('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
@@ -56,9 +72,66 @@ export async function GET(request: NextRequest) {
|
||||
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')}`,
|
||||
});
|
||||
|
||||
// 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, {
|
||||
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)
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
cookieStore.delete(sessionCookieName(i));
|
||||
clearStalwartAuthContextInStore(cookieStore, i);
|
||||
}
|
||||
} else {
|
||||
const slot = getSlot(request);
|
||||
cookieStore.delete(sessionCookieName(slot));
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
}
|
||||
|
||||
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', ''),
|
||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
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';
|
||||
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
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;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
|
||||
// Block private/internal hostnames
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
hostname.endsWith('.localhost') ||
|
||||
hostname.endsWith('.local') ||
|
||||
hostname.endsWith('.internal') ||
|
||||
hostname.endsWith('.arpa') ||
|
||||
hostname.startsWith('10.') ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('169.254.') ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
||||
hostname.endsWith('.localdomain')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
|
||||
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) {
|
||||
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -64,20 +106,43 @@ export async function POST(request: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
if (!(await isValidExternalUrl(currentUrl))) {
|
||||
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);
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response || !response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Remote server returned ${response.status}` },
|
||||
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
+22
-11
@@ -3,6 +3,7 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
@@ -50,30 +51,40 @@ function isEnabled(): boolean {
|
||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
||||
}
|
||||
|
||||
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify identity against session cookies across all account slots.
|
||||
* With multi-account, the requesting account may be on any slot (0-4).
|
||||
* Returns true if any slot matches OR if no session cookies exist at all.
|
||||
* Checks both basic-auth session cookies and stalwart auth context cookies
|
||||
* (used by OAuth/SSO and TOTP-upgraded sessions).
|
||||
* Returns true only if a matching cookie is found.
|
||||
*/
|
||||
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
let hasAnyCookie = false;
|
||||
const normalizedServerUrl = normalizeUrl(serverUrl);
|
||||
|
||||
for (let slot = 0; slot <= 4; slot++) {
|
||||
// Check basic-auth session cookie
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (!token) continue;
|
||||
hasAnyCookie = true;
|
||||
if (token) {
|
||||
const session = decryptSession(token);
|
||||
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const session = decryptSession(token);
|
||||
if (session && session.username === username && session.serverUrl === serverUrl) {
|
||||
return true; // Found a matching slot
|
||||
// Check stalwart auth context cookie (set for all auth modes)
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// No cookies at all → can't verify, allow (same-origin protection applies)
|
||||
if (!hasAnyCookie) return true;
|
||||
|
||||
// Cookies exist but none matched → identity mismatch
|
||||
// No matching session found (or no cookies at all) → reject
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+42
-12
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
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
|
||||
* 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 cleanPath = davPath.replace(/^\/+/, '');
|
||||
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
||||
const targetUrl = cleanPath
|
||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
|
||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
||||
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
||||
|
||||
// Build headers for the upstream request
|
||||
const upstreamHeaders: Record<string, string> = {
|
||||
@@ -50,19 +73,20 @@ export async function POST(request: NextRequest) {
|
||||
// For MOVE/COPY, construct the full Destination URL from the relative path
|
||||
const destination = request.headers.get('X-WebDAV-Destination');
|
||||
if (destination) {
|
||||
const cleanDest = destination.replace(/^\/+/, '');
|
||||
upstreamHeaders['Destination'] = cleanDest
|
||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
|
||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
||||
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
|
||||
}
|
||||
|
||||
const overwrite = request.headers.get('Overwrite');
|
||||
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
|
||||
|
||||
// Forward request body for methods that need it
|
||||
let body: ArrayBuffer | null = null;
|
||||
if (method === 'PROPFIND' || method === 'PUT') {
|
||||
// Forward request body for methods that need it.
|
||||
// PUT streams directly to upstream to avoid buffering large uploads in memory.
|
||||
// PROPFIND bodies are small XML and are read fully.
|
||||
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
|
||||
if (method === 'PROPFIND') {
|
||||
body = await request.arrayBuffer();
|
||||
} else if (method === 'PUT') {
|
||||
body = request.body;
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
@@ -70,7 +94,9 @@ export async function POST(request: NextRequest) {
|
||||
headers: upstreamHeaders,
|
||||
body,
|
||||
redirect: 'follow',
|
||||
});
|
||||
// `duplex: 'half'` is required by undici when sending a streaming request body.
|
||||
...(method === 'PUT' ? { duplex: 'half' } : {}),
|
||||
} as Parameters<typeof fetch>[1] & { duplex?: 'half' });
|
||||
|
||||
// For file downloads (GET), stream the response back
|
||||
if (method === 'GET') {
|
||||
@@ -104,6 +130,10 @@ export async function POST(request: NextRequest) {
|
||||
status: response.status,
|
||||
});
|
||||
} 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' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -20,6 +22,15 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||
};
|
||||
}
|
||||
@@ -36,6 +47,14 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-title"
|
||||
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
{parentOrigin && (
|
||||
<meta name="parent-origin" content={parentOrigin} />
|
||||
)}
|
||||
@@ -63,7 +82,9 @@ export default async function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
<PWAInstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useMemo, useRef, useEffect, useCallback } from "react";
|
||||
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 { cn } from "@/lib/utils";
|
||||
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 type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
|
||||
@@ -49,7 +49,7 @@ export function CalendarAgendaView({
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const sorted = [...events].sort((a, b) =>
|
||||
parseISO(a.start).getTime() - parseISO(b.start).getTime()
|
||||
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
|
||||
);
|
||||
|
||||
const groups: DayGroup[] = [];
|
||||
@@ -144,9 +144,9 @@ export function CalendarAgendaView({
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||
const color = getEventColor(ev, calendar);
|
||||
const start = parseISO(ev.start);
|
||||
const start = getEventStartDate(ev);
|
||||
const durMin = parseDuration(ev.duration);
|
||||
const end = new Date(start.getTime() + durMin * 60000);
|
||||
const end = getEventEndDate(ev);
|
||||
const locationName = ev.locations
|
||||
? Object.values(ev.locations)[0]?.name
|
||||
: null;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { EventCard } from "./event-card";
|
||||
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 { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
@@ -66,7 +66,7 @@ export function CalendarDayView({
|
||||
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
||||
if (!spansThisDay) return;
|
||||
|
||||
if (ev.showWithoutTime) allDay.push(ev);
|
||||
if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
|
||||
else timed.push(ev);
|
||||
} catch { /* skip invalid dates */ }
|
||||
});
|
||||
@@ -127,10 +127,10 @@ export function CalendarDayView({
|
||||
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 (
|
||||
<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")}>
|
||||
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
||||
{isMobile
|
||||
@@ -200,7 +200,7 @@ export function CalendarDayView({
|
||||
</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={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
||||
{HOURS.map((h) => (
|
||||
@@ -241,11 +241,9 @@ export function CalendarDayView({
|
||||
/>
|
||||
))}
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
||||
const start = parseISO(ev.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Globe, ListTodo, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
@@ -18,6 +19,7 @@ interface CalendarSidebarPanelProps {
|
||||
onToggleVisibility: (id: string) => void;
|
||||
onColorChange?: (calendarId: string, color: string) => void;
|
||||
onSubscribe?: () => void;
|
||||
onEditSubscription?: (subscriptionId: string) => void;
|
||||
client?: IJMAPClient | null;
|
||||
}
|
||||
|
||||
@@ -27,6 +29,7 @@ export function CalendarSidebarPanel({
|
||||
onToggleVisibility,
|
||||
onColorChange,
|
||||
onSubscribe,
|
||||
onEditSubscription,
|
||||
client,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
const t = useTranslations("calendar");
|
||||
@@ -162,6 +165,9 @@ export function CalendarSidebarPanel({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{cal.id === BIRTHDAY_CALENDAR_ID && (
|
||||
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Subscription context menu on right-click */}
|
||||
@@ -173,6 +179,16 @@ export function CalendarSidebarPanel({
|
||||
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"
|
||||
>
|
||||
<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
|
||||
onClick={() => handleRefreshSubscription(sub.id)}
|
||||
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";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { EventCard } from "./event-card";
|
||||
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 { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
@@ -77,7 +77,7 @@ export function CalendarWeekView({
|
||||
const cursor = new Date(startDay);
|
||||
while (cursor <= endDay) {
|
||||
const key = format(cursor, "yyyy-MM-dd");
|
||||
if (!ev.showWithoutTime) {
|
||||
if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
|
||||
const arr = timed.get(key) || [];
|
||||
arr.push(ev);
|
||||
timed.set(key, arr);
|
||||
@@ -89,10 +89,18 @@ export function CalendarWeekView({
|
||||
return timed;
|
||||
}, [events]);
|
||||
|
||||
const allDaySegments = useMemo(() => buildWeekSegments(
|
||||
events.filter((event) => event.showWithoutTime),
|
||||
weekDays,
|
||||
), [events, weekDays]);
|
||||
const allDaySegments = useMemo(() => {
|
||||
const explicitAllDay = buildWeekSegmentsRaw(
|
||||
events.filter((event) => event.showWithoutTime),
|
||||
weekDays,
|
||||
);
|
||||
const timedFullDay = buildTimedFullDayWeekSegments(
|
||||
events.filter((event) => !event.showWithoutTime),
|
||||
weekDays,
|
||||
);
|
||||
|
||||
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
|
||||
}, [events, weekDays]);
|
||||
|
||||
const allDayRowCount = useMemo(() => {
|
||||
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||
@@ -189,13 +197,13 @@ export function CalendarWeekView({
|
||||
<div
|
||||
ref={rootRef}
|
||||
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"
|
||||
)}
|
||||
role="grid"
|
||||
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={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 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={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
|
||||
{HOURS.map((h) => (
|
||||
@@ -344,7 +352,7 @@ export function CalendarWeekView({
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvents = timedEvents.get(key) || [];
|
||||
const todayCol = isToday(day);
|
||||
const layouted = layoutOverlappingEvents(dayEvents);
|
||||
const layouted = layoutOverlappingEvents(dayEvents, day);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -371,11 +379,9 @@ export function CalendarWeekView({
|
||||
/>
|
||||
))}
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
||||
const start = parseISO(ev.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 { getParticipantCount } from "@/lib/calendar-participants";
|
||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface EventCardProps {
|
||||
@@ -68,14 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
const t = useTranslations("calendar");
|
||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||
const color = getEventColor(event, calendar);
|
||||
const startDate = parseISO(event.start);
|
||||
const startDate = getEventStartDate(event);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
|
||||
const calendarName = calendar?.name || "";
|
||||
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 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 type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -87,7 +88,7 @@ function formatDurationDisplay(minutes: number): string {
|
||||
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||
if (!event.alerts) return null;
|
||||
const first = Object.values(event.alerts)[0];
|
||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||
if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||
const offset = first.trigger.offset;
|
||||
if (offset === "PT0S") return t("alerts.at_time");
|
||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
||||
@@ -138,9 +139,9 @@ export function EventDetailPopover({
|
||||
const [isSavingNote, setIsSavingNote] = useState(false);
|
||||
|
||||
const color = getEventColor(event, calendar);
|
||||
const startDate = parseISO(event.start);
|
||||
const startDate = getEventStartDate(event);
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||
const endDate = getEventEndDate(event);
|
||||
|
||||
const locationName = useMemo(() => {
|
||||
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 type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
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 {
|
||||
isOrganizer,
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
getStatusCounts,
|
||||
buildParticipantMap,
|
||||
} from "@/lib/calendar-participants";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -159,7 +161,7 @@ export function EventModal({
|
||||
}, [event, existingParticipants]);
|
||||
|
||||
const getInitialStart = (): Date => {
|
||||
if (event?.start) return parseISO(event.start);
|
||||
if (event?.start) return getEventStartDate(event);
|
||||
if (defaultDate) {
|
||||
const d = new Date(defaultDate);
|
||||
if (defaultEndDate) return d;
|
||||
@@ -177,9 +179,7 @@ export function EventModal({
|
||||
if (event.showWithoutTime) {
|
||||
return getEventDisplayEndDate(event);
|
||||
}
|
||||
const s = parseISO(event.start);
|
||||
const dur = parseDuration(event.duration);
|
||||
return new Date(s.getTime() + dur * 60000);
|
||||
return getEventEndDate(event);
|
||||
}
|
||||
if (defaultEndDate) return new Date(defaultEndDate);
|
||||
return addHours(getInitialStart(), 1);
|
||||
@@ -190,6 +190,9 @@ export function EventModal({
|
||||
const [location, setLocation] = useState(
|
||||
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 [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||
@@ -315,6 +318,20 @@ export function EventModal({
|
||||
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") {
|
||||
data.recurrenceRules = [{
|
||||
"@type": "RecurrenceRule",
|
||||
@@ -374,7 +391,7 @@ export function EventModal({
|
||||
} finally {
|
||||
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']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -384,11 +401,9 @@ export function EventModal({
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!event || !onDuplicate) return;
|
||||
const start = parseISO(event.start);
|
||||
const start = getEventStartDate(event);
|
||||
const newStart = addDays(start, 1);
|
||||
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
const newUid = generateUUID();
|
||||
const data: Partial<CalendarEvent> = {
|
||||
uid: newUid,
|
||||
title: event.title,
|
||||
@@ -403,6 +418,7 @@ export function EventModal({
|
||||
privacy: event.privacy,
|
||||
};
|
||||
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.alerts) data.alerts = structuredClone(event.alerts);
|
||||
if (event.participants) data.participants = structuredClone(event.participants);
|
||||
@@ -456,9 +472,9 @@ export function EventModal({
|
||||
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
||||
|
||||
if (isAttendeeMode && event) {
|
||||
const startD = parseISO(event.start);
|
||||
const startD = getEventStartDate(event);
|
||||
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 participants = getParticipantList(event);
|
||||
|
||||
@@ -567,9 +583,9 @@ export function EventModal({
|
||||
|
||||
// View mode: read-only display of event details with Edit button
|
||||
if (mode === "view" && event) {
|
||||
const startD = parseISO(event.start);
|
||||
const startD = getEventStartDate(event);
|
||||
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 virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||
const viewParticipants = getParticipantList(event);
|
||||
@@ -780,6 +796,39 @@ export function EventModal({
|
||||
/>
|
||||
</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>
|
||||
<label className="text-sm font-medium mb-1 block">
|
||||
<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 type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -17,7 +18,7 @@ interface ICalImportModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||
|
||||
type ImportStep = "select" | "preview" | "importing";
|
||||
@@ -195,7 +196,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
||||
if (!event.start) return "";
|
||||
try {
|
||||
const date = parseISO(event.start);
|
||||
const date = getEventStartDate(event as CalendarEvent);
|
||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
return event.showWithoutTime
|
||||
? format(date, "MMM d, yyyy")
|
||||
|
||||
@@ -5,24 +5,28 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Loader2, Globe } from "lucide-react";
|
||||
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 { toast } from "@/stores/toast-store";
|
||||
|
||||
interface ICalSubscriptionModalProps {
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
editSubscription?: ICalSubscription;
|
||||
}
|
||||
|
||||
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
|
||||
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
|
||||
const t = useTranslations("calendar.subscription");
|
||||
const tCommon = useTranslations("common");
|
||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState("#3b82f6");
|
||||
const [refreshInterval, setRefreshInterval] = useState(60);
|
||||
const isEdit = !!editSubscription;
|
||||
|
||||
const [url, setUrl] = useState(editSubscription?.url || "");
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
||||
if (subscription) {
|
||||
toast.success(t("success", { name: name.trim() }));
|
||||
if (isEdit && editSubscription) {
|
||||
const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
|
||||
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();
|
||||
} 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 {
|
||||
setError(t("error"));
|
||||
setError(isEdit ? t("update_error") : t("error"));
|
||||
} finally {
|
||||
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 gap-2">
|
||||
<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>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
||||
isSameDay, isSameMonth, isToday, format, parseISO,
|
||||
isSameDay, isSameMonth, isToday, format,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
type PickerView = "days" | "months" | "years";
|
||||
@@ -54,7 +55,14 @@ export function MiniCalendar({
|
||||
const eventDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
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;
|
||||
}, [events]);
|
||||
|
||||
@@ -304,11 +304,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
if (contact?.addressBookIds) {
|
||||
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
|
||||
if (ids.length > 0) {
|
||||
// For shared contacts, the addressBookIds uses the original (non-namespaced) id
|
||||
// but we need the namespaced id to match addressBooks entries
|
||||
if (contact.isShared && contact.accountId) {
|
||||
return `${contact.accountId}:${ids[0]}`;
|
||||
}
|
||||
// addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
|
||||
// so we can use them directly to match addressBook entries
|
||||
return ids[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
|
||||
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
|
||||
onDeleteGroup?: (groupId: string) => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||
onRenameKeyword?: (keyword: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -58,10 +61,15 @@ export function ContactsSidebar({
|
||||
onDeleteGroup,
|
||||
onDropContacts,
|
||||
onDropContactsToCategory,
|
||||
onRenameAddressBook,
|
||||
onRenameKeyword,
|
||||
className,
|
||||
}: ContactsSidebarProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const router = useRouter();
|
||||
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
|
||||
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
|
||||
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
@@ -246,19 +254,32 @@ export function ContactsSidebar({
|
||||
{/* My Address Books */}
|
||||
{personalBooks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleSection("addressBooks")}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
>
|
||||
{collapsed.addressBooks ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t("address_books.title")}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection("addressBooks")}
|
||||
className="flex items-center gap-1 flex-1 text-left"
|
||||
>
|
||||
{collapsed.addressBooks ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t("address_books.title")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||
router.push('/settings');
|
||||
}}
|
||||
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||
title={t("address_books.manage")}
|
||||
>
|
||||
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||
<AddressBookItem
|
||||
key={book.id}
|
||||
@@ -267,6 +288,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -362,6 +384,7 @@ export function ContactsSidebar({
|
||||
isActive={isActive}
|
||||
onSelect={() => onSelectCategory({ keyword })}
|
||||
onDropContacts={onDropContactsToCategory}
|
||||
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -372,20 +395,33 @@ export function ContactsSidebar({
|
||||
{/* Shared accounts with address books */}
|
||||
{sharedBookGroups.map((group) => (
|
||||
<div key={group.accountId} className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
>
|
||||
{collapsed[`shared-${group.accountId}`] ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<Share2 className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
||||
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
||||
>
|
||||
{collapsed[`shared-${group.accountId}`] ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<Share2 className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
||||
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||
router.push('/settings');
|
||||
}}
|
||||
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||
title={t("address_books.manage")}
|
||||
>
|
||||
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
||||
<AddressBookItem
|
||||
key={book.id}
|
||||
@@ -394,12 +430,53 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Address book context menu */}
|
||||
{bookContextMenu.data && onRenameAddressBook && (
|
||||
<ContextMenu
|
||||
ref={bookMenuRef}
|
||||
isOpen={bookContextMenu.isOpen}
|
||||
position={bookContextMenu.position}
|
||||
onClose={closeBookContextMenu}
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("address_books.rename")}
|
||||
onClick={() => {
|
||||
const book = bookContextMenu.data!;
|
||||
closeBookContextMenu();
|
||||
onRenameAddressBook(book);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{/* Keyword (category) context menu */}
|
||||
{keywordContextMenu.data && onRenameKeyword && (
|
||||
<ContextMenu
|
||||
ref={keywordMenuRef}
|
||||
isOpen={keywordContextMenu.isOpen}
|
||||
position={keywordContextMenu.position}
|
||||
onClose={closeKeywordContextMenu}
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("rename_category")}
|
||||
onClick={() => {
|
||||
const kw = keywordContextMenu.data!;
|
||||
closeKeywordContextMenu();
|
||||
onRenameKeyword(kw);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{/* Group context menu */}
|
||||
{groupContextMenu.data && (
|
||||
<ContextMenu
|
||||
@@ -438,12 +515,14 @@ function CategoryItem({
|
||||
isActive,
|
||||
onSelect,
|
||||
onDropContacts,
|
||||
onContextMenu,
|
||||
}: {
|
||||
keyword: string;
|
||||
count: number;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onDropContacts?: (contactIds: string[], keyword: string) => void;
|
||||
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -476,6 +555,7 @@ function CategoryItem({
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -503,12 +583,14 @@ function AddressBookItem({
|
||||
contactCount,
|
||||
onSelect,
|
||||
onDropContacts,
|
||||
onContextMenu,
|
||||
}: {
|
||||
book: AddressBook;
|
||||
isActive: boolean;
|
||||
contactCount: number;
|
||||
onSelect: () => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -541,6 +623,7 @@ function AddressBookItem({
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
|
||||
render(<EmailListItem email={email} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -754,49 +754,67 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
{/* Event title */}
|
||||
{summary?.title && (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className={cn(
|
||||
"text-base font-semibold leading-snug",
|
||||
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
|
||||
)}>
|
||||
{summary.title}
|
||||
</h3>
|
||||
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
|
||||
{t('event_updated', { sequence: parsedEvent.sequence })}
|
||||
</span>
|
||||
<div className="lg:flex lg:gap-6">
|
||||
{/* Left: Event info */}
|
||||
<div className="lg:flex-1 space-y-2.5 min-w-0">
|
||||
{/* Event title */}
|
||||
{summary?.title && (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className={cn(
|
||||
"text-base font-semibold leading-snug",
|
||||
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
|
||||
)}>
|
||||
{summary.title}
|
||||
</h3>
|
||||
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
|
||||
{t('event_updated', { sequence: parsedEvent.sequence })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event details */}
|
||||
<div className="space-y-1">
|
||||
{summary?.start && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
{formatDateTime(summary.start)}
|
||||
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
||||
</span>
|
||||
{/* Event details */}
|
||||
<div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
|
||||
{summary?.start && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
{formatDateTime(summary.start)}
|
||||
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{summary?.location && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{summary.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{summary?.organizer && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Users className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{t('organizer', { name: summary.organizer })}
|
||||
</span>
|
||||
)}
|
||||
{summary && summary.attendeeCount > 0 && (
|
||||
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{summary?.location && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{summary.location}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
||||
{summary?.organizer && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{t('organizer', { name: summary.organizer })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Info & actor messages on large screens */}
|
||||
<div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
|
||||
{bannerInfo && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
|
||||
)}
|
||||
{summary && summary.attendeeCount > 0 && (
|
||||
<span>{t('attendees', { count: summary.attendeeCount })}</span>
|
||||
{actorMessage && (
|
||||
<p className="text-xs text-muted-foreground">{actorMessage}</p>
|
||||
)}
|
||||
{actorSummary?.participationComment && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t('actor_note', { comment: actorSummary.participationComment })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -840,23 +858,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info text */}
|
||||
{bannerInfo && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
|
||||
)}
|
||||
|
||||
{/* Actor message */}
|
||||
{actorMessage && (
|
||||
<p className="text-xs text-muted-foreground">{actorMessage}</p>
|
||||
)}
|
||||
|
||||
{/* Actor comment */}
|
||||
{actorSummary?.participationComment && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t('actor_note', { comment: actorSummary.participationComment })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Proposed changes */}
|
||||
{proposedChanges.length > 0 && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
|
||||
|
||||
@@ -29,13 +29,13 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
|
||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||
function htmlToPlainText(html: string): string {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
return doc.body.textContent || '';
|
||||
}
|
||||
|
||||
export interface ComposerDraftData {
|
||||
@@ -76,8 +76,10 @@ interface EmailComposerProps {
|
||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
replyTo?: {
|
||||
from?: { email?: string; name?: string }[];
|
||||
replyToAddresses?: { email?: string; name?: string }[];
|
||||
to?: { email?: string; name?: string }[];
|
||||
cc?: { email?: string; name?: string }[];
|
||||
bcc?: { email?: string; name?: string }[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
htmlBody?: string;
|
||||
@@ -100,17 +102,20 @@ export function EmailComposer({
|
||||
const tCommon = useTranslations('common');
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = () => {
|
||||
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') {
|
||||
const email = replyTo.from?.[0]?.email || "";
|
||||
return email ? email + ', ' : "";
|
||||
return replyTarget ? replyTarget + ', ' : "";
|
||||
} else if (mode === 'replyAll') {
|
||||
const from = replyTo.from?.[0]?.email || "";
|
||||
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 "";
|
||||
@@ -226,6 +231,31 @@ export function EmailComposer({
|
||||
const currentIdentity = selectedIdentityId
|
||||
? identities.find((identity) => identity.id === selectedIdentityId) || 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
|
||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||
: currentIdentity?.textSignature
|
||||
@@ -301,6 +331,17 @@ export function EmailComposer({
|
||||
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 [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||
@@ -308,10 +349,26 @@ export function EmailComposer({
|
||||
const toInputRef = useRef<HTMLInputElement>(null);
|
||||
const ccInputRef = 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 ccDropdownRef = 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') => {
|
||||
if (autocompleteTimeoutRef.current) {
|
||||
clearTimeout(autocompleteTimeoutRef.current);
|
||||
@@ -475,17 +532,18 @@ export function EmailComposer({
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
|
||||
if (!client) return null;
|
||||
try {
|
||||
const { blobId } = await client.uploadBlob(file);
|
||||
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
|
||||
} catch (error) {
|
||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
return null;
|
||||
}
|
||||
}, [client, t]);
|
||||
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||
reader.onerror = () => {
|
||||
debug.error(`Failed to read inline image ${file.name}`);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
resolve(null);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return;
|
||||
@@ -1040,6 +1098,7 @@ export function EmailComposer({
|
||||
onInsertAutocomplete={insertAutocomplete}
|
||||
validationError={validationErrors.to}
|
||||
validationMessage={t('validation.recipient_required')}
|
||||
onTab={focusSubject}
|
||||
/>
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
<Button
|
||||
@@ -1109,6 +1168,7 @@ export function EmailComposer({
|
||||
<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>
|
||||
<Input
|
||||
ref={subjectInputRef}
|
||||
type="text"
|
||||
placeholder={t('subject_placeholder')}
|
||||
value={subject}
|
||||
@@ -1116,6 +1176,12 @@ export function EmailComposer({
|
||||
setSubject(e.target.value);
|
||||
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Tab' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
focusBody();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"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"
|
||||
@@ -1128,6 +1194,7 @@ export function EmailComposer({
|
||||
{/* Body */}
|
||||
{plainTextMode ? (
|
||||
<textarea
|
||||
ref={bodyRef}
|
||||
value={body}
|
||||
onChange={(e) => {
|
||||
setBody(e.target.value);
|
||||
@@ -1142,16 +1209,18 @@ export function EmailComposer({
|
||||
aria-invalid={validationErrors.body || undefined}
|
||||
/>
|
||||
) : (
|
||||
<RichTextEditor
|
||||
content={body}
|
||||
onChange={(html) => {
|
||||
setBody(html);
|
||||
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||
}}
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
/>
|
||||
<div ref={editorContainerRef}>
|
||||
<RichTextEditor
|
||||
content={body}
|
||||
onChange={(html) => {
|
||||
setBody(html);
|
||||
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||
}}
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plainTextMode ? (
|
||||
@@ -1483,6 +1552,7 @@ function RecipientChipInput({
|
||||
onInsertAutocomplete,
|
||||
validationError,
|
||||
validationMessage,
|
||||
onTab,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -1499,6 +1569,7 @@ function RecipientChipInput({
|
||||
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
|
||||
validationError?: boolean;
|
||||
validationMessage?: string;
|
||||
onTab?: () => void;
|
||||
}) {
|
||||
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
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 !== 'Tab') e.preventDefault();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
interface EmailHoverActionsProps {
|
||||
email: Email;
|
||||
backgroundClassName?: string;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
@@ -63,6 +64,7 @@ const CORNER_CLASSES = {
|
||||
|
||||
export function EmailHoverActions({
|
||||
email,
|
||||
backgroundClassName = "bg-muted",
|
||||
onToggleStar,
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
@@ -77,6 +79,7 @@ export function EmailHoverActions({
|
||||
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const hoverBackgroundClassName = backgroundClassName;
|
||||
|
||||
if (hoverActions.length === 0) return null;
|
||||
|
||||
@@ -91,7 +94,7 @@ export function EmailHoverActions({
|
||||
onToggleStar?.();
|
||||
break;
|
||||
case "markRead":
|
||||
onMarkAsRead?.(!isUnread);
|
||||
onMarkAsRead?.(isUnread);
|
||||
break;
|
||||
case "archive":
|
||||
onArchive?.();
|
||||
@@ -144,7 +147,10 @@ export function EmailHoverActions({
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,8 +161,14 @@ export function EmailHoverActions({
|
||||
<div
|
||||
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 className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,9 +32,10 @@ interface EmailListItemProps {
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { identities } = useAuthStore();
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
@@ -43,7 +44,12 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const sender = email.from?.[0];
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
|
||||
// Resolve color tag using keyword definitions from settings
|
||||
const colorTagId = getEmailColorTag(email.keywords);
|
||||
@@ -114,15 +120,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
}
|
||||
}}
|
||||
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 */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleCheckboxClick}
|
||||
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",
|
||||
"active:scale-95",
|
||||
"animate-in fade-in zoom-in-95 duration-150",
|
||||
@@ -145,7 +155,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
{density !== 'extra-compact' && (
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
@@ -156,85 +166,131 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{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
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</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" />
|
||||
)}
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</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" />}
|
||||
{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 && (
|
||||
<>
|
||||
<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" />
|
||||
)}
|
||||
{email.hasAttachment && <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 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",
|
||||
) : (
|
||||
<>
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{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
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{email.subject || t('no_subject')}
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
@@ -242,6 +298,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
|
||||
@@ -103,11 +103,16 @@ export function EmailList({
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
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];
|
||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||
}, [density, showPreview]);
|
||||
}, [density, isFocusedMailLayout, showPreview]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: threadGroups.length,
|
||||
@@ -216,15 +221,26 @@ export function EmailList({
|
||||
}
|
||||
}, [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 lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
||||
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastVirtualItemIndex === undefined) return;
|
||||
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]);
|
||||
|
||||
// Scroll to the thread group containing the selected email
|
||||
@@ -244,7 +260,7 @@ export function EmailList({
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [density, showPreview]);
|
||||
}, [density, isFocusedMailLayout, showPreview]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col min-h-0", className)}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/ema
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime } from "@/lib/utils";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||
import {
|
||||
Reply,
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
HelpCircle,
|
||||
EditIcon,
|
||||
PlayCircle,
|
||||
PenSquare,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
@@ -121,6 +122,7 @@ interface EmailViewerProps {
|
||||
onNavigatePrev?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCompose?: () => void;
|
||||
currentUserEmail?: string;
|
||||
currentUserName?: string;
|
||||
currentMailboxRole?: string;
|
||||
@@ -865,6 +867,7 @@ export function EmailViewer({
|
||||
onNavigatePrev,
|
||||
onShowShortcuts,
|
||||
onEditDraft,
|
||||
onCompose,
|
||||
currentUserEmail,
|
||||
currentUserName,
|
||||
currentMailboxRole,
|
||||
@@ -887,8 +890,10 @@ export function EmailViewer({
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
// Detect if current mailbox is Junk folder
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
@@ -906,7 +911,7 @@ export function EmailViewer({
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode } = useAuthStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const { startTour } = useTour();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
@@ -960,9 +965,9 @@ export function EmailViewer({
|
||||
|
||||
// Ensure S/MIME key records are loaded from IndexedDB
|
||||
useLayoutEffect(() => {
|
||||
smimeStore.load();
|
||||
smimeStore.load(activeAccountId ?? undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [activeAccountId]);
|
||||
|
||||
// Build mailbox tree for move-to dropdown
|
||||
const moveTargetIds = useMemo(() => new Set(
|
||||
@@ -1147,9 +1152,27 @@ export function EmailViewer({
|
||||
}
|
||||
);
|
||||
|
||||
const autoMarkedEmailRef = useRef<string | null>(null);
|
||||
|
||||
// Reset auto-mark tracking when email changes
|
||||
useEffect(() => {
|
||||
autoMarkedEmailRef.current = null;
|
||||
}, [email?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Mark as read when email is viewed, respecting the delay setting
|
||||
if (!email || email.keywords?.$seen || !onMarkAsRead) {
|
||||
if (!email || !onMarkAsRead) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already read — record that so manual unread toggle won't re-trigger auto-mark
|
||||
if (email.keywords?.$seen) {
|
||||
autoMarkedEmailRef.current = email.id;
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't re-trigger if we already auto-marked this email (user may have toggled it back to unread)
|
||||
if (autoMarkedEmailRef.current === email.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1162,17 +1185,18 @@ export function EmailViewer({
|
||||
|
||||
// Instant mark
|
||||
if (markAsReadDelay === 0) {
|
||||
autoMarkedEmailRef.current = email.id;
|
||||
onMarkAsRead(email.id, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delayed mark
|
||||
const timeout = setTimeout(() => {
|
||||
autoMarkedEmailRef.current = email.id;
|
||||
onMarkAsRead(email.id, true);
|
||||
}, markAsReadDelay);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger
|
||||
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
|
||||
|
||||
// Reset external content permission and quick reply when email changes
|
||||
@@ -1540,8 +1564,25 @@ export function EmailViewer({
|
||||
|
||||
if (detection.type === 'enveloped-data') {
|
||||
// Encrypted message
|
||||
const { keyRecords, unlockedDecryptionKeys } = smimeStore;
|
||||
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
|
||||
const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
|
||||
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 {
|
||||
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
||||
let lastError: unknown = null;
|
||||
@@ -1552,6 +1593,7 @@ export function EmailViewer({
|
||||
cmsBytes: candidate.normalized,
|
||||
keyRecords,
|
||||
unlockedKeys: unlockedDecryptionKeys,
|
||||
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
|
||||
});
|
||||
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
||||
source: candidate.source,
|
||||
@@ -1633,8 +1675,15 @@ export function EmailViewer({
|
||||
if (!existing) {
|
||||
try {
|
||||
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) {
|
||||
smimeError('[S/MIME] nested signature verify failed:', {
|
||||
@@ -1672,10 +1721,12 @@ export function EmailViewer({
|
||||
decryptionError: 'locked',
|
||||
});
|
||||
} else {
|
||||
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
|
||||
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
|
||||
setSmimeStatus({
|
||||
isSigned: false,
|
||||
isEncrypted: true,
|
||||
decryptionError: err instanceof Error ? err.message : 'Decryption failed',
|
||||
decryptionError: isNoKeyError ? 'no-key' : errMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1737,8 +1788,15 @@ export function EmailViewer({
|
||||
if (!existing) {
|
||||
try {
|
||||
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) {
|
||||
if (cancelled) return;
|
||||
@@ -1771,6 +1829,7 @@ export function EmailViewer({
|
||||
smimeStore.autoImportSignerCerts,
|
||||
smimeStore.keyRecords,
|
||||
smimeStore.unlockedDecryptionKeys,
|
||||
smimeStore.unlockedLegacyDecryptionKeys,
|
||||
smimeStore,
|
||||
]);
|
||||
|
||||
@@ -1780,12 +1839,12 @@ export function EmailViewer({
|
||||
|
||||
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
||||
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;
|
||||
}
|
||||
|
||||
debug.group('TNEF Processing');
|
||||
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
||||
debug.group('TNEF Processing', 'email');
|
||||
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
|
||||
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||
@@ -1795,46 +1854,46 @@ export function EmailViewer({
|
||||
let hasRealHtmlBody = !!htmlValue;
|
||||
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
|
||||
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) {
|
||||
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 {
|
||||
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;
|
||||
|
||||
async function processTnef() {
|
||||
try {
|
||||
debug.time('TNEF fetch blob');
|
||||
debug.time('TNEF fetch blob', 'email');
|
||||
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
||||
debug.timeEnd('TNEF fetch blob');
|
||||
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||
debug.timeEnd('TNEF fetch blob', 'email');
|
||||
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||
|
||||
if (cancelled) {
|
||||
debug.log('TNEF: Processing cancelled after fetch');
|
||||
debug.log('email', 'TNEF: Processing cancelled after fetch');
|
||||
debug.groupEnd();
|
||||
return;
|
||||
}
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
const tnefData = new Uint8Array(blobBytes);
|
||||
debug.time('TNEF parse');
|
||||
debug.time('TNEF parse', 'email');
|
||||
const parsed = parseTnef(tnefData);
|
||||
debug.timeEnd('TNEF parse');
|
||||
debug.timeEnd('TNEF parse', 'email');
|
||||
|
||||
if (cancelled) {
|
||||
debug.log('TNEF: Processing cancelled after parse');
|
||||
debug.log('email', 'TNEF: Processing cancelled after parse');
|
||||
debug.groupEnd();
|
||||
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) {
|
||||
setTnefHtml(parsed.htmlBody);
|
||||
@@ -1844,11 +1903,11 @@ export function EmailViewer({
|
||||
}
|
||||
if (parsed.attachments.length > 0) {
|
||||
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) {
|
||||
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();
|
||||
@@ -1886,13 +1945,13 @@ export function EmailViewer({
|
||||
const hasRealText = !!textValue;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
debug.group('Embedded RFC822 Unwrapping');
|
||||
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
||||
debug.log('Outer email body is empty, will unwrap embedded email');
|
||||
debug.group('Embedded RFC822 Unwrapping', 'email');
|
||||
debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
||||
debug.log('email', 'Outer email body is empty, will unwrap embedded email');
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -1901,7 +1960,7 @@ export function EmailViewer({
|
||||
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
||||
if (cancelled) { debug.groupEnd(); return; }
|
||||
if (blobBytes.byteLength === 0) {
|
||||
debug.warn('Embedded RFC822: Fetched blob is empty');
|
||||
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
|
||||
debug.groupEnd();
|
||||
return;
|
||||
}
|
||||
@@ -1911,7 +1970,7 @@ export function EmailViewer({
|
||||
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
||||
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)',
|
||||
', attachments:', parsed.attachments?.length ?? 0);
|
||||
|
||||
@@ -1923,7 +1982,7 @@ export function EmailViewer({
|
||||
}
|
||||
if (parsed.attachments && parsed.attachments.length > 0) {
|
||||
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 + ')'
|
||||
).join(', '));
|
||||
}
|
||||
@@ -2526,14 +2585,21 @@ export function EmailViewer({
|
||||
|
||||
// If email has native dark mode, let it handle its own theming
|
||||
// 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 ? `
|
||||
html { background: #1a1a1a; }
|
||||
body { filter: invert(1) hue-rotate(180deg); }
|
||||
img, video, picture, svg, canvas, object, embed,
|
||||
[style*="background-image"], [style*="background:"],
|
||||
[background], [bgcolor],
|
||||
td[background], table[background],
|
||||
img[src], input[type="image"] {
|
||||
img, video, svg, canvas, object, embed, input[type="image"] {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||
[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);
|
||||
}
|
||||
` : '';
|
||||
@@ -2572,11 +2638,35 @@ export function EmailViewer({
|
||||
a.setAttribute('target', '_blank');
|
||||
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 {
|
||||
// Cross-origin restrictions - iframe will still display content
|
||||
}
|
||||
}, []);
|
||||
}, [isDark, emailHasNativeDarkMode]);
|
||||
|
||||
// Export email as .eml file
|
||||
const handleExportEmail = async () => {
|
||||
@@ -2775,6 +2865,12 @@ export function EmailViewer({
|
||||
</div>
|
||||
<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>
|
||||
{onCompose && (
|
||||
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t('compose')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2790,7 +2886,7 @@ export function EmailViewer({
|
||||
<>
|
||||
{/* Left: Reply actions */}
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -2805,7 +2901,7 @@ export function EmailViewer({
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEditDraft}
|
||||
onClick={() => onEditDraft()}
|
||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('tooltips.edit_draft')}
|
||||
>
|
||||
@@ -3524,7 +3620,7 @@ export function EmailViewer({
|
||||
<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">
|
||||
{/* Back button (for below-subject mode on tablet) */}
|
||||
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && (
|
||||
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -4403,7 +4499,7 @@ export function EmailViewer({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onEditDraft}
|
||||
onClick={() => onEditDraft()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<EditIcon className="w-3.5 h-3.5" />
|
||||
@@ -4430,7 +4526,7 @@ export function EmailViewer({
|
||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
hasCalendarInvitation) && (
|
||||
<div className="border-b border-border bg-muted/30 isolate">
|
||||
<div className="max-w-4xl mx-auto px-6 py-1.5">
|
||||
<div className="max-w-6xl mx-auto px-6 py-1.5">
|
||||
<div className="flex flex-col gap-3 isolate">
|
||||
{/* External Content Controls */}
|
||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||
@@ -4672,7 +4768,7 @@ export function EmailViewer({
|
||||
</button>
|
||||
{isDraft && onEditDraft ? (
|
||||
<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"
|
||||
aria-label={t('tooltips.edit_draft')}
|
||||
>
|
||||
@@ -4797,7 +4893,7 @@ export function EmailViewer({
|
||||
if (client && supportsSync) {
|
||||
createContact(client, contactData).then(() => toast.success('Contact added'));
|
||||
} else {
|
||||
addLocalContact({ id: `local-${crypto.randomUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
||||
addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
||||
toast.success('Contact added');
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on scroll so the popover does not float while content moves.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = () => handleClose();
|
||||
document.addEventListener("scroll", handler, true);
|
||||
window.addEventListener("resize", handler);
|
||||
return () => {
|
||||
document.removeEventListener("scroll", handler, true);
|
||||
window.removeEventListener("resize", handler);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleViewContact = () => {
|
||||
if (onViewContact) {
|
||||
onViewContact(contact ?? null, email);
|
||||
|
||||
@@ -118,6 +118,7 @@ export function RichTextEditor({
|
||||
);
|
||||
if (imageFiles.length === 0) return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
for (const file of imageFiles) {
|
||||
upload(file).then((url) => {
|
||||
if (url) {
|
||||
|
||||
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
||||
text: t('unlock_key_desc'),
|
||||
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 {
|
||||
items.push({
|
||||
icon: <ShieldX className="w-4 h-4" />,
|
||||
@@ -49,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
||||
// Signature status
|
||||
if (status.isSigned) {
|
||||
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({
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
text: t('status_signed_mismatch'),
|
||||
|
||||
@@ -55,11 +55,17 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
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
|
||||
const tagId = getEmailColorTag(email.keywords);
|
||||
@@ -131,15 +137,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}
|
||||
onClick={handleClick}
|
||||
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 */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleCheckboxClick}
|
||||
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",
|
||||
"active:scale-95",
|
||||
"animate-in fade-in zoom-in-95 duration-150",
|
||||
@@ -160,7 +170,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
@@ -170,76 +180,121 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{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" />
|
||||
)}
|
||||
{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" />
|
||||
)}
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || '(no subject)'}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</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 && (
|
||||
<>
|
||||
<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" />
|
||||
)}
|
||||
{email.hasAttachment && <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 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",
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{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" />
|
||||
)}
|
||||
{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
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
{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>
|
||||
@@ -247,6 +302,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
@@ -280,10 +336,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const t = useTranslations('threads');
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
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, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const displayNames = showRecipient
|
||||
? Array.from(new Set(
|
||||
thread.emails.flatMap(e => (e.to ?? []).map(r => r.name || r.email.split('@')[0]))
|
||||
)).slice(0, 4)
|
||||
: participantNames;
|
||||
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
|
||||
|
||||
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
||||
email: latestEmail,
|
||||
@@ -405,15 +473,19 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
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 */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleThreadCheckboxClick}
|
||||
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",
|
||||
"active:scale-95",
|
||||
"animate-in fade-in zoom-in-95 duration-150",
|
||||
@@ -428,7 +500,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
{!isMobile && !isFocusedMailLayout && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
@@ -460,98 +532,153 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={latestEmail.from?.[0]?.name}
|
||||
email={latestEmail.from?.[0]?.email}
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
hasUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{participantNames.join(", ")}
|
||||
</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" />
|
||||
)}
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{displayNames.join(', ')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs 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 min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{latestEmail.subject || '(no subject)'}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</div>
|
||||
</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 && (
|
||||
<>
|
||||
<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" />
|
||||
)}
|
||||
{hasAttachment && <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 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",
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
hasUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{displayNames.join(", ")}
|
||||
</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
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
{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>
|
||||
@@ -559,6 +686,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{/* Hover Quick Actions for thread header */}
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
@@ -568,7 +696,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isExpanded && !isMobile && (
|
||||
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||
{isLoading ? (
|
||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -837,7 +837,7 @@ export function FileBrowser({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col h-full"
|
||||
className="flex flex-col flex-1 min-h-0"
|
||||
onClick={handleContainerClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
||||
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
sandbox="allow-same-origin"
|
||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||
title={name}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
FilterActionType,
|
||||
} from "@/lib/jmap/sieve-types";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from "@/lib/utils";
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||
|
||||
interface FilterRuleModalProps {
|
||||
rule?: FilterRule;
|
||||
@@ -109,7 +109,7 @@ export function FilterRuleModal({
|
||||
}
|
||||
|
||||
onSave({
|
||||
id: rule?.id || crypto.randomUUID(),
|
||||
id: rule?.id || generateUUID(),
|
||||
name: trimmedName,
|
||||
enabled: rule?.enabled ?? true,
|
||||
matchType,
|
||||
|
||||
@@ -14,6 +14,10 @@ function useSyncIdentities() {
|
||||
const syncIdentities = useAuthStore((state) => state.syncIdentities);
|
||||
return syncIdentities;
|
||||
}
|
||||
|
||||
function useRefreshIdentities() {
|
||||
return useAuthStore((state) => state.refreshIdentities);
|
||||
}
|
||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
@@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
|
||||
const syncIdentities = useSyncIdentities();
|
||||
|
||||
const refreshIdentitiesFromServer = useRefreshIdentities();
|
||||
|
||||
// Refresh identities from server whenever the modal is opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
refreshIdentitiesFromServer();
|
||||
}
|
||||
}, [isOpen, refreshIdentitiesFromServer]);
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
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 { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
@@ -16,8 +16,12 @@ import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-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 { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -164,20 +168,60 @@ export function NavigationRail({
|
||||
const { mailboxes } = useEmailStore();
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
const { client } = useAuthStore.getState();
|
||||
if (!client) return;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': client.getAuthHeader(),
|
||||
'X-JMAP-Server-URL': client.getServerUrl(),
|
||||
'X-JMAP-Username': client.getUsername(),
|
||||
};
|
||||
const headers = getActiveAccountSlotHeaders();
|
||||
if (!headers['X-JMAP-Cookie-Slot']) return;
|
||||
fetch('/api/admin/stalwart-check', { headers })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
@@ -199,7 +243,7 @@ export function NavigationRail({
|
||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||
{ 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");
|
||||
@@ -504,6 +548,22 @@ export function NavigationRail({
|
||||
<Keyboard className="w-[18px] h-[18px]" />
|
||||
</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 && (
|
||||
<div data-tour="storage-quota">
|
||||
@@ -511,21 +571,85 @@ export function NavigationRail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPushConnected != null && (
|
||||
<span
|
||||
className="relative group"
|
||||
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
|
||||
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{onLogout && showRailAccountList && accounts.length > 0 && (
|
||||
<>
|
||||
<div className="w-8 border-t my-1" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
|
||||
|
||||
{/* Account circles */}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||
return (
|
||||
<button
|
||||
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" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
|
||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { IconPicker } from './icon-picker';
|
||||
@@ -207,7 +207,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
|
||||
});
|
||||
|
||||
const handleCreate = useCallback((data: SidebarAppFormData) => {
|
||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const id = `app-${generateUUID()}`;
|
||||
addSidebarApp({ id, ...data });
|
||||
setIsCreating(false);
|
||||
}, [addSidebarApp]);
|
||||
|
||||
+132
-42
@@ -12,14 +12,12 @@ import {
|
||||
Star,
|
||||
Trash2,
|
||||
Archive,
|
||||
PenSquare,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Users,
|
||||
User,
|
||||
Palmtree,
|
||||
Settings,
|
||||
@@ -60,10 +58,6 @@ interface SidebarProps {
|
||||
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
|
||||
const lowerName = name?.toLowerCase() || "";
|
||||
|
||||
if (id === 'shared-folders-root') {
|
||||
return isExpanded ? FolderOpen : Users;
|
||||
}
|
||||
|
||||
if (id?.startsWith('shared-account-')) {
|
||||
return isExpanded ? FolderOpen : User;
|
||||
}
|
||||
@@ -309,7 +303,7 @@ function TagItem({
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
|
||||
{!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">
|
||||
{unreadCount > 0 && (
|
||||
<span className={cn(
|
||||
@@ -428,9 +422,16 @@ export function Sidebar({
|
||||
onUnreadFilterClick,
|
||||
className,
|
||||
}: SidebarProps) {
|
||||
const router = useRouter();
|
||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||
const { primaryIdentity: _primaryIdentity } = useAuthStore();
|
||||
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(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarTagsExpanded');
|
||||
@@ -438,6 +439,7 @@ export function Sidebar({
|
||||
} catch { return true; }
|
||||
});
|
||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher);
|
||||
const tagCounts = useEmailStore(s => s.tagCounts);
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
@@ -452,10 +454,17 @@ export function Sidebar({
|
||||
}
|
||||
} else {
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const defaultExpanded = tree
|
||||
.filter(node => node.children.length > 0)
|
||||
.map(node => node.id);
|
||||
setExpandedFolders(new Set(defaultExpanded));
|
||||
const collectExpandable = (nodes: MailboxNode[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.children.length > 0) {
|
||||
ids.push(node.id);
|
||||
ids.push(...collectExpandable(node.children));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
setExpandedFolders(new Set(collectExpandable(tree)));
|
||||
}
|
||||
}, [mailboxes]);
|
||||
|
||||
@@ -539,7 +548,7 @@ export function Sidebar({
|
||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
{!isCollapsed && (
|
||||
{!isCollapsed && !hideAccountSwitcher && (
|
||||
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||
)}
|
||||
</div>
|
||||
@@ -553,24 +562,103 @@ export function Sidebar({
|
||||
{/* Mailbox List */}
|
||||
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||
<div className="py-1">
|
||||
{mailboxes.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
{/* Folders Section Header */}
|
||||
<div
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
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) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
/>
|
||||
))}
|
||||
{mailboxes.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{mailboxTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -635,6 +723,20 @@ export function Sidebar({
|
||||
<span className="flex-1 truncate">{t("tags")}</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||
@@ -661,19 +763,7 @@ export function Sidebar({
|
||||
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,24 +6,32 @@ import { useLocaleStore } from '@/stores/locale-store';
|
||||
import enMessages from '@/locales/en/common.json';
|
||||
import frMessages from '@/locales/fr/common.json';
|
||||
import jaMessages from '@/locales/ja/common.json';
|
||||
import koMessages from '@/locales/ko/common.json';
|
||||
import esMessages from '@/locales/es/common.json';
|
||||
import itMessages from '@/locales/it/common.json';
|
||||
import deMessages from '@/locales/de/common.json';
|
||||
import lvMessages from '@/locales/lv/common.json';
|
||||
import nlMessages from '@/locales/nl/common.json';
|
||||
import plMessages from '@/locales/pl/common.json';
|
||||
import ptMessages from '@/locales/pt/common.json';
|
||||
import ruMessages from '@/locales/ru/common.json';
|
||||
import zhMessages from '@/locales/zh/common.json';
|
||||
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
const ALL_MESSAGES = {
|
||||
en: enMessages,
|
||||
fr: frMessages,
|
||||
ja: jaMessages,
|
||||
ko: koMessages,
|
||||
es: esMessages,
|
||||
it: itMessages,
|
||||
de: deMessages,
|
||||
lv: lvMessages,
|
||||
nl: nlMessages,
|
||||
pl: plMessages,
|
||||
pt: ptMessages,
|
||||
ru: ruMessages,
|
||||
zh: zhMessages,
|
||||
};
|
||||
|
||||
interface IntlProviderProps {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download } from "lucide-react";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
export function PWAInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] =
|
||||
useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
setShowPrompt(true);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === "accepted") {
|
||||
setDeferredPrompt(null);
|
||||
setShowPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
if (!showPrompt || !deferredPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
|
||||
Install Bulwark
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
Install our app for quick access and offline support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
||||
aria-label="Dismiss install prompt"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// In development, unregister any previously installed service worker
|
||||
// and clear its caches so hot reload always serves fresh assets.
|
||||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
registrations.forEach((registration) => registration.unregister());
|
||||
});
|
||||
if ("caches" in window) {
|
||||
caches.keys().then((keys) => {
|
||||
keys.forEach((key) => caches.delete(key));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
console.log("Service Worker registered successfully:", registration);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Service Worker registration failed:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -157,6 +157,7 @@ function DisplayNameSection() {
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={displayName || t('display_name.placeholder')}
|
||||
className="w-48"
|
||||
/>
|
||||
<Button
|
||||
@@ -523,7 +524,7 @@ export function AccountSecuritySettings() {
|
||||
if (isStalwart === false) {
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<p className="text-sm text-muted-foreground py-4">{t('not_available')}</p>
|
||||
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: t('not_available') }} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Book, Pencil, Share2, Tag } from "lucide-react";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AddressBook } from "@/lib/jmap/types";
|
||||
|
||||
function AddressBookEditRow({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: {
|
||||
initial: string;
|
||||
onSave: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const t = useTranslations("contacts.address_books");
|
||||
const tCal = useTranslations("calendar.management");
|
||||
const [name, setName] = useState(initial);
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("name_label")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && isValid) onSave(name.trim());
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => isValid && onSave(name.trim())}
|
||||
disabled={isLoading || !isValid}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{tCal("save")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
|
||||
>
|
||||
{tCal("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddressBookManagementSettings() {
|
||||
const t = useTranslations("contacts.address_books");
|
||||
const tContacts = useTranslations("contacts");
|
||||
const tSettings = useTranslations("settings.contacts");
|
||||
const { client } = useAuthStore();
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && addressBooks.length === 0) {
|
||||
fetchAddressBooks(client);
|
||||
}
|
||||
}, [client, addressBooks.length, fetchAddressBooks]);
|
||||
|
||||
const handleUpdate = async (book: AddressBook, newName: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await renameAddressBook(client, book, newName);
|
||||
setEditingId(null);
|
||||
toast.success(t("renamed"));
|
||||
} catch {
|
||||
toast.error(t("rename_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Group: personal first, then by shared account
|
||||
const personal = addressBooks.filter((b) => !b.isShared);
|
||||
const sharedGroups = new Map<string, { accountName: string; books: AddressBook[] }>();
|
||||
for (const book of addressBooks) {
|
||||
if (!book.isShared || !book.accountId) continue;
|
||||
const key = book.accountId;
|
||||
const existing = sharedGroups.get(key);
|
||||
if (existing) existing.books.push(book);
|
||||
else sharedGroups.set(key, { accountName: book.accountName || book.accountId, books: [book] });
|
||||
}
|
||||
|
||||
const renderBook = (book: AddressBook) => {
|
||||
if (editingId === book.id) {
|
||||
return (
|
||||
<AddressBookEditRow
|
||||
key={book.id}
|
||||
initial={book.name}
|
||||
onSave={(name) => handleUpdate(book, name)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canRename = !book.isShared || book.myRights?.mayWrite !== false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={book.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
|
||||
)}
|
||||
>
|
||||
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{book.name}</span>
|
||||
</div>
|
||||
{book.isDefault && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
{t("default")}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{canRename && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(book.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("rename")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Collect keywords with counts
|
||||
const keywordCounts: Record<string, number> = {};
|
||||
for (const c of contacts) {
|
||||
if (c.kind === "group" || !c.keywords) continue;
|
||||
for (const [kw, active] of Object.entries(c.keywords)) {
|
||||
if (active) keywordCounts[kw] = (keywordCounts[kw] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const sortedKeywords = Object.entries(keywordCounts).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const handleRenameKeyword = async (oldKw: string, newKw: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await renameKeyword(supportsSync && client ? client : null, oldKw, newKw);
|
||||
setEditingKeyword(null);
|
||||
toast.success(tContacts("category_renamed"));
|
||||
} catch {
|
||||
toast.error(tContacts("category_rename_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
|
||||
<div className="space-y-2">
|
||||
{personal.map(renderBook)}
|
||||
|
||||
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
|
||||
<div key={accountId} className="mt-4 space-y-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{t("shared_prefix", { name: group.accountName })}
|
||||
</h4>
|
||||
{group.books.map(renderBook)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{addressBooks.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mt-8">
|
||||
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
|
||||
<div className="space-y-2">
|
||||
{sortedKeywords.map(([keyword, count]) => {
|
||||
if (editingKeyword === keyword) {
|
||||
return (
|
||||
<AddressBookEditRow
|
||||
key={keyword}
|
||||
initial={keyword}
|
||||
onSave={(name) => handleRenameKeyword(keyword, name)}
|
||||
onCancel={() => setEditingKeyword(null)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={keyword}
|
||||
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
|
||||
>
|
||||
<Tag className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{keyword}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{count}</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingKeyword(keyword)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={tContacts("rename_category")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sortedKeywords.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">{tSettings("no_categories")}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,16 +7,36 @@ import { useConfig } from '@/hooks/use-config';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
|
||||
export function AdvancedSettings() {
|
||||
const t = useTranslations('settings.advanced');
|
||||
const tCommon = useTranslations('common');
|
||||
const { debugMode, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||
useSettingsStore();
|
||||
const { settingsSyncEnabled } = useConfig();
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
const [showGame, setShowGame] = useState(false);
|
||||
const logoClickCount = useRef(0);
|
||||
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleLogoClick = () => {
|
||||
logoClickCount.current++;
|
||||
if (logoClickTimer.current) clearTimeout(logoClickTimer.current);
|
||||
if (logoClickCount.current >= 3) {
|
||||
logoClickCount.current = 0;
|
||||
setShowGame(true);
|
||||
} else {
|
||||
logoClickTimer.current = setTimeout(() => { logoClickCount.current = 0; }, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const settingsJson = exportSettings();
|
||||
@@ -64,6 +84,44 @@ export function AdvancedSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
|
||||
{/* About */}
|
||||
<div className="rounded-lg border border-border bg-card p-5 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_Color.svg"
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_White.svg"
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('about.title')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
|
||||
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/bulwarkmail/webmail"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
GitHub <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Debug Mode */}
|
||||
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
|
||||
@@ -72,6 +130,30 @@ export function AdvancedSettings() {
|
||||
</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 */}
|
||||
{settingsSyncEnabled && (
|
||||
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
|
||||
@@ -122,5 +204,6 @@ export function AdvancedSettings() {
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tTour = useTranslations('tour');
|
||||
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 { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
|
||||
@@ -145,6 +145,22 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</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 */}
|
||||
{!isSettingHidden('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 { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { SettingsSection } from './settings-section';
|
||||
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 [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
||||
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
|
||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||
const tImport = useTranslations('calendar.import');
|
||||
@@ -202,7 +204,10 @@ export function CalendarManagementSettings() {
|
||||
|
||||
fetch('/api/caldav/discover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getActiveAccountSlotHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
|
||||
}),
|
||||
@@ -629,6 +634,14 @@ export function CalendarManagementSettings() {
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => handleRefreshSubscription(sub.id)}
|
||||
@@ -667,6 +680,14 @@ export function CalendarManagementSettings() {
|
||||
onClose={() => setShowSubscriptionModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSubscription && client && (
|
||||
<ICalSubscriptionModal
|
||||
client={client}
|
||||
editSubscription={editingSubscription}
|
||||
onClose={() => setEditingSubscription(null)}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export function CalendarSettings() {
|
||||
showWeekNumbers,
|
||||
enableCalendarTasks,
|
||||
showTasksOnCalendar,
|
||||
showBirthdayCalendar,
|
||||
calendarHoverPreview,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
@@ -80,6 +82,33 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</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>
|
||||
|
||||
<SettingItem
|
||||
label={t('show_birthday_calendar')}
|
||||
description={t('show_birthday_calendar_desc')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showBirthdayCalendar}
|
||||
onChange={(checked) => updateSetting('showBirthdayCalendar', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{isFeatureEnabled('calendarTasksEnabled') && (
|
||||
<>
|
||||
<SettingItem
|
||||
|
||||
@@ -4,16 +4,92 @@ import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
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 { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
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 { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
|
||||
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() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const { appName } = useConfig();
|
||||
@@ -39,7 +115,9 @@ export function EmailSettings() {
|
||||
deleteAction,
|
||||
permanentlyDeleteJunk,
|
||||
showPreview,
|
||||
mailLayout,
|
||||
disableThreading,
|
||||
autoSelectReplyIdentity,
|
||||
plainTextMode,
|
||||
emailsPerPage,
|
||||
externalContentPolicy,
|
||||
@@ -62,6 +140,8 @@ export function EmailSettings() {
|
||||
return t('trusted_senders.count_other', { count });
|
||||
};
|
||||
|
||||
const isFocusedLayout = mailLayout === 'focus';
|
||||
|
||||
const handleReorganizeArchive = async () => {
|
||||
const { client } = useAuthStore.getState();
|
||||
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
|
||||
@@ -207,9 +287,29 @@ export function EmailSettings() {
|
||||
/>
|
||||
</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 */}
|
||||
{!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)} />
|
||||
</SettingItem>
|
||||
)}
|
||||
@@ -230,6 +330,13 @@ export function EmailSettings() {
|
||||
/>
|
||||
</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 */}
|
||||
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||
<div className="py-3 border-b border-border space-y-3">
|
||||
|
||||
@@ -99,11 +99,17 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
|
||||
export function FolderSettings() {
|
||||
const t = useTranslations('settings.folders');
|
||||
const { client } = useAuthStore();
|
||||
const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
|
||||
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
|
||||
const { folderIcons, setFolderIcon } = useSettingsStore();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
|
||||
|
||||
useEffect(() => {
|
||||
if (client && mailboxes.length === 0) {
|
||||
fetchMailboxes(client);
|
||||
}
|
||||
}, [client, mailboxes.length, fetchMailboxes]);
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [creatingParentId, setCreatingParentId] = useState<string | null>(null);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
@@ -200,8 +206,18 @@ export function FolderSettings() {
|
||||
await deleteMailbox(client, mailboxId);
|
||||
setDeletingId(null);
|
||||
toast.success(t('folder_deleted'));
|
||||
} catch {
|
||||
toast.error(t('error_delete'));
|
||||
} catch (err: unknown) {
|
||||
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
|
||||
switch (jmapType) {
|
||||
case 'mailboxHasChild':
|
||||
toast.error(t('error_delete_has_children'));
|
||||
break;
|
||||
case 'mailboxHasEmail':
|
||||
toast.error(t('error_delete_has_email'));
|
||||
break;
|
||||
default:
|
||||
toast.error(t('error_delete'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record<PluginStatus, string> = {
|
||||
|
||||
export function PluginsSettings() {
|
||||
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
|
||||
const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore();
|
||||
const { isFeatureEnabled, isPluginForceEnabled, isPluginApproved, fetchPolicy, loaded } = usePolicyStore();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -75,6 +75,13 @@ export function PluginsSettings() {
|
||||
return;
|
||||
}
|
||||
|
||||
const requireApproval = isFeatureEnabled('requirePluginApproval');
|
||||
const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
|
||||
if (!plugin.enabled && requireApproval && !isApproved) {
|
||||
toast.info(`Plugin "${plugin.name}" requires admin approval before it can be enabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plugin.enabled) {
|
||||
disablePlugin(plugin.id);
|
||||
toast.info(`Plugin "${plugin.name}" disabled`);
|
||||
@@ -108,20 +115,26 @@ export function PluginsSettings() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{plugins.map(plugin => (
|
||||
{plugins.map(plugin => {
|
||||
const requireApproval = isFeatureEnabled('requirePluginApproval');
|
||||
const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
|
||||
const needsApproval = requireApproval && !isApproved;
|
||||
return (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
isExpanded={expandedPlugin === plugin.id}
|
||||
isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)}
|
||||
isManaged={Boolean(plugin.managed)}
|
||||
needsApproval={needsApproval}
|
||||
controlsDisabled={!initialized}
|
||||
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
||||
onToggle={() => handleToggle(plugin)}
|
||||
onUninstall={() => handleUninstall(plugin)}
|
||||
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -166,6 +179,7 @@ interface PluginCardProps {
|
||||
isExpanded: boolean;
|
||||
isForceEnabled: boolean;
|
||||
isManaged: boolean;
|
||||
needsApproval: boolean;
|
||||
controlsDisabled: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onToggle: () => void;
|
||||
@@ -173,7 +187,7 @@ interface PluginCardProps {
|
||||
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
|
||||
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApproval, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border transition-colors',
|
||||
@@ -197,6 +211,11 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
|
||||
<Server className="w-2.5 h-2.5" /> Managed
|
||||
</span>
|
||||
)}
|
||||
{needsApproval && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
|
||||
Awaiting approval
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{plugin.author}</span>
|
||||
@@ -206,7 +225,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled} />
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled || needsApproval} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -217,6 +236,10 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p>
|
||||
)}
|
||||
|
||||
{needsApproval && (
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400">This plugin is awaiting admin approval and cannot be enabled until an administrator approves it.</p>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-muted-foreground">{plugin.description}</p>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { IconPicker } from "@/components/layout/icon-picker";
|
||||
import { useSettingsStore, type SidebarApp } from "@/stores/settings-store";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, generateUUID } from "@/lib/utils";
|
||||
|
||||
interface SidebarAppFormData {
|
||||
name: string;
|
||||
@@ -188,7 +188,7 @@ export function SidebarAppsSettings() {
|
||||
const draggedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const handleAdd = useCallback((data: SidebarAppFormData) => {
|
||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const id = `app-${generateUUID()}`;
|
||||
addSidebarApp({ id, ...data });
|
||||
setShowAddForm(false);
|
||||
}, [addSidebarApp]);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-di
|
||||
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
||||
|
||||
@@ -50,6 +51,7 @@ export function SmimeSettings() {
|
||||
} = useSmimeStore();
|
||||
|
||||
const { identities } = useIdentityStore();
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
|
||||
// Local UI state
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
@@ -75,8 +77,8 @@ export function SmimeSettings() {
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
load(activeAccountId ?? undefined);
|
||||
}, [load, activeAccountId]);
|
||||
|
||||
// ── PKCS#12 import flow ────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const GAME_WIDTH = 400;
|
||||
const GAME_HEIGHT = 520;
|
||||
const FORTRESS_Y = GAME_HEIGHT - 48;
|
||||
const SPAWN_INTERVAL_START = 850;
|
||||
const SPAWN_INTERVAL_MIN = 320;
|
||||
const GAME_DURATION = 30;
|
||||
const ENEMY_SPEED_START = 1.2;
|
||||
const ENEMY_SPEED_INCREASE = 0.04;
|
||||
|
||||
interface Enemy {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
speed: number;
|
||||
type: "spam" | "phishing" | "legit";
|
||||
}
|
||||
|
||||
type GameState = "idle" | "playing" | "won" | "lost";
|
||||
|
||||
export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
|
||||
const [gameState, setGameState] = useState<GameState>("idle");
|
||||
const [enemies, setEnemies] = useState<Enemy[]>([]);
|
||||
const [score, setScore] = useState(0);
|
||||
const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
|
||||
const [shieldHealth, setShieldHealth] = useState(3);
|
||||
const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]);
|
||||
const [destroyEffects, setDestroyEffects] = useState<{ id: number; x: number; y: number }[]>([]);
|
||||
const [deliverEffects, setDeliverEffects] = useState<{ id: number; x: number; y: number }[]>([]);
|
||||
const nextId = useRef(0);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const lastTimeRef = useRef<number>(0);
|
||||
const spawnTimerRef = useRef<number>(0);
|
||||
const gameStateRef = useRef<GameState>("idle");
|
||||
const elapsedRef = useRef(0);
|
||||
const destroyedRef = useRef(new Set<number>());
|
||||
|
||||
useEffect(() => {
|
||||
gameStateRef.current = gameState;
|
||||
}, [gameState]);
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
setGameState("playing");
|
||||
setEnemies([]);
|
||||
setScore(0);
|
||||
setTimeLeft(GAME_DURATION);
|
||||
setShieldHealth(3);
|
||||
setHitEffects([]);
|
||||
setDestroyEffects([]);
|
||||
setDeliverEffects([]);
|
||||
nextId.current = 0;
|
||||
spawnTimerRef.current = 0;
|
||||
elapsedRef.current = 0;
|
||||
destroyedRef.current = new Set();
|
||||
lastTimeRef.current = performance.now();
|
||||
}, []);
|
||||
|
||||
const spawnEnemy = useCallback(() => {
|
||||
const id = nextId.current++;
|
||||
const rand = Math.random();
|
||||
const type = rand > 0.7 ? "legit" : rand > 0.45 ? "phishing" : "spam";
|
||||
const x = 20 + Math.random() * (GAME_WIDTH - 60);
|
||||
const elapsed = elapsedRef.current;
|
||||
const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE;
|
||||
setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]);
|
||||
}, []);
|
||||
|
||||
const handleHover = useCallback((enemy: Enemy) => {
|
||||
if (destroyedRef.current.has(enemy.id)) return;
|
||||
destroyedRef.current.add(enemy.id);
|
||||
|
||||
if (enemy.type === "legit") {
|
||||
// Penalty for blocking legit mail
|
||||
setShieldHealth((prev) => {
|
||||
const nh = prev - 1;
|
||||
if (nh <= 0) setGameState("lost");
|
||||
return Math.max(0, nh);
|
||||
});
|
||||
setScore((prev) => Math.max(0, prev - 15));
|
||||
const effectId = nextId.current++;
|
||||
setHitEffects((p) => [...p, { id: effectId, x: enemy.x, y: enemy.y, color: "rgba(34, 197, 94, 0.5)" }]);
|
||||
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
|
||||
} else {
|
||||
setScore((prev) => prev + 10);
|
||||
const effectId = nextId.current++;
|
||||
setDestroyEffects((prev) => [...prev, { id: effectId, x: enemy.x, y: enemy.y }]);
|
||||
setTimeout(() => setDestroyEffects((prev) => prev.filter((e) => e.id !== effectId)), 400);
|
||||
}
|
||||
|
||||
setEnemies((prev) => prev.filter((e) => e.id !== enemy.id));
|
||||
}, []);
|
||||
|
||||
// Game loop
|
||||
useEffect(() => {
|
||||
if (gameState !== "playing") return;
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (gameStateRef.current !== "playing") return;
|
||||
|
||||
const dt = now - lastTimeRef.current;
|
||||
lastTimeRef.current = now;
|
||||
elapsedRef.current += dt;
|
||||
|
||||
// Timer
|
||||
const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000);
|
||||
setTimeLeft(Math.max(0, newTimeLeft));
|
||||
if (newTimeLeft <= 0) {
|
||||
setGameState("won");
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn
|
||||
spawnTimerRef.current += dt;
|
||||
const spawnInterval = Math.max(
|
||||
SPAWN_INTERVAL_MIN,
|
||||
SPAWN_INTERVAL_START - (elapsedRef.current / 1000) * 35
|
||||
);
|
||||
if (spawnTimerRef.current >= spawnInterval) {
|
||||
spawnTimerRef.current = 0;
|
||||
spawnEnemy();
|
||||
}
|
||||
|
||||
// Move enemies
|
||||
setEnemies((prev) => {
|
||||
const next: Enemy[] = [];
|
||||
let spamBreached = false;
|
||||
for (const e of prev) {
|
||||
const ny = e.y + e.speed * (dt / 16);
|
||||
if (ny >= FORTRESS_Y) {
|
||||
if (e.type === "legit") {
|
||||
// Legit mail delivered — bonus
|
||||
setScore((s) => s + 5);
|
||||
const effectId = nextId.current++;
|
||||
setDeliverEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y }]);
|
||||
setTimeout(() => setDeliverEffects((p) => p.filter((d) => d.id !== effectId)), 500);
|
||||
} else {
|
||||
spamBreached = true;
|
||||
const effectId = nextId.current++;
|
||||
setHitEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y, color: "rgba(219, 45, 84, 0.3)" }]);
|
||||
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
|
||||
}
|
||||
} else {
|
||||
next.push({ ...e, y: ny });
|
||||
}
|
||||
}
|
||||
if (spamBreached) {
|
||||
setShieldHealth((prev) => {
|
||||
const nh = prev - 1;
|
||||
if (nh <= 0) setGameState("lost");
|
||||
return Math.max(0, nh);
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(animFrameRef.current);
|
||||
}, [gameState, spawnEnemy]);
|
||||
|
||||
const getEnemyStyle = (type: Enemy["type"]) => {
|
||||
switch (type) {
|
||||
case "phishing":
|
||||
return { bg: "rgba(234, 179, 8, 0.15)", border: "rgba(234, 179, 8, 0.4)", color: "rgb(234, 179, 8)" };
|
||||
case "legit":
|
||||
return { bg: "rgba(34, 197, 94, 0.12)", border: "rgba(34, 197, 94, 0.4)", color: "rgb(34, 197, 94)" };
|
||||
default:
|
||||
return { bg: "rgba(219, 45, 84, 0.1)", border: "rgba(219, 45, 84, 0.3)", color: "rgb(219, 45, 84)" };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="relative rounded-xl border border-border bg-card shadow-2xl overflow-hidden select-none"
|
||||
style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" style={{ color: "rgb(219, 45, 84)" }} />
|
||||
<span className="text-sm font-semibold text-foreground">Spam Siege</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors">
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* HUD */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground">Score: <span className="font-semibold text-foreground">{score}</span></span>
|
||||
<span className="text-muted-foreground">Time: <span className="font-semibold text-foreground">{timeLeft}s</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Shield
|
||||
key={i}
|
||||
className="w-3.5 h-3.5 transition-colors"
|
||||
style={{ color: i < shieldHealth ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
|
||||
fill={i < shieldHealth ? "rgb(219, 45, 84)" : "none"}
|
||||
strokeWidth={i < shieldHealth ? 0 : 1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game area */}
|
||||
<div
|
||||
className="relative bg-background overflow-hidden"
|
||||
style={{ height: GAME_HEIGHT }}
|
||||
>
|
||||
{/* Grid lines for depth */}
|
||||
<div className="absolute inset-0 opacity-[0.03]" style={{
|
||||
backgroundImage: "linear-gradient(to bottom, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
}} />
|
||||
|
||||
{/* Fortress wall */}
|
||||
<div className="absolute left-0 right-0 bottom-0 flex flex-col items-center" style={{ height: GAME_HEIGHT - FORTRESS_Y }}>
|
||||
<div className="relative w-full">
|
||||
{/* Shield centered above the line */}
|
||||
<div className="absolute -top-5 left-1/2 -translate-x-1/2 z-10">
|
||||
<Shield
|
||||
className="w-7 h-7 drop-shadow-sm"
|
||||
style={{ color: shieldHealth > 0 ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
|
||||
fill={shieldHealth > 0 ? "rgba(219, 45, 84, 0.2)" : "none"}
|
||||
/>
|
||||
</div>
|
||||
{/* Solid line */}
|
||||
<div
|
||||
className="h-[2px] w-full"
|
||||
style={{ backgroundColor: shieldHealth > 0 ? "rgba(219, 45, 84, 0.35)" : "rgba(100, 100, 100, 0.3)" }}
|
||||
/>
|
||||
</div>
|
||||
{/* Subtle gradient fill below */}
|
||||
<div
|
||||
className="flex-1 w-full"
|
||||
style={{
|
||||
background: shieldHealth > 0
|
||||
? "linear-gradient(to bottom, rgba(219, 45, 84, 0.06), transparent)"
|
||||
: "linear-gradient(to bottom, rgba(100, 100, 100, 0.04), transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enemies */}
|
||||
{enemies.map((e) => {
|
||||
const style = getEnemyStyle(e.type);
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
className="absolute flex items-center justify-center w-8 h-8 rounded-md transition-transform"
|
||||
style={{
|
||||
left: e.x,
|
||||
top: e.y,
|
||||
backgroundColor: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
}}
|
||||
onMouseEnter={() => handleHover(e)}
|
||||
>
|
||||
{e.type === "phishing" ? (
|
||||
<AlertTriangle className="w-4 h-4" style={{ color: style.color }} />
|
||||
) : e.type === "legit" ? (
|
||||
<MailCheck className="w-4 h-4" style={{ color: style.color }} />
|
||||
) : (
|
||||
<Mail className="w-4 h-4" style={{ color: style.color }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Destroy effects */}
|
||||
{destroyEffects.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="absolute pointer-events-none animate-ping"
|
||||
style={{ left: e.x + 4, top: e.y + 4 }}
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-foreground/50" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Deliver effects (legit mail arrived) */}
|
||||
{deliverEffects.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="absolute pointer-events-none animate-ping"
|
||||
style={{ left: e.x + 4, top: e.y - 8 }}
|
||||
>
|
||||
<Inbox className="w-5 h-5" style={{ color: "rgb(34, 197, 94)" }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Hit effects on fortress */}
|
||||
{hitEffects.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="absolute pointer-events-none"
|
||||
style={{ left: e.x, top: e.y - 10 }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full animate-ping" style={{ backgroundColor: e.color }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Idle overlay */}
|
||||
{gameState === "idle" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
||||
<Shield className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} fill="rgba(219, 45, 84, 0.1)" />
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-foreground">Spam Siege</p>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 max-w-[280px] leading-relaxed">
|
||||
Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4 mt-3 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" style={{ color: "rgb(219, 45, 84)" }} /> Spam
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" style={{ color: "rgb(234, 179, 8)" }} /> Phishing
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MailCheck className="w-3 h-3" style={{ color: "rgb(34, 197, 94)" }} /> Legit
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={startGame} className="mt-1 text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
||||
<Shield className="w-3.5 h-3.5 mr-1.5" />
|
||||
Defend
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Won overlay */}
|
||||
{gameState === "won" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
||||
<Trophy className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} />
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-foreground">Fortress Secured</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Score: <span className="font-semibold text-foreground">{score}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lost overlay */}
|
||||
{gameState === "lost" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
||||
<Shield className="w-14 h-14 text-muted-foreground/40" />
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-foreground">Fortress Breached</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Score: <span className="font-semibold text-foreground">{score}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -174,64 +174,68 @@ interface ThemeCardProps {
|
||||
|
||||
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onActivate}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'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',
|
||||
isActive
|
||||
? '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 className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onActivate}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
|
||||
isActive
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-primary/40 bg-card',
|
||||
disabled && !isActive && 'hover:border-border'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-sm font-medium text-foreground truncate">{name}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{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>
|
||||
>
|
||||
{/* 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>
|
||||
<span className="text-xs text-muted-foreground truncate block">{author}</span>
|
||||
{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>
|
||||
))}
|
||||
|
||||
{/* Info */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-sm font-medium text-foreground truncate">{name}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{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 && (
|
||||
<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 */}
|
||||
{onRemove && !isActive && (
|
||||
<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"
|
||||
title="Remove theme"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,16 @@ export function LanguageSwitcher({ className }: { className?: string }) {
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'lv', label: 'Latviešu' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ru', label: 'Русский' }
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '简体中文' }
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -71,6 +71,7 @@ export default [
|
||||
".next/**",
|
||||
"node_modules/**",
|
||||
"repos/**",
|
||||
"data/admin/plugins/**",
|
||||
"*.config.js",
|
||||
"*.config.mjs",
|
||||
"e2e/**",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export interface NavSnapshot {
|
||||
mailboxId: string | null;
|
||||
emailId: string | null;
|
||||
threadId: string | null;
|
||||
composerOpen: boolean;
|
||||
sidebarOpen: boolean;
|
||||
}
|
||||
|
||||
interface StoredNavState extends NavSnapshot {
|
||||
navId: number;
|
||||
}
|
||||
|
||||
interface UseBrowserNavigationOptions extends NavSnapshot {
|
||||
onRestore: (state: NavSnapshot) => void | Promise<void>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const STATE_KEY = "__mailNav";
|
||||
let navIdCounter = 0;
|
||||
|
||||
function snapshotsEqual(
|
||||
a: NavSnapshot | undefined | null,
|
||||
b: NavSnapshot,
|
||||
): boolean {
|
||||
if (!a) return false;
|
||||
return (
|
||||
a.mailboxId === b.mailboxId &&
|
||||
a.emailId === b.emailId &&
|
||||
a.threadId === b.threadId &&
|
||||
a.composerOpen === b.composerOpen &&
|
||||
a.sidebarOpen === b.sidebarOpen
|
||||
);
|
||||
}
|
||||
|
||||
function readStoredState(): StoredNavState | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const raw = window.history.state as Record<string, unknown> | null;
|
||||
if (!raw) return undefined;
|
||||
return raw[STATE_KEY] as StoredNavState | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs in-app navigation state to the browser history stack so the browser
|
||||
* back / forward buttons (mouse buttons on desktop, gesture / hardware button
|
||||
* on mobile) navigate within the mail UI.
|
||||
*
|
||||
* - Pushes a new history entry whenever the captured snapshot changes from
|
||||
* user action.
|
||||
* - Listens for popstate and calls onRestore so the page can apply the
|
||||
* previous snapshot (mailbox, email, view, sidebar, conversation thread).
|
||||
*
|
||||
* The URL is left untouched so Next.js routes (e.g. /calendar, /settings)
|
||||
* continue to behave normally.
|
||||
*/
|
||||
export function useBrowserNavigation({
|
||||
mailboxId,
|
||||
emailId,
|
||||
threadId,
|
||||
composerOpen,
|
||||
sidebarOpen,
|
||||
onRestore,
|
||||
enabled = true,
|
||||
}: UseBrowserNavigationOptions) {
|
||||
// Counter so overlapping restores don't accidentally clear the flag
|
||||
// belonging to a later restore.
|
||||
const popDepthRef = useRef(0);
|
||||
const isApplyingPopRef = useRef(false);
|
||||
const restoreRef = useRef(onRestore);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// Always keep the latest restore callback in a ref so the popstate
|
||||
// listener never sees a stale closure.
|
||||
restoreRef.current = onRestore;
|
||||
|
||||
// Install the popstate listener once.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const handlePop = (event: PopStateEvent) => {
|
||||
const raw = event.state as Record<string, unknown> | null;
|
||||
const state = raw ? (raw[STATE_KEY] as StoredNavState | undefined) : undefined;
|
||||
if (!state) return;
|
||||
|
||||
// Hold the "applying pop" flag for the entire restore — including any
|
||||
// async work like fetching email content — so the resulting state
|
||||
// updates don't trigger a fresh history push that would undo the
|
||||
// user's back / forward navigation.
|
||||
popDepthRef.current += 1;
|
||||
isApplyingPopRef.current = true;
|
||||
|
||||
const settle = () => {
|
||||
popDepthRef.current -= 1;
|
||||
if (popDepthRef.current === 0) {
|
||||
// One extra macrotask so React has flushed any state updates
|
||||
// dispatched at the very end of the restore.
|
||||
setTimeout(() => {
|
||||
if (popDepthRef.current === 0) {
|
||||
isApplyingPopRef.current = false;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
let result: void | Promise<void>;
|
||||
try {
|
||||
result = restoreRef.current(state);
|
||||
} catch (error) {
|
||||
settle();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result && typeof (result as Promise<void>).then === "function") {
|
||||
(result as Promise<void>).finally(settle);
|
||||
} else {
|
||||
settle();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePop);
|
||||
return () => window.removeEventListener("popstate", handlePop);
|
||||
}, []);
|
||||
|
||||
// Push a new history entry whenever the navigation snapshot changes.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (isApplyingPopRef.current) return;
|
||||
|
||||
const snapshot: NavSnapshot = {
|
||||
mailboxId,
|
||||
emailId,
|
||||
threadId,
|
||||
composerOpen,
|
||||
sidebarOpen,
|
||||
};
|
||||
|
||||
const existing = readStoredState();
|
||||
if (snapshotsEqual(existing, snapshot)) return;
|
||||
|
||||
const stored: StoredNavState = { ...snapshot, navId: ++navIdCounter };
|
||||
const baseState = (window.history.state ?? {}) as Record<string, unknown>;
|
||||
const newState = { ...baseState, [STATE_KEY]: stored };
|
||||
|
||||
if (!initializedRef.current) {
|
||||
initializedRef.current = true;
|
||||
// Replace the current entry on the very first run so we don't
|
||||
// create an extra step the user has to back through to leave the app.
|
||||
window.history.replaceState(newState, "");
|
||||
} else {
|
||||
window.history.pushState(newState, "");
|
||||
}
|
||||
}, [enabled, mailboxId, emailId, threadId, composerOpen, sidebarOpen]);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ interface ConfigData {
|
||||
loginWebsiteUrl: string;
|
||||
demoMode: boolean;
|
||||
autoSsoEnabled: boolean;
|
||||
allowCustomJmapEndpoint: boolean;
|
||||
embeddedMode: boolean;
|
||||
parentOrigin: string;
|
||||
}
|
||||
@@ -100,6 +101,7 @@ export function useConfig(): AppConfig {
|
||||
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
||||
demoMode: configCache?.demoMode || false,
|
||||
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
||||
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
|
||||
embeddedMode: configCache?.embeddedMode || false,
|
||||
parentOrigin: configCache?.parentOrigin || '',
|
||||
isLoading: !configCache,
|
||||
@@ -131,6 +133,7 @@ export function useConfig(): AppConfig {
|
||||
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
||||
demoMode: configCache.demoMode,
|
||||
autoSsoEnabled: configCache.autoSsoEnabled,
|
||||
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
|
||||
embeddedMode: configCache.embeddedMode,
|
||||
parentOrigin: configCache.parentOrigin,
|
||||
isLoading: false,
|
||||
@@ -163,6 +166,7 @@ export function useConfig(): AppConfig {
|
||||
loginWebsiteUrl: data.loginWebsiteUrl,
|
||||
demoMode: data.demoMode,
|
||||
autoSsoEnabled: data.autoSsoEnabled,
|
||||
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
|
||||
embeddedMode: data.embeddedMode,
|
||||
parentOrigin: data.parentOrigin,
|
||||
isLoading: false,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// Re-sync identities every 30 minutes while the app is open
|
||||
const SYNC_INTERVAL_MS = 30 * 60 * 1000;
|
||||
|
||||
export function useIdentitySync() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const refreshIdentities = useAuthStore((s) => s.refreshIdentities);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
// Sync when the user returns to the tab (e.g. after adding an alias in Stalwart)
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refreshIdentities();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
const interval = setInterval(refreshIdentities, SYNC_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [isAuthenticated, refreshIdentities]);
|
||||
}
|
||||
+13
-1
@@ -26,15 +26,27 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'ja':
|
||||
messages = (await import('../locales/ja/common.json')).default;
|
||||
break;
|
||||
case 'ko':
|
||||
messages = (await import('../locales/ko/common.json')).default;
|
||||
break;
|
||||
case 'lv':
|
||||
messages = (await import('../locales/lv/common.json')).default;
|
||||
break;
|
||||
case 'nl':
|
||||
messages = (await import('../locales/nl/common.json')).default;
|
||||
break;
|
||||
case 'pl':
|
||||
messages = (await import('../locales/pl/common.json')).default;
|
||||
break;
|
||||
case 'pt':
|
||||
messages = (await import('../locales/pt/common.json')).default;
|
||||
break;
|
||||
case 'ru':
|
||||
messages = (await import('../locales/ru/common.json')).default;
|
||||
break;
|
||||
case 'zh':
|
||||
messages = (await import('../locales/zh/common.json')).default;
|
||||
break;
|
||||
default:
|
||||
messages = (await import('../locales/en/common.json')).default;
|
||||
}
|
||||
@@ -45,4 +57,4 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
timeZone: 'Europe/Paris',
|
||||
now: new Date()
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'nl', 'pt', 'ru'],
|
||||
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'zh'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix: 'never'
|
||||
});
|
||||
|
||||
@@ -45,4 +45,43 @@ describe('calendar event normalization', () => {
|
||||
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 type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import {
|
||||
buildTimedFullDayWeekSegments,
|
||||
buildWeekSegments,
|
||||
buildAllDayDuration,
|
||||
getEventDayBounds,
|
||||
getEventDisplayEndDate,
|
||||
getEventEndDate,
|
||||
getEventStartDate,
|
||||
getTimedEventBoundsForDay,
|
||||
isTimedEventFullDayOnDate,
|
||||
layoutOverlappingEvents,
|
||||
normalizeAllDayDuration,
|
||||
} from '../calendar-utils';
|
||||
|
||||
@@ -92,7 +97,110 @@ describe('calendar-utils all-day handling', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -148,3 +148,46 @@ describe('toast bridge', () => {
|
||||
expect(api.toast.warning).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
|
||||
describe('http.post path validation', () => {
|
||||
function makeApi(permissions: string[] = ['http:post']) {
|
||||
return createPluginAPI(makePlugin({ permissions }));
|
||||
}
|
||||
|
||||
it('rejects protocol-relative URLs like //evil.example', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('//evil.example/collect', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects absolute URLs to other origins', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('https://evil.example/steal', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects paths not under /api/', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('/other/path', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects paths that use backslash to bypass the check', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('/api/\\@evil.example', {})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws without http:post permission', async () => {
|
||||
const api = makeApi([]);
|
||||
await expect(api.http.post('/api/jitsi', {})).rejects.toThrow('lacks permission');
|
||||
});
|
||||
|
||||
it('accepts a valid /api/ path', async () => {
|
||||
const api = makeApi();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ url: 'https://meet.example.com/room' }),
|
||||
});
|
||||
const result = await api.http.post('/api/jitsi', { eventTitle: 'test' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data).toEqual({ url: 'https://meet.example.com/room' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,8 @@ function resetStore() {
|
||||
'settings-section': [],
|
||||
'context-menu-email': [],
|
||||
'navigation-rail-bottom': [],
|
||||
'calendar-event-actions': [],
|
||||
'admin-plugin-page': [],
|
||||
},
|
||||
initialized: false,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
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');
|
||||
});
|
||||
|
||||
it('skips server-returned override events that belong to a recurring master', () => {
|
||||
// Server returns the master event plus override instances with recurrenceId set
|
||||
const master = makeEvent({
|
||||
id: 'master1',
|
||||
uid: 'shared-uid',
|
||||
start: '2025-01-06T09:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
});
|
||||
const override1 = makeEvent({
|
||||
id: 'override1',
|
||||
uid: 'shared-uid',
|
||||
start: '2025-01-13T09:00:00',
|
||||
recurrenceId: '2025-01-13T09:00:00',
|
||||
recurrenceRules: null,
|
||||
});
|
||||
const override2 = makeEvent({
|
||||
id: 'override2',
|
||||
uid: 'shared-uid',
|
||||
start: '2025-01-20T09:00:00',
|
||||
recurrenceId: '2025-01-20T09:00:00',
|
||||
recurrenceRules: null,
|
||||
});
|
||||
|
||||
const result = expandRecurringEvents(
|
||||
[master, override1, override2],
|
||||
'2025-01-06T00:00:00',
|
||||
'2025-01-27T00:00:00',
|
||||
);
|
||||
|
||||
// Should have 3 weekly occurrences from master expansion only (Jan 6, 13, 20)
|
||||
// Override events should be skipped since they share the master's UID
|
||||
expect(result).toHaveLength(3);
|
||||
expect(starts(result)).toEqual([
|
||||
'2025-01-06T09:00:00',
|
||||
'2025-01-13T09:00:00',
|
||||
'2025-01-20T09:00:00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps override events whose UID has no matching master', () => {
|
||||
// Standalone override event with no master in the batch
|
||||
const orphanOverride = makeEvent({
|
||||
id: 'orphan1',
|
||||
uid: 'orphan-uid',
|
||||
start: '2025-01-13T09:00:00',
|
||||
recurrenceId: '2025-01-13T09:00:00',
|
||||
recurrenceRules: null,
|
||||
});
|
||||
|
||||
const result = expandRecurringEvents(
|
||||
[orphanOverride],
|
||||
'2025-01-06T00:00:00',
|
||||
'2025-01-27T00:00:00',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('orphan1');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// utcStart computation for occurrences
|
||||
// -----------------------------------------------------------------------
|
||||
describe('utcStart per occurrence', () => {
|
||||
it('computes distinct utcStart for each weekly occurrence', () => {
|
||||
const event = makeEvent({
|
||||
start: '2026-09-01T12:00:00',
|
||||
utcStart: '2026-09-01T10:00:00Z',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
});
|
||||
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
|
||||
const utcStarts = result.map(e => (e as any).utcStart);
|
||||
// Each occurrence should have a unique utcStart
|
||||
expect(new Set(utcStarts).size).toBe(result.length);
|
||||
// First occurrence keeps master's UTC offset relationship
|
||||
expect(utcStarts[0]).toContain('2026-09-01');
|
||||
expect(utcStarts[1]).toContain('2026-09-08');
|
||||
expect(utcStarts[2]).toContain('2026-09-15');
|
||||
});
|
||||
|
||||
it('computes distinct utcEnd for each weekly occurrence', () => {
|
||||
const event = makeEvent({
|
||||
start: '2026-09-01T12:00:00',
|
||||
duration: 'PT1H',
|
||||
utcStart: '2026-09-01T10:00:00Z',
|
||||
utcEnd: '2026-09-01T11:00:00Z',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
});
|
||||
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
|
||||
const utcEnds = result.map(e => (e as any).utcEnd);
|
||||
// Each occurrence should have a unique utcEnd
|
||||
expect(new Set(utcEnds).size).toBe(result.length);
|
||||
expect(utcEnds[0]).toContain('2026-09-01');
|
||||
expect(utcEnds[1]).toContain('2026-09-08');
|
||||
expect(utcEnds[2]).toContain('2026-09-15');
|
||||
});
|
||||
|
||||
it('does not set utcStart when master has none', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-06T09:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
|
||||
expect(result[0].utcStart).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useSmimeStore } from '@/stores/smime-store';
|
||||
|
||||
// Minimal snapshot shapes — we only capture what we need
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -117,6 +118,7 @@ export function clearAllStores(): void {
|
||||
useVacationStore.getState().clearState();
|
||||
useCalendarStore.getState().clearState();
|
||||
useFilterStore.getState().clearState();
|
||||
useSmimeStore.getState().clearState();
|
||||
}
|
||||
|
||||
/** 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 ───────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -28,6 +38,7 @@ export interface ServerPlugin {
|
||||
entrypoint: string;
|
||||
enabled: boolean;
|
||||
forceEnabled?: boolean;
|
||||
configSchema?: Record<string, PluginConfigField>;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
+22
-1
@@ -8,9 +8,17 @@ const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
const MIN_SECRET_LENGTH = 32;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -116,11 +124,24 @@ export async function clearAdminSessionCookie(): Promise<void> {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
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';
|
||||
}
|
||||
|
||||
+9
-1
@@ -25,6 +25,7 @@ export interface SettingRestriction {
|
||||
export interface FeatureGates {
|
||||
pluginsEnabled: boolean;
|
||||
pluginsUploadEnabled: boolean;
|
||||
requirePluginApproval: boolean;
|
||||
themesEnabled: boolean;
|
||||
sidebarAppsEnabled: boolean;
|
||||
userThemesEnabled: boolean;
|
||||
@@ -37,11 +38,13 @@ export interface FeatureGates {
|
||||
debugModeEnabled: boolean;
|
||||
folderIconsEnabled: boolean;
|
||||
hoverActionsConfigEnabled: boolean;
|
||||
filesEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
pluginsEnabled: true,
|
||||
pluginsEnabled: false,
|
||||
pluginsUploadEnabled: true,
|
||||
requirePluginApproval: true,
|
||||
themesEnabled: true,
|
||||
sidebarAppsEnabled: true,
|
||||
userThemesEnabled: true,
|
||||
@@ -54,6 +57,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
debugModeEnabled: true,
|
||||
folderIconsEnabled: true,
|
||||
hoverActionsConfigEnabled: true,
|
||||
filesEnabled: true,
|
||||
};
|
||||
|
||||
export interface ThemePolicy {
|
||||
@@ -78,6 +82,8 @@ export interface SettingsPolicy {
|
||||
themePolicy: ThemePolicy;
|
||||
/** Plugin IDs that are force-enabled (users cannot disable) */
|
||||
forceEnabledPlugins: string[];
|
||||
/** Plugin IDs that have been approved by admin (users can enable) */
|
||||
approvedPlugins: string[];
|
||||
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
||||
forceEnabledThemes: string[];
|
||||
}
|
||||
@@ -88,6 +94,7 @@ export const DEFAULT_POLICY: SettingsPolicy = {
|
||||
defaults: {},
|
||||
themePolicy: { ...DEFAULT_THEME_POLICY },
|
||||
forceEnabledPlugins: [],
|
||||
approvedPlugins: [],
|
||||
forceEnabledThemes: [],
|
||||
};
|
||||
|
||||
@@ -120,6 +127,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
|
||||
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', 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 },
|
||||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user