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 |
@@ -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,5 +1,77 @@
|
|||||||
# Changelog
|
# 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)
|
## 1.4.10 (2026-03-31)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+14
-3
@@ -1,9 +1,9 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
|
<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%20Logo%20with%20Lettering%20Dark%20Color.svg" />
|
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||||
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
|
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -12,6 +12,17 @@
|
|||||||
|
|
||||||
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
|
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
|
## Getting Started
|
||||||
|
|
||||||
### Development Setup
|
### Development Setup
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
|
|||||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
|
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
|
|
||||||
type PendingScopeAction =
|
type PendingScopeAction =
|
||||||
@@ -69,10 +71,11 @@ export default function CalendarPage() {
|
|||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
|
const contacts = useContactStore((s) => s.contacts);
|
||||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||||
|
|
||||||
const currentUserEmails = useMemo(() =>
|
const currentUserEmails = useMemo(() =>
|
||||||
@@ -148,6 +151,13 @@ export default function CalendarPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [client, refreshAllSubscriptions]);
|
}, [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 dateRange = useMemo(() => {
|
||||||
const d = selectedDate;
|
const d = selectedDate;
|
||||||
switch (normalizedViewMode) {
|
switch (normalizedViewMode) {
|
||||||
@@ -743,14 +753,31 @@ export default function CalendarPage() {
|
|||||||
return () => window.removeEventListener("keydown", handleKey);
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
|
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
|
||||||
|
|
||||||
const visibleEvents = useMemo(() =>
|
const birthdayEvents = useMemo(() => {
|
||||||
events.filter((e) => {
|
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;
|
if (!e.start || !e.calendarIds) return false;
|
||||||
const calIds = Object.keys(e.calendarIds);
|
const calIds = Object.keys(e.calendarIds);
|
||||||
return calIds.some((id) => selectedCalendarIds.includes(id));
|
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(() => {
|
useEffect(() => {
|
||||||
const hiddenEvents = events.filter((event) => {
|
const hiddenEvents = events.filter((event) => {
|
||||||
@@ -765,7 +792,7 @@ export default function CalendarPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('Calendar visibility summary', {
|
debug.log('calendar', 'Calendar visibility summary', {
|
||||||
totalEvents: events.length,
|
totalEvents: events.length,
|
||||||
visibleEvents: visibleEvents.length,
|
visibleEvents: visibleEvents.length,
|
||||||
hiddenEvents: hiddenEvents.length,
|
hiddenEvents: hiddenEvents.length,
|
||||||
@@ -799,7 +826,7 @@ export default function CalendarPage() {
|
|||||||
<CalendarMonthView
|
<CalendarMonthView
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={allCalendars}
|
||||||
onSelectDate={handleSelectDate}
|
onSelectDate={handleSelectDate}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
@@ -815,7 +842,7 @@ export default function CalendarPage() {
|
|||||||
<CalendarWeekView
|
<CalendarWeekView
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={allCalendars}
|
||||||
onSelectDate={handleSelectDate}
|
onSelectDate={handleSelectDate}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
@@ -834,7 +861,7 @@ export default function CalendarPage() {
|
|||||||
<CalendarDayView
|
<CalendarDayView
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={allCalendars}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
@@ -851,7 +878,7 @@ export default function CalendarPage() {
|
|||||||
<CalendarAgendaView
|
<CalendarAgendaView
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={allCalendars}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
@@ -942,10 +969,14 @@ export default function CalendarPage() {
|
|||||||
showWeekNumbers={showWeekNumbers}
|
showWeekNumbers={showWeekNumbers}
|
||||||
/>
|
/>
|
||||||
<CalendarSidebarPanel
|
<CalendarSidebarPanel
|
||||||
calendars={calendars}
|
calendars={allCalendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
onToggleVisibility={toggleCalendarVisibility}
|
onToggleVisibility={toggleCalendarVisibility}
|
||||||
onColorChange={client ? (calendarId, color) => {
|
onColorChange={client ? (calendarId, color) => {
|
||||||
|
if (calendarId === BIRTHDAY_CALENDAR_ID) {
|
||||||
|
updateSetting('birthdayCalendarColor', color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
|||||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
||||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||||
|
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||||
import { exportContacts } from "@/components/contacts/contact-export";
|
import { exportContacts } from "@/components/contacts/contact-export";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { toast } from "@/stores/toast-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 { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
@@ -72,12 +73,16 @@ export default function ContactsPage() {
|
|||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
|
renameAddressBook,
|
||||||
|
renameKeyword,
|
||||||
importContacts,
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
|
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
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 [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -274,7 +279,7 @@ export default function ContactsPage() {
|
|||||||
toast.success(t("toast.created"));
|
toast.success(t("toast.created"));
|
||||||
} else {
|
} else {
|
||||||
const localContact: ContactCard = {
|
const localContact: ContactCard = {
|
||||||
id: `local-${crypto.randomUUID()}`,
|
id: `local-${generateUUID()}`,
|
||||||
addressBookIds: {},
|
addressBookIds: {},
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
@@ -631,6 +636,8 @@ export default function ContactsPage() {
|
|||||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
onDropContactsToCategory={handleDropContactsToCategory}
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
|
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||||
|
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
@@ -725,6 +732,46 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<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 && (
|
{showImportDialog && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<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">
|
<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 { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { FileBrowser } from "@/components/files/file-browser";
|
import { FileBrowser } from "@/components/files/file-browser";
|
||||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("files");
|
const t = useTranslations("files");
|
||||||
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
@@ -392,12 +395,25 @@ export default function FilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
{supportsFiles === false ? (
|
{!filesEnabled ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="max-w-lg text-center space-y-3 px-4">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||||
|
<p className="text-sm font-medium">{t("disabled_title")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : supportsFiles === false ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
|
||||||
|
</div>
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
resources={resources}
|
resources={resources}
|
||||||
@@ -442,6 +458,7 @@ export default function FilesPage() {
|
|||||||
onToggleDetails={handleToggleDetails}
|
onToggleDetails={handleToggleDetails}
|
||||||
detailResource={detailResource}
|
detailResource={detailResource}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import { useThemeStore } from "@/stores/theme-store";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { cn } from "@/lib/utils";
|
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 { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
|
|
||||||
const APP_VERSION = "1.4.10";
|
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 = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||||
@@ -24,6 +25,41 @@ const THEME_OPTIONS = [
|
|||||||
{ value: "system" as const, icon: Monitor, label: "System" },
|
{ 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() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
@@ -579,9 +615,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-center text-xs text-muted-foreground/40">
|
<VersionBadge />
|
||||||
v{APP_VERSION}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1075,9 +1109,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-center text-xs text-muted-foreground/40">
|
<VersionBadge />
|
||||||
v{APP_VERSION}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+123
-16
@@ -19,6 +19,7 @@ import { useUIStore } from "@/stores/ui-store";
|
|||||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
|
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -38,6 +39,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
|||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
|
import { useIdentitySync } from "@/hooks/use-identity-sync";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
@@ -76,6 +78,7 @@ export default function Home() {
|
|||||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
|
useIdentitySync();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRateLimited || !rateLimitUntil) {
|
if (!isRateLimited || !rateLimitUntil) {
|
||||||
@@ -143,6 +146,102 @@ export default function Home() {
|
|||||||
fetchEmailContent,
|
fetchEmailContent,
|
||||||
} = useEmailStore();
|
} = 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
|
// Keyboard shortcuts handlers
|
||||||
const keyboardHandlers = useMemo(() => ({
|
const keyboardHandlers = useMemo(() => ({
|
||||||
onNextEmail: () => {
|
onNextEmail: () => {
|
||||||
@@ -352,13 +451,13 @@ export default function Home() {
|
|||||||
|
|
||||||
if (pushEnabled) {
|
if (pushEnabled) {
|
||||||
setPushConnected(true);
|
setPushConnected(true);
|
||||||
debug.log('[Push] Push notifications successfully enabled');
|
debug.log('push', '[Push] Push notifications successfully enabled');
|
||||||
} else {
|
} else {
|
||||||
debug.log('[Push] Push notifications not available on this server');
|
debug.log('push', '[Push] Push notifications not available on this server');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Push notifications are optional - don't break the app if they fail
|
// Push notifications are optional - don't break the app if they fail
|
||||||
debug.log('[Push] Failed to setup push notifications:', error);
|
debug.log('push', '[Push] Failed to setup push notifications:', error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading email data:', error);
|
console.error('Error loading email data:', error);
|
||||||
@@ -392,7 +491,7 @@ export default function Home() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Clear any existing timeout when email changes
|
// Clear any existing timeout when email changes
|
||||||
if (markAsReadTimeoutRef.current) {
|
if (markAsReadTimeoutRef.current) {
|
||||||
debug.log('[Mark as Read] Clearing previous timeout');
|
debug.log('email', '[Mark as Read] Clearing previous timeout');
|
||||||
clearTimeout(markAsReadTimeoutRef.current);
|
clearTimeout(markAsReadTimeoutRef.current);
|
||||||
markAsReadTimeoutRef.current = null;
|
markAsReadTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -404,20 +503,20 @@ export default function Home() {
|
|||||||
|
|
||||||
// Get current setting value
|
// Get current setting value
|
||||||
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
||||||
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
debug.log('email', '[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
||||||
|
|
||||||
if (markAsReadDelay === -1) {
|
if (markAsReadDelay === -1) {
|
||||||
// Never mark as read automatically
|
// Never mark as read automatically
|
||||||
debug.log('[Mark as Read] Never mode - email will stay unread');
|
debug.log('email', '[Mark as Read] Never mode - email will stay unread');
|
||||||
} else if (markAsReadDelay === 0) {
|
} else if (markAsReadDelay === 0) {
|
||||||
// Mark as read instantly
|
// Mark as read instantly
|
||||||
debug.log('[Mark as Read] Instant mode - marking as read now');
|
debug.log('email', '[Mark as Read] Instant mode - marking as read now');
|
||||||
markAsRead(client, selectedEmail.id, true);
|
markAsRead(client, selectedEmail.id, true);
|
||||||
} else {
|
} else {
|
||||||
// Mark as read after delay
|
// Mark as read after delay
|
||||||
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
|
debug.log('email', '[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
|
||||||
markAsReadTimeoutRef.current = setTimeout(() => {
|
markAsReadTimeoutRef.current = setTimeout(() => {
|
||||||
debug.log('[Mark as Read] Timeout fired - marking as read now');
|
debug.log('email', '[Mark as Read] Timeout fired - marking as read now');
|
||||||
markAsRead(client, selectedEmail.id, true);
|
markAsRead(client, selectedEmail.id, true);
|
||||||
markAsReadTimeoutRef.current = null;
|
markAsReadTimeoutRef.current = null;
|
||||||
}, markAsReadDelay);
|
}, markAsReadDelay);
|
||||||
@@ -426,7 +525,7 @@ export default function Home() {
|
|||||||
// Cleanup on unmount or when dependencies change
|
// Cleanup on unmount or when dependencies change
|
||||||
return () => {
|
return () => {
|
||||||
if (markAsReadTimeoutRef.current) {
|
if (markAsReadTimeoutRef.current) {
|
||||||
debug.log('[Mark as Read] Cleanup - clearing timeout');
|
debug.log('email', '[Mark as Read] Cleanup - clearing timeout');
|
||||||
clearTimeout(markAsReadTimeoutRef.current);
|
clearTimeout(markAsReadTimeoutRef.current);
|
||||||
markAsReadTimeoutRef.current = null;
|
markAsReadTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -441,7 +540,7 @@ export default function Home() {
|
|||||||
if (emailNotificationsEnabled && emailNotificationSound) {
|
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||||
playNotificationSound(notificationSoundChoice);
|
playNotificationSound(notificationSoundChoice);
|
||||||
}
|
}
|
||||||
debug.log('New email received:', newEmailNotification.subject);
|
debug.log('email', 'New email received:', newEmailNotification.subject);
|
||||||
clearNewEmailNotification();
|
clearNewEmailNotification();
|
||||||
}
|
}
|
||||||
}, [newEmailNotification, clearNewEmailNotification]);
|
}, [newEmailNotification, clearNewEmailNotification]);
|
||||||
@@ -1018,9 +1117,16 @@ 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 = () => {
|
const handleMobileBack = () => {
|
||||||
// If in conversation view, clear it
|
if (typeof window !== 'undefined') {
|
||||||
|
window.history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (conversationThread) {
|
if (conversationThread) {
|
||||||
setConversationThread(null);
|
setConversationThread(null);
|
||||||
setConversationEmails([]);
|
setConversationEmails([]);
|
||||||
@@ -1209,11 +1315,12 @@ export default function Home() {
|
|||||||
"max-md:flex-1 max-md:border-r-0",
|
"max-md:flex-1 max-md:border-r-0",
|
||||||
isMobile && activeView !== "list" && "max-md:hidden",
|
isMobile && activeView !== "list" && "max-md:hidden",
|
||||||
// Tablet/Desktop: fixed width with collapse animation
|
// 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",
|
!isResizing && "transition-all duration-200 ease-out",
|
||||||
shouldCollapseListPane && "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 && !shouldCollapseListPane ? { width: emailListWidth } : undefined}
|
style={!isMobile && !shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined}
|
||||||
>
|
>
|
||||||
{/* Mobile Header for List View */}
|
{/* Mobile Header for List View */}
|
||||||
<MobileHeader
|
<MobileHeader
|
||||||
@@ -1653,7 +1760,7 @@ export default function Home() {
|
|||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onArchive={handleArchive}
|
onArchive={() => handleArchive()}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onSetColorTag={handleSetColorTag}
|
onSetColorTag={handleSetColorTag}
|
||||||
onMarkAsSpam={handleMarkAsSpam}
|
onMarkAsSpam={handleMarkAsSpam}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { IdentitySettings } from '@/components/settings/identity-settings';
|
|||||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||||
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
||||||
import { CalendarManagementSettings } from '@/components/settings/calendar-management-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 { FilterSettings } from '@/components/settings/filter-settings';
|
||||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||||
@@ -211,7 +212,7 @@ export default function SettingsPage() {
|
|||||||
{activeTab === 'encryption' && <SmimeSettings />}
|
{activeTab === 'encryption' && <SmimeSettings />}
|
||||||
{activeTab === 'vacation' && <VacationSettings />}
|
{activeTab === 'vacation' && <VacationSettings />}
|
||||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
{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 === 'filters' && <FilterSettings />}
|
||||||
{activeTab === 'templates' && <TemplateSettings />}
|
{activeTab === 'templates' && <TemplateSettings />}
|
||||||
{activeTab === 'folders' && <FolderSettings />}
|
{activeTab === 'folders' && <FolderSettings />}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
@@ -78,13 +79,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
function getJmapHeaders(): Record<string, string> {
|
function getJmapHeaders(): Record<string, string> {
|
||||||
const client = useAuthStore.getState().client;
|
return getActiveAccountSlotHeaders();
|
||||||
if (!client) return {};
|
|
||||||
return {
|
|
||||||
'Authorization': client.getAuthHeader(),
|
|
||||||
'X-JMAP-Server-URL': client.getServerUrl(),
|
|
||||||
'X-JMAP-Username': client.getUsername(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export default function AdminPluginsPage() {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleRequirePluginApproval() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSavePolicy() {
|
async function handleSavePolicy() {
|
||||||
setSavingPolicy(true);
|
setSavingPolicy(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
@@ -248,6 +257,7 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||||
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||||
|
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -320,6 +330,17 @@ export default function AdminPluginsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Force enable / disable all */}
|
||||||
{plugins.length > 0 && (
|
{plugins.length > 0 && (
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
|||||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||||
|
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESTRICTABLE_SETTINGS = [
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
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
|
* GET /api/account/stalwart/auth
|
||||||
* Proxy to Stalwart GET /api/account/auth
|
* Proxy to Stalwart GET /api/account/auth
|
||||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch auth info', details: text },
|
{ error: detail || 'Failed to fetch auth info' },
|
||||||
{ status: response.status }
|
{ status: response.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
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
|
* GET /api/account/stalwart/crypto
|
||||||
* Proxy to Stalwart GET /api/account/crypto
|
* Proxy to Stalwart GET /api/account/crypto
|
||||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch crypto info', details: text },
|
{ error: detail || 'Failed to fetch crypto info' },
|
||||||
{ status: response.status }
|
{ status: response.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { encryptSession } from '@/lib/auth/crypto';
|
import { encryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -69,10 +70,19 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If session cookie exists, update it with the new password
|
// If session cookie exists, update it with the new password
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
if (creds.hasSessionCookie) {
|
if (creds.hasSessionCookie) {
|
||||||
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
||||||
const cookieStore = await cookies();
|
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
|
||||||
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
|
}
|
||||||
|
|
||||||
|
if (creds.authHeader.startsWith('Basic ')) {
|
||||||
|
setStalwartAuthContextInStore(cookieStore, creds.slot, {
|
||||||
|
serverUrl: creds.serverUrl,
|
||||||
|
username: creds.username,
|
||||||
|
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
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
|
* GET /api/account/stalwart/principal
|
||||||
* Proxy to Stalwart GET /api/principal/{username}
|
* Proxy to Stalwart GET /api/principal/{username}
|
||||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch principal', details: text },
|
{ error: detail || 'Failed to fetch principal' },
|
||||||
{ status: response.status }
|
{ status: response.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const code = await jsFile.async('string');
|
const code = await jsFile.async('string');
|
||||||
|
|
||||||
|
// Block plugins with dangerous JS patterns
|
||||||
|
const DANGEROUS_JS_PATTERNS = [
|
||||||
|
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||||
|
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||||
|
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||||
|
{ pattern: /document\.write/g, label: 'document.write' },
|
||||||
|
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||||
|
];
|
||||||
|
const dangerousFindings: string[] = [];
|
||||||
|
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
|
||||||
|
if (pattern.test(code)) dangerousFindings.push(label);
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
}
|
||||||
|
if (dangerousFindings.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate permissions
|
// Validate permissions
|
||||||
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
||||||
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||||
|
|||||||
@@ -139,12 +139,18 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
const code = await entryFile.async('string');
|
const code = await entryFile.async('string');
|
||||||
|
|
||||||
// Security warnings (logged but not blocking for admin)
|
// Security: block plugins containing dangerous JS patterns
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||||
pattern.lastIndex = 0;
|
pattern.lastIndex = 0;
|
||||||
}
|
}
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
@@ -165,9 +171,9 @@ export async function POST(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip);
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ plugin, warnings });
|
return NextResponse.json({ plugin });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { logger } from '@/lib/logger';
|
|||||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
|
import {
|
||||||
|
clearStalwartAuthContextInStore,
|
||||||
|
setStalwartAuthContextInStore,
|
||||||
|
} from '@/lib/stalwart/auth-context';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
const cookieName = sessionCookieName(slot);
|
const cookieName = sessionCookieName(slot);
|
||||||
const token = encryptSession(serverUrl, username, password);
|
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||||
|
const token = encryptSession(normalizedServerUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: normalizedServerUrl,
|
||||||
|
username,
|
||||||
|
authHeader,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
@@ -56,9 +72,16 @@ export async function GET(request: NextRequest) {
|
|||||||
const credentials = decryptSession(token);
|
const credentials = decryptSession(token);
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
cookieStore.delete(cookieName);
|
cookieStore.delete(cookieName);
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: credentials.serverUrl,
|
||||||
|
username: credentials.username,
|
||||||
|
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||||
|
});
|
||||||
|
|
||||||
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
||||||
const { serverUrl, username } = credentials;
|
const { serverUrl, username } = credentials;
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -73,13 +96,17 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT — retrieve full credentials (including password) for session restoration.
|
* PUT — retrieve full credentials (including password) for session restoration.
|
||||||
* Protected by Sec-Fetch-Site to ensure only same-origin browser requests succeed.
|
* 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) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Block non-browser and cross-origin requests
|
// 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 secFetchSite = request.headers.get('sec-fetch-site');
|
||||||
if (secFetchSite !== 'same-origin') {
|
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 });
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,9 +122,16 @@ export async function PUT(request: NextRequest) {
|
|||||||
const credentials = decryptSession(token);
|
const credentials = decryptSession(token);
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
cookieStore.delete(cookieName);
|
cookieStore.delete(cookieName);
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: credentials.serverUrl,
|
||||||
|
username: credentials.username,
|
||||||
|
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(credentials, {
|
return NextResponse.json(credentials, {
|
||||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
||||||
});
|
});
|
||||||
@@ -116,10 +150,12 @@ export async function DELETE(request: NextRequest) {
|
|||||||
// Delete all session cookies (slots 0-4)
|
// Delete all session cookies (slots 0-4)
|
||||||
for (let i = 0; i <= 4; i++) {
|
for (let i = 0; i <= 4; i++) {
|
||||||
cookieStore.delete(sessionCookieName(i));
|
cookieStore.delete(sessionCookieName(i));
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, i);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const slot = getSlot(request);
|
const slot = getSlot(request);
|
||||||
cookieStore.delete(sessionCookieName(slot));
|
cookieStore.delete(sessionCookieName(slot));
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
|
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
|
|
||||||
|
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||||
|
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||||
|
return bodySlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
|
if (raw === null) return 0;
|
||||||
|
|
||||||
|
const slot = parseInt(raw, 10);
|
||||||
|
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
|
if (!serverUrl || !username || !authHeader) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request, bodySlot);
|
||||||
|
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||||
|
|
||||||
|
await setStalwartAuthContext(slot, {
|
||||||
|
serverUrl: normalizedServerUrl,
|
||||||
|
username,
|
||||||
|
authHeader,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Failed to store Stalwart auth context', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
-21
@@ -1,9 +1,39 @@
|
|||||||
|
import { lookup } from 'node:dns/promises';
|
||||||
|
import { BlockList, isIP } from 'node:net';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
function isValidExternalUrl(urlString: string): boolean {
|
const blockedAddressRanges = new BlockList();
|
||||||
|
blockedAddressRanges.addAddress('0.0.0.0');
|
||||||
|
blockedAddressRanges.addAddress('127.0.0.1');
|
||||||
|
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
||||||
|
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
||||||
|
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
||||||
|
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
||||||
|
blockedAddressRanges.addAddress('::', 'ipv6');
|
||||||
|
blockedAddressRanges.addAddress('::1', 'ipv6');
|
||||||
|
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
||||||
|
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
||||||
|
|
||||||
|
function normalizeHostname(hostname: string): string {
|
||||||
|
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpAddress(hostname: string): boolean {
|
||||||
|
const normalized = normalizeHostname(hostname);
|
||||||
|
const family = isIP(normalized);
|
||||||
|
if (family === 4) {
|
||||||
|
return blockedAddressRanges.check(normalized, 'ipv4');
|
||||||
|
}
|
||||||
|
if (family === 6) {
|
||||||
|
return blockedAddressRanges.check(normalized, 'ipv6');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isValidExternalUrl(urlString: string): Promise<boolean> {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
url = new URL(urlString);
|
url = new URL(urlString);
|
||||||
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostname = url.hostname.toLowerCase();
|
const hostname = normalizeHostname(url.hostname);
|
||||||
|
|
||||||
// Block private/internal hostnames
|
// Block private/internal hostnames
|
||||||
if (
|
if (
|
||||||
hostname === 'localhost' ||
|
hostname === 'localhost' ||
|
||||||
hostname === '127.0.0.1' ||
|
hostname.endsWith('.localhost') ||
|
||||||
hostname === '::1' ||
|
|
||||||
hostname === '0.0.0.0' ||
|
|
||||||
hostname.endsWith('.local') ||
|
hostname.endsWith('.local') ||
|
||||||
hostname.endsWith('.internal') ||
|
hostname.endsWith('.internal') ||
|
||||||
hostname.endsWith('.arpa') ||
|
hostname.endsWith('.arpa') ||
|
||||||
hostname.startsWith('10.') ||
|
hostname.endsWith('.localdomain')
|
||||||
hostname.startsWith('192.168.') ||
|
|
||||||
hostname.startsWith('169.254.') ||
|
|
||||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
if (isBlockedIpAddress(hostname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIP(hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const records = await lookup(hostname, { all: true, verbatim: true });
|
||||||
|
if (records.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return records.every((record) => !isBlockedIpAddress(record.address));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidExternalUrl(url)) {
|
if (!(await isValidExternalUrl(url))) {
|
||||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,20 +106,43 @@ export async function POST(request: NextRequest) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const MAX_REDIRECTS = 5;
|
||||||
signal: controller.signal,
|
let currentUrl = url;
|
||||||
headers: {
|
let response: Response | undefined;
|
||||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
|
||||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||||
},
|
if (!(await isValidExternalUrl(currentUrl))) {
|
||||||
redirect: 'follow',
|
clearTimeout(timeout);
|
||||||
});
|
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await fetch(currentUrl, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||||
|
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||||
|
},
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (!location) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||||
|
}
|
||||||
|
// Resolve relative redirects
|
||||||
|
currentUrl = new URL(location, currentUrl).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response || !response.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Remote server returned ${response.status}` },
|
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { cookies } from 'next/headers';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { decryptSession } from '@/lib/auth/crypto';
|
import { decryptSession } from '@/lib/auth/crypto';
|
||||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
|
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
@@ -50,21 +51,36 @@ function isEnabled(): boolean {
|
|||||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
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.
|
* Verify identity against session cookies across all account slots.
|
||||||
* With multi-account, the requesting account may be on any slot (0-4).
|
* With multi-account, the requesting account may be on any slot (0-4).
|
||||||
* Returns true only if a matching session cookie is found.
|
* 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> {
|
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
const normalizedServerUrl = normalizeUrl(serverUrl);
|
||||||
|
|
||||||
for (let slot = 0; slot <= 4; slot++) {
|
for (let slot = 0; slot <= 4; slot++) {
|
||||||
|
// Check basic-auth session cookie
|
||||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||||
if (!token) continue;
|
if (token) {
|
||||||
|
const session = decryptSession(token);
|
||||||
|
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const session = decryptSession(token);
|
// Check stalwart auth context cookie (set for all auth modes)
|
||||||
if (session && session.username === username && session.serverUrl === serverUrl) {
|
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||||
return true; // Found a matching slot
|
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-12
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|||||||
|
|
||||||
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
|
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
|
||||||
|
|
||||||
|
function normalizeDavRelativePath(rawPath: string): string {
|
||||||
|
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
|
||||||
|
const segments = sanitized.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
return segments.map((segment) => {
|
||||||
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(segment);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid WebDAV path encoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
|
||||||
|
throw new Error('Invalid WebDAV path segment');
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeURIComponent(decoded);
|
||||||
|
}).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
|
||||||
|
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
|
||||||
|
const relativePath = normalizeDavRelativePath(rawPath);
|
||||||
|
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/webdav
|
* POST /api/webdav
|
||||||
* Proxies WebDAV requests to the Stalwart server.
|
* Proxies WebDAV requests to the Stalwart server.
|
||||||
@@ -29,11 +55,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
||||||
const cleanPath = davPath.replace(/^\/+/, '');
|
|
||||||
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
||||||
const targetUrl = cleanPath
|
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
||||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
|
|
||||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
|
||||||
|
|
||||||
// Build headers for the upstream request
|
// Build headers for the upstream request
|
||||||
const upstreamHeaders: Record<string, string> = {
|
const upstreamHeaders: Record<string, string> = {
|
||||||
@@ -50,19 +73,20 @@ export async function POST(request: NextRequest) {
|
|||||||
// For MOVE/COPY, construct the full Destination URL from the relative path
|
// For MOVE/COPY, construct the full Destination URL from the relative path
|
||||||
const destination = request.headers.get('X-WebDAV-Destination');
|
const destination = request.headers.get('X-WebDAV-Destination');
|
||||||
if (destination) {
|
if (destination) {
|
||||||
const cleanDest = destination.replace(/^\/+/, '');
|
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
|
||||||
upstreamHeaders['Destination'] = cleanDest
|
|
||||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
|
|
||||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const overwrite = request.headers.get('Overwrite');
|
const overwrite = request.headers.get('Overwrite');
|
||||||
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
|
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
|
||||||
|
|
||||||
// Forward request body for methods that need it
|
// Forward request body for methods that need it.
|
||||||
let body: ArrayBuffer | null = null;
|
// PUT streams directly to upstream to avoid buffering large uploads in memory.
|
||||||
if (method === 'PROPFIND' || method === 'PUT') {
|
// PROPFIND bodies are small XML and are read fully.
|
||||||
|
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
|
||||||
|
if (method === 'PROPFIND') {
|
||||||
body = await request.arrayBuffer();
|
body = await request.arrayBuffer();
|
||||||
|
} else if (method === 'PUT') {
|
||||||
|
body = request.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(targetUrl, {
|
const response = await fetch(targetUrl, {
|
||||||
@@ -70,7 +94,9 @@ export async function POST(request: NextRequest) {
|
|||||||
headers: upstreamHeaders,
|
headers: upstreamHeaders,
|
||||||
body,
|
body,
|
||||||
redirect: 'follow',
|
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
|
// For file downloads (GET), stream the response back
|
||||||
if (method === 'GET') {
|
if (method === 'GET') {
|
||||||
@@ -104,6 +130,10 @@ export async function POST(request: NextRequest) {
|
|||||||
status: response.status,
|
status: response.status,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale } from "next-intl/server";
|
||||||
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -20,6 +22,15 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
return {
|
return {
|
||||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
description: "Minimalist webmail client using JMAP protocol",
|
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 } } : {}),
|
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -36,6 +47,14 @@ export default async function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<head>
|
<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 && (
|
{parentOrigin && (
|
||||||
<meta name="parent-origin" content={parentOrigin} />
|
<meta name="parent-origin" content={parentOrigin} />
|
||||||
)}
|
)}
|
||||||
@@ -63,7 +82,9 @@ export default async function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
<ServiceWorkerRegistration />
|
||||||
{children}
|
{children}
|
||||||
|
<PWAInstallPrompt />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2 } from "lucide-react";
|
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
@@ -164,6 +165,9 @@ export function CalendarSidebarPanel({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{cal.id === BIRTHDAY_CALENDAR_ID && (
|
||||||
|
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Subscription context menu on right-click */}
|
{/* Subscription context menu on right-click */}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function formatDurationDisplay(minutes: number): string {
|
|||||||
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||||
if (!event.alerts) return null;
|
if (!event.alerts) return null;
|
||||||
const first = Object.values(event.alerts)[0];
|
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;
|
const offset = first.trigger.offset;
|
||||||
if (offset === "PT0S") return t("alerts.at_time");
|
if (offset === "PT0S") return t("alerts.at_time");
|
||||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -402,9 +403,7 @@ export function EventModal({
|
|||||||
if (!event || !onDuplicate) return;
|
if (!event || !onDuplicate) return;
|
||||||
const start = getEventStartDate(event);
|
const start = getEventStartDate(event);
|
||||||
const newStart = addDays(start, 1);
|
const newStart = addDays(start, 1);
|
||||||
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
const newUid = generateUUID();
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
||||||
const data: Partial<CalendarEvent> = {
|
const data: Partial<CalendarEvent> = {
|
||||||
uid: newUid,
|
uid: newUid,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
|
|||||||
@@ -304,11 +304,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
if (contact?.addressBookIds) {
|
if (contact?.addressBookIds) {
|
||||||
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
|
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
// For shared contacts, the addressBookIds uses the original (non-namespaced) id
|
// addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
|
||||||
// but we need the namespaced id to match addressBooks entries
|
// so we can use them directly to match addressBook entries
|
||||||
if (contact.isShared && contact.accountId) {
|
|
||||||
return `${contact.accountId}:${ids[0]}`;
|
|
||||||
}
|
|
||||||
return ids[0];
|
return ids[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
|
|||||||
onDeleteGroup?: (groupId: string) => void;
|
onDeleteGroup?: (groupId: string) => void;
|
||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||||
|
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||||
|
onRenameKeyword?: (keyword: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +61,15 @@ export function ContactsSidebar({
|
|||||||
onDeleteGroup,
|
onDeleteGroup,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
onDropContactsToCategory,
|
onDropContactsToCategory,
|
||||||
|
onRenameAddressBook,
|
||||||
|
onRenameKeyword,
|
||||||
className,
|
className,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
const router = useRouter();
|
||||||
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
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 [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
@@ -246,19 +254,32 @@ export function ContactsSidebar({
|
|||||||
{/* My Address Books */}
|
{/* My Address Books */}
|
||||||
{personalBooks.length > 0 && (
|
{personalBooks.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<div className="flex items-center px-3 py-1 group">
|
||||||
onClick={() => toggleSection("addressBooks")}
|
<button
|
||||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
onClick={() => toggleSection("addressBooks")}
|
||||||
>
|
className="flex items-center gap-1 flex-1 text-left"
|
||||||
{collapsed.addressBooks ? (
|
>
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
{collapsed.addressBooks ? (
|
||||||
) : (
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
<ChevronDown 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 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
</span>
|
{t("address_books.title")}
|
||||||
</button>
|
</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) => (
|
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||||
<AddressBookItem
|
<AddressBookItem
|
||||||
key={book.id}
|
key={book.id}
|
||||||
@@ -267,6 +288,7 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
|
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -362,6 +384,7 @@ export function ContactsSidebar({
|
|||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
onSelect={() => onSelectCategory({ keyword })}
|
onSelect={() => onSelectCategory({ keyword })}
|
||||||
onDropContacts={onDropContactsToCategory}
|
onDropContacts={onDropContactsToCategory}
|
||||||
|
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -372,20 +395,33 @@ export function ContactsSidebar({
|
|||||||
{/* Shared accounts with address books */}
|
{/* Shared accounts with address books */}
|
||||||
{sharedBookGroups.map((group) => (
|
{sharedBookGroups.map((group) => (
|
||||||
<div key={group.accountId} className="mt-2">
|
<div key={group.accountId} className="mt-2">
|
||||||
<button
|
<div className="flex items-center px-3 py-1 group">
|
||||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
<button
|
||||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
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" />
|
{collapsed[`shared-${group.accountId}`] ? (
|
||||||
) : (
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
<ChevronDown 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">
|
<Share2 className="w-3 h-3 text-muted-foreground" />
|
||||||
{t("address_books.shared_prefix", { name: group.accountName })}
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
||||||
</span>
|
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||||
</button>
|
</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) => (
|
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
||||||
<AddressBookItem
|
<AddressBookItem
|
||||||
key={book.id}
|
key={book.id}
|
||||||
@@ -394,12 +430,53 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
|
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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 */}
|
{/* Group context menu */}
|
||||||
{groupContextMenu.data && (
|
{groupContextMenu.data && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@@ -438,12 +515,14 @@ function CategoryItem({
|
|||||||
isActive,
|
isActive,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
count: number;
|
count: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onDropContacts?: (contactIds: string[], keyword: string) => void;
|
onDropContacts?: (contactIds: string[], keyword: string) => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
@@ -476,6 +555,7 @@ function CategoryItem({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
@@ -503,12 +583,14 @@ function AddressBookItem({
|
|||||||
contactCount,
|
contactCount,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
book: AddressBook;
|
book: AddressBook;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
contactCount: number;
|
contactCount: number;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
@@ -541,6 +623,7 @@ function AddressBookItem({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
|||||||
@@ -754,49 +754,67 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-4 py-3 space-y-2.5">
|
<div className="px-4 py-3 space-y-2.5">
|
||||||
{/* Event title */}
|
<div className="lg:flex lg:gap-6">
|
||||||
{summary?.title && (
|
{/* Left: Event info */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="lg:flex-1 space-y-2.5 min-w-0">
|
||||||
<h3 className={cn(
|
{/* Event title */}
|
||||||
"text-base font-semibold leading-snug",
|
{summary?.title && (
|
||||||
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
|
<div className="flex items-start justify-between gap-2">
|
||||||
)}>
|
<h3 className={cn(
|
||||||
{summary.title}
|
"text-base font-semibold leading-snug",
|
||||||
</h3>
|
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
|
||||||
{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">
|
{summary.title}
|
||||||
{t('event_updated', { sequence: parsedEvent.sequence })}
|
</h3>
|
||||||
</span>
|
{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 */}
|
{/* Event details */}
|
||||||
<div className="space-y-1">
|
<div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
|
||||||
{summary?.start && (
|
{summary?.start && (
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{formatDateTime(summary.start)}
|
{formatDateTime(summary.start)}
|
||||||
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{summary?.location && (
|
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
{/* Right: Info & actor messages on large screens */}
|
||||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
<div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
|
||||||
<span>{summary.location}</span>
|
{bannerInfo && (
|
||||||
</div>
|
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
{summary && summary.attendeeCount > 0 && (
|
{actorMessage && (
|
||||||
<span>{t('attendees', { count: summary.attendeeCount })}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -840,23 +858,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
</div>
|
</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 */}
|
{/* Proposed changes */}
|
||||||
{proposedChanges.length > 0 && (
|
{proposedChanges.length > 0 && (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
|
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
|
||||||
|
|||||||
@@ -34,9 +34,8 @@ import { RichTextEditor } from "@/components/email/rich-text-editor";
|
|||||||
|
|
||||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||||
function htmlToPlainText(html: string): string {
|
function htmlToPlainText(html: string): string {
|
||||||
const tmp = document.createElement('div');
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
tmp.innerHTML = html;
|
return doc.body.textContent || '';
|
||||||
return tmp.textContent || tmp.innerText || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComposerDraftData {
|
export interface ComposerDraftData {
|
||||||
@@ -332,6 +331,17 @@ export function EmailComposer({
|
|||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-focus the To field when composing a new email or forwarding
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'forward' || mode === 'compose') {
|
||||||
|
// Small delay to ensure the input is rendered
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
toInputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
||||||
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||||
@@ -339,10 +349,26 @@ export function EmailComposer({
|
|||||||
const toInputRef = useRef<HTMLInputElement>(null);
|
const toInputRef = useRef<HTMLInputElement>(null);
|
||||||
const ccInputRef = useRef<HTMLInputElement>(null);
|
const ccInputRef = useRef<HTMLInputElement>(null);
|
||||||
const bccInputRef = useRef<HTMLInputElement>(null);
|
const bccInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const subjectInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const toDropdownRef = useRef<HTMLDivElement>(null);
|
const toDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const ccDropdownRef = useRef<HTMLDivElement>(null);
|
const ccDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const bccDropdownRef = useRef<HTMLDivElement>(null);
|
const bccDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const focusSubject = useCallback(() => {
|
||||||
|
subjectInputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const focusBody = useCallback(() => {
|
||||||
|
if (plainTextMode) {
|
||||||
|
bodyRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null;
|
||||||
|
proseMirror?.focus();
|
||||||
|
}
|
||||||
|
}, [plainTextMode]);
|
||||||
|
|
||||||
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
|
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
|
||||||
if (autocompleteTimeoutRef.current) {
|
if (autocompleteTimeoutRef.current) {
|
||||||
clearTimeout(autocompleteTimeoutRef.current);
|
clearTimeout(autocompleteTimeoutRef.current);
|
||||||
@@ -506,17 +532,18 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
|
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
|
||||||
if (!client) return null;
|
return new Promise((resolve) => {
|
||||||
try {
|
const reader = new FileReader();
|
||||||
const { blobId } = await client.uploadBlob(file);
|
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||||
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
|
reader.onerror = () => {
|
||||||
} catch (error) {
|
debug.error(`Failed to read inline image ${file.name}`);
|
||||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
toast.error(t('upload_failed', { filename: file.name }));
|
||||||
toast.error(t('upload_failed', { filename: file.name }));
|
resolve(null);
|
||||||
return null;
|
};
|
||||||
}
|
reader.readAsDataURL(file);
|
||||||
}, [client, t]);
|
});
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!event.target.files) return;
|
if (!event.target.files) return;
|
||||||
@@ -1071,6 +1098,7 @@ export function EmailComposer({
|
|||||||
onInsertAutocomplete={insertAutocomplete}
|
onInsertAutocomplete={insertAutocomplete}
|
||||||
validationError={validationErrors.to}
|
validationError={validationErrors.to}
|
||||||
validationMessage={t('validation.recipient_required')}
|
validationMessage={t('validation.recipient_required')}
|
||||||
|
onTab={focusSubject}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-0.5 shrink-0">
|
<div className="flex gap-0.5 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
@@ -1140,6 +1168,7 @@ export function EmailComposer({
|
|||||||
<div className="flex items-center gap-2 px-4 py-2.5">
|
<div className="flex items-center gap-2 px-4 py-2.5">
|
||||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
|
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
|
||||||
<Input
|
<Input
|
||||||
|
ref={subjectInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('subject_placeholder')}
|
placeholder={t('subject_placeholder')}
|
||||||
value={subject}
|
value={subject}
|
||||||
@@ -1147,6 +1176,12 @@ export function EmailComposer({
|
|||||||
setSubject(e.target.value);
|
setSubject(e.target.value);
|
||||||
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
|
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
focusBody();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
|
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
|
||||||
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
|
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
|
||||||
@@ -1159,6 +1194,7 @@ export function EmailComposer({
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
{plainTextMode ? (
|
{plainTextMode ? (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={bodyRef}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBody(e.target.value);
|
setBody(e.target.value);
|
||||||
@@ -1173,16 +1209,18 @@ export function EmailComposer({
|
|||||||
aria-invalid={validationErrors.body || undefined}
|
aria-invalid={validationErrors.body || undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RichTextEditor
|
<div ref={editorContainerRef}>
|
||||||
content={body}
|
<RichTextEditor
|
||||||
onChange={(html) => {
|
content={body}
|
||||||
setBody(html);
|
onChange={(html) => {
|
||||||
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
setBody(html);
|
||||||
}}
|
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||||
onImageUpload={handleImageUpload}
|
}}
|
||||||
placeholder={t('body_placeholder')}
|
onImageUpload={handleImageUpload}
|
||||||
hasError={validationErrors.body}
|
placeholder={t('body_placeholder')}
|
||||||
/>
|
hasError={validationErrors.body}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{plainTextMode ? (
|
{plainTextMode ? (
|
||||||
@@ -1514,6 +1552,7 @@ function RecipientChipInput({
|
|||||||
onInsertAutocomplete,
|
onInsertAutocomplete,
|
||||||
validationError,
|
validationError,
|
||||||
validationMessage,
|
validationMessage,
|
||||||
|
onTab,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@@ -1530,6 +1569,7 @@ function RecipientChipInput({
|
|||||||
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
|
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
|
||||||
validationError?: boolean;
|
validationError?: boolean;
|
||||||
validationMessage?: string;
|
validationMessage?: string;
|
||||||
|
onTab?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
|
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
const hasTrailingComma = value.trimEnd().endsWith(',');
|
const hasTrailingComma = value.trimEnd().endsWith(',');
|
||||||
@@ -1563,7 +1603,18 @@ function RecipientChipInput({
|
|||||||
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
|
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
|
||||||
if (e.key !== 'Tab') e.preventDefault();
|
if (e.key !== 'Tab') e.preventDefault();
|
||||||
commitCurrentInput();
|
commitCurrentInput();
|
||||||
setTimeout(() => inputRef.current?.focus(), 0);
|
if (e.key === 'Tab' && onTab) {
|
||||||
|
e.preventDefault();
|
||||||
|
setTimeout(() => onTab(), 0);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey && onTab) {
|
||||||
|
e.preventDefault();
|
||||||
|
onTab();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function EmailHoverActions({
|
|||||||
onToggleStar?.();
|
onToggleStar?.();
|
||||||
break;
|
break;
|
||||||
case "markRead":
|
case "markRead":
|
||||||
onMarkAsRead?.(!isUnread);
|
onMarkAsRead?.(isUnread);
|
||||||
break;
|
break;
|
||||||
case "archive":
|
case "archive":
|
||||||
onArchive?.();
|
onArchive?.();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ interface EmailListItemProps {
|
|||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||||
const t = useTranslations('email_viewer');
|
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 showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
@@ -44,7 +44,10 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const isImportant = email.keywords?.["$important"];
|
const isImportant = email.keywords?.["$important"];
|
||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const sender = email.from?.[0];
|
// 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 isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/ema
|
|||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
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 { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||||
import {
|
import {
|
||||||
Reply,
|
Reply,
|
||||||
@@ -1152,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(() => {
|
useEffect(() => {
|
||||||
// Mark as read when email is viewed, respecting the delay setting
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,17 +1185,18 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Instant mark
|
// Instant mark
|
||||||
if (markAsReadDelay === 0) {
|
if (markAsReadDelay === 0) {
|
||||||
|
autoMarkedEmailRef.current = email.id;
|
||||||
onMarkAsRead(email.id, true);
|
onMarkAsRead(email.id, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delayed mark
|
// Delayed mark
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
autoMarkedEmailRef.current = email.id;
|
||||||
onMarkAsRead(email.id, true);
|
onMarkAsRead(email.id, true);
|
||||||
}, markAsReadDelay);
|
}, markAsReadDelay);
|
||||||
|
|
||||||
return () => clearTimeout(timeout);
|
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]);
|
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
|
||||||
|
|
||||||
// Reset external content permission and quick reply when email changes
|
// Reset external content permission and quick reply when email changes
|
||||||
@@ -1820,12 +1839,12 @@ export function EmailViewer({
|
|||||||
|
|
||||||
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
||||||
if (!tnefAtt?.blobId) {
|
if (!tnefAtt?.blobId) {
|
||||||
debug.log('TNEF: No winmail.dat attachment found in email', email?.id);
|
debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.group('TNEF Processing');
|
debug.group('TNEF Processing', 'email');
|
||||||
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
||||||
|
|
||||||
// Check if the email already has a usable HTML body with real content
|
// Check if the email already has a usable HTML body with real content
|
||||||
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||||
@@ -1835,46 +1854,46 @@ export function EmailViewer({
|
|||||||
let hasRealHtmlBody = !!htmlValue;
|
let hasRealHtmlBody = !!htmlValue;
|
||||||
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
|
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
|
||||||
hasRealHtmlBody = false;
|
hasRealHtmlBody = false;
|
||||||
debug.log('TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
|
debug.log('email', 'TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
|
||||||
}
|
}
|
||||||
if (hasRealHtmlBody) {
|
if (hasRealHtmlBody) {
|
||||||
debug.log('TNEF: Email has real HTML body, will extract attachments only');
|
debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only');
|
||||||
} else {
|
} else {
|
||||||
debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
|
debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function processTnef() {
|
async function processTnef() {
|
||||||
try {
|
try {
|
||||||
debug.time('TNEF fetch blob');
|
debug.time('TNEF fetch blob', 'email');
|
||||||
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
||||||
debug.timeEnd('TNEF fetch blob');
|
debug.timeEnd('TNEF fetch blob', 'email');
|
||||||
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
debug.log('TNEF: Processing cancelled after fetch');
|
debug.log('email', 'TNEF: Processing cancelled after fetch');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (blobBytes.byteLength === 0) {
|
if (blobBytes.byteLength === 0) {
|
||||||
debug.warn('TNEF: Fetched blob is empty (0 bytes)');
|
debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tnefData = new Uint8Array(blobBytes);
|
const tnefData = new Uint8Array(blobBytes);
|
||||||
debug.time('TNEF parse');
|
debug.time('TNEF parse', 'email');
|
||||||
const parsed = parseTnef(tnefData);
|
const parsed = parseTnef(tnefData);
|
||||||
debug.timeEnd('TNEF parse');
|
debug.timeEnd('TNEF parse', 'email');
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
debug.log('TNEF: Processing cancelled after parse');
|
debug.log('email', 'TNEF: Processing cancelled after parse');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
||||||
|
|
||||||
if (parsed.htmlBody && !hasRealHtmlBody) {
|
if (parsed.htmlBody && !hasRealHtmlBody) {
|
||||||
setTnefHtml(parsed.htmlBody);
|
setTnefHtml(parsed.htmlBody);
|
||||||
@@ -1884,11 +1903,11 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
if (parsed.attachments.length > 0) {
|
if (parsed.attachments.length > 0) {
|
||||||
setTnefAttachments(parsed.attachments);
|
setTnefAttachments(parsed.attachments);
|
||||||
debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
|
debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
||||||
debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
@@ -1926,13 +1945,13 @@ export function EmailViewer({
|
|||||||
const hasRealText = !!textValue;
|
const hasRealText = !!textValue;
|
||||||
|
|
||||||
if (hasRealHtml || hasRealText) {
|
if (hasRealHtml || hasRealText) {
|
||||||
debug.log('Embedded RFC822: Outer email has real body content, not unwrapping');
|
debug.log('email', 'Embedded RFC822: Outer email has real body content, not unwrapping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.group('Embedded RFC822 Unwrapping');
|
debug.group('Embedded RFC822 Unwrapping', 'email');
|
||||||
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
||||||
debug.log('Outer email body is empty, will unwrap embedded email');
|
debug.log('email', 'Outer email body is empty, will unwrap embedded email');
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -1941,7 +1960,7 @@ export function EmailViewer({
|
|||||||
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
||||||
if (cancelled) { debug.groupEnd(); return; }
|
if (cancelled) { debug.groupEnd(); return; }
|
||||||
if (blobBytes.byteLength === 0) {
|
if (blobBytes.byteLength === 0) {
|
||||||
debug.warn('Embedded RFC822: Fetched blob is empty');
|
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1951,7 +1970,7 @@ export function EmailViewer({
|
|||||||
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
||||||
if (cancelled) { debug.groupEnd(); return; }
|
if (cancelled) { debug.groupEnd(); return; }
|
||||||
|
|
||||||
debug.log('Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
||||||
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
||||||
', attachments:', parsed.attachments?.length ?? 0);
|
', attachments:', parsed.attachments?.length ?? 0);
|
||||||
|
|
||||||
@@ -1963,7 +1982,7 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
if (parsed.attachments && parsed.attachments.length > 0) {
|
if (parsed.attachments && parsed.attachments.length > 0) {
|
||||||
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
|
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
|
||||||
debug.log('Embedded RFC822 attachments:', parsed.attachments.map(
|
debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map(
|
||||||
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
|
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
|
||||||
).join(', '));
|
).join(', '));
|
||||||
}
|
}
|
||||||
@@ -4507,7 +4526,7 @@ export function EmailViewer({
|
|||||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||||
hasCalendarInvitation) && (
|
hasCalendarInvitation) && (
|
||||||
<div className="border-b border-border bg-muted/30 isolate">
|
<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">
|
<div className="flex flex-col gap-3 isolate">
|
||||||
{/* External Content Controls */}
|
{/* External Content Controls */}
|
||||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||||
@@ -4874,7 +4893,7 @@ export function EmailViewer({
|
|||||||
if (client && supportsSync) {
|
if (client && supportsSync) {
|
||||||
createContact(client, contactData).then(() => toast.success('Contact added'));
|
createContact(client, contactData).then(() => toast.success('Contact added'));
|
||||||
} else {
|
} else {
|
||||||
addLocalContact({ id: `local-${crypto.randomUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
||||||
toast.success('Contact added');
|
toast.success('Contact added');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
|||||||
return () => document.removeEventListener("keydown", handler);
|
return () => document.removeEventListener("keydown", handler);
|
||||||
}, [isOpen]);
|
}, [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 = () => {
|
const handleViewContact = () => {
|
||||||
if (onViewContact) {
|
if (onViewContact) {
|
||||||
onViewContact(contact ?? null, email);
|
onViewContact(contact ?? null, email);
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export function RichTextEditor({
|
|||||||
);
|
);
|
||||||
if (imageFiles.length === 0) return false;
|
if (imageFiles.length === 0) return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
upload(file).then((url) => {
|
upload(file).then((url) => {
|
||||||
if (url) {
|
if (url) {
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
// Signature status
|
// Signature status
|
||||||
if (status.isSigned) {
|
if (status.isSigned) {
|
||||||
if (status.signatureValid === true) {
|
if (status.signatureValid === true) {
|
||||||
if (status.signerEmailMatch === false) {
|
if (status.selfSigned) {
|
||||||
|
items.push({
|
||||||
|
icon: <AlertTriangle className="w-4 h-4" />,
|
||||||
|
text: t('status_signed_self_signed'),
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
|
} else if (status.signerEmailMatch === false) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <AlertTriangle className="w-4 h-4" />,
|
icon: <AlertTriangle className="w-4 h-4" />,
|
||||||
text: t('status_signed_mismatch'),
|
text: t('status_signed_mismatch'),
|
||||||
|
|||||||
@@ -55,8 +55,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const sender = email.from?.[0];
|
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
const { selectedMailbox, 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 emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
@@ -339,7 +342,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
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({
|
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
||||||
email: latestEmail,
|
email: latestEmail,
|
||||||
@@ -522,8 +534,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={latestEmail.from?.[0]?.name}
|
name={avatarPerson?.name}
|
||||||
email={latestEmail.from?.[0]?.email}
|
email={avatarPerson?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
/>
|
/>
|
||||||
@@ -537,7 +549,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
)}>
|
)}>
|
||||||
{participantNames.join(', ')}
|
{displayNames.join(', ')}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -591,7 +603,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
? "font-bold text-foreground"
|
? "font-bold text-foreground"
|
||||||
: "font-medium text-muted-foreground"
|
: "font-medium text-muted-foreground"
|
||||||
)}>
|
)}>
|
||||||
{participantNames.join(", ")}
|
{displayNames.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -837,7 +837,7 @@ export function FileBrowser({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex flex-col h-full"
|
className="flex flex-col flex-1 min-h-0"
|
||||||
onClick={handleContainerClick}
|
onClick={handleContainerClick}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||||
<iframe
|
<iframe
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
|
sandbox="allow-same-origin"
|
||||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||||
title={name}
|
title={name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
FilterActionType,
|
FilterActionType,
|
||||||
} from "@/lib/jmap/sieve-types";
|
} from "@/lib/jmap/sieve-types";
|
||||||
import type { Mailbox } from "@/lib/jmap/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 {
|
interface FilterRuleModalProps {
|
||||||
rule?: FilterRule;
|
rule?: FilterRule;
|
||||||
@@ -109,7 +109,7 @@ export function FilterRuleModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSave({
|
onSave({
|
||||||
id: rule?.id || crypto.randomUUID(),
|
id: rule?.id || generateUUID(),
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
enabled: rule?.enabled ?? true,
|
enabled: rule?.enabled ?? true,
|
||||||
matchType,
|
matchType,
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ function useSyncIdentities() {
|
|||||||
const syncIdentities = useAuthStore((state) => state.syncIdentities);
|
const syncIdentities = useAuthStore((state) => state.syncIdentities);
|
||||||
return syncIdentities;
|
return syncIdentities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useRefreshIdentities() {
|
||||||
|
return useAuthStore((state) => state.refreshIdentities);
|
||||||
|
}
|
||||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||||
@@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
|
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
|
||||||
const syncIdentities = useSyncIdentities();
|
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 [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||||
import { getInitials } from "@/lib/account-utils";
|
import { getInitials } from "@/lib/account-utils";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
@@ -169,6 +170,7 @@ export function NavigationRail({
|
|||||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||||
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||||
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
@@ -218,13 +220,8 @@ export function NavigationRail({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const { client } = useAuthStore.getState();
|
const headers = getActiveAccountSlotHeaders();
|
||||||
if (!client) return;
|
if (!headers['X-JMAP-Cookie-Slot']) return;
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Authorization': client.getAuthHeader(),
|
|
||||||
'X-JMAP-Server-URL': client.getServerUrl(),
|
|
||||||
'X-JMAP-Username': client.getUsername(),
|
|
||||||
};
|
|
||||||
fetch('/api/admin/stalwart-check', { headers })
|
fetch('/api/admin/stalwart-check', { headers })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -246,7 +243,7 @@ export function NavigationRail({
|
|||||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
||||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
|
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
|
||||||
];
|
];
|
||||||
|
|
||||||
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
|
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
|
||||||
import { icons as lucideIcons, type LucideIcon } 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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { IconPicker } from './icon-picker';
|
import { IconPicker } from './icon-picker';
|
||||||
@@ -207,7 +207,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleCreate = useCallback((data: SidebarAppFormData) => {
|
const handleCreate = useCallback((data: SidebarAppFormData) => {
|
||||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
const id = `app-${generateUUID()}`;
|
||||||
addSidebarApp({ id, ...data });
|
addSidebarApp({ id, ...data });
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}, [addSidebarApp]);
|
}, [addSidebarApp]);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Users,
|
|
||||||
User,
|
User,
|
||||||
Palmtree,
|
Palmtree,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -59,10 +58,6 @@ interface SidebarProps {
|
|||||||
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
|
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
|
||||||
const lowerName = name?.toLowerCase() || "";
|
const lowerName = name?.toLowerCase() || "";
|
||||||
|
|
||||||
if (id === 'shared-folders-root') {
|
|
||||||
return isExpanded ? FolderOpen : Users;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id?.startsWith('shared-account-')) {
|
if (id?.startsWith('shared-account-')) {
|
||||||
return isExpanded ? FolderOpen : User;
|
return isExpanded ? FolderOpen : User;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,24 +6,32 @@ import { useLocaleStore } from '@/stores/locale-store';
|
|||||||
import enMessages from '@/locales/en/common.json';
|
import enMessages from '@/locales/en/common.json';
|
||||||
import frMessages from '@/locales/fr/common.json';
|
import frMessages from '@/locales/fr/common.json';
|
||||||
import jaMessages from '@/locales/ja/common.json';
|
import jaMessages from '@/locales/ja/common.json';
|
||||||
|
import koMessages from '@/locales/ko/common.json';
|
||||||
import esMessages from '@/locales/es/common.json';
|
import esMessages from '@/locales/es/common.json';
|
||||||
import itMessages from '@/locales/it/common.json';
|
import itMessages from '@/locales/it/common.json';
|
||||||
import deMessages from '@/locales/de/common.json';
|
import deMessages from '@/locales/de/common.json';
|
||||||
|
import lvMessages from '@/locales/lv/common.json';
|
||||||
import nlMessages from '@/locales/nl/common.json';
|
import nlMessages from '@/locales/nl/common.json';
|
||||||
|
import plMessages from '@/locales/pl/common.json';
|
||||||
import ptMessages from '@/locales/pt/common.json';
|
import ptMessages from '@/locales/pt/common.json';
|
||||||
import ruMessages from '@/locales/ru/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)
|
// Pre-loaded translations (loaded at build time, not runtime)
|
||||||
const ALL_MESSAGES = {
|
const ALL_MESSAGES = {
|
||||||
en: enMessages,
|
en: enMessages,
|
||||||
fr: frMessages,
|
fr: frMessages,
|
||||||
ja: jaMessages,
|
ja: jaMessages,
|
||||||
|
ko: koMessages,
|
||||||
es: esMessages,
|
es: esMessages,
|
||||||
it: itMessages,
|
it: itMessages,
|
||||||
de: deMessages,
|
de: deMessages,
|
||||||
|
lv: lvMessages,
|
||||||
nl: nlMessages,
|
nl: nlMessages,
|
||||||
|
pl: plMessages,
|
||||||
pt: ptMessages,
|
pt: ptMessages,
|
||||||
ru: ruMessages,
|
ru: ruMessages,
|
||||||
|
zh: zhMessages,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IntlProviderProps {
|
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
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={displayName || t('display_name.placeholder')}
|
||||||
className="w-48"
|
className="w-48"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -523,7 +524,7 @@ export function AccountSecuritySettings() {
|
|||||||
if (isStalwart === false) {
|
if (isStalwart === false) {
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<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>
|
</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 { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
|
||||||
|
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() {
|
export function AdvancedSettings() {
|
||||||
const t = useTranslations('settings.advanced');
|
const t = useTranslations('settings.advanced');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { debugMode, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||||
useSettingsStore();
|
useSettingsStore();
|
||||||
const { settingsSyncEnabled } = useConfig();
|
const { settingsSyncEnabled } = useConfig();
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
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 handleExport = () => {
|
||||||
const settingsJson = exportSettings();
|
const settingsJson = exportSettings();
|
||||||
@@ -64,6 +84,44 @@ export function AdvancedSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
{/* Debug Mode */}
|
{/* Debug Mode */}
|
||||||
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
|
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
|
||||||
@@ -72,6 +130,30 @@ export function AdvancedSettings() {
|
|||||||
</SettingItem>
|
</SettingItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Debug Categories */}
|
||||||
|
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
|
||||||
|
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
|
||||||
|
{ALL_DEBUG_CATEGORIES.map((cat) => (
|
||||||
|
<SettingItem
|
||||||
|
key={cat.id}
|
||||||
|
label={t(`debug_categories.${cat.labelKey}`)}
|
||||||
|
description={t(`debug_categories.${cat.labelKey}_description`)}
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={debugCategories?.[cat.id] !== false}
|
||||||
|
onChange={(checked) => {
|
||||||
|
updateSetting('debugCategories', {
|
||||||
|
...debugCategories,
|
||||||
|
[cat.id]: checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings Sync */}
|
{/* Settings Sync */}
|
||||||
{settingsSyncEnabled && (
|
{settingsSyncEnabled && (
|
||||||
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
|
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
|
||||||
@@ -122,5 +204,6 @@ export function AdvancedSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useCalendarStore } from '@/stores/calendar-store';
|
import { useCalendarStore } from '@/stores/calendar-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import { SettingsSection } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
|
||||||
@@ -203,7 +204,10 @@ export function CalendarManagementSettings() {
|
|||||||
|
|
||||||
fetch('/api/caldav/discover', {
|
fetch('/api/caldav/discover', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...getActiveAccountSlotHeaders(),
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
|
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function CalendarSettings() {
|
|||||||
showWeekNumbers,
|
showWeekNumbers,
|
||||||
enableCalendarTasks,
|
enableCalendarTasks,
|
||||||
showTasksOnCalendar,
|
showTasksOnCalendar,
|
||||||
|
showBirthdayCalendar,
|
||||||
calendarHoverPreview,
|
calendarHoverPreview,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
@@ -98,6 +99,16 @@ export function CalendarSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('show_birthday_calendar')}
|
||||||
|
description={t('show_birthday_calendar_desc')}
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={showBirthdayCalendar}
|
||||||
|
onChange={(checked) => updateSetting('showBirthdayCalendar', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{isFeatureEnabled('calendarTasksEnabled') && (
|
{isFeatureEnabled('calendarTasksEnabled') && (
|
||||||
<>
|
<>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
|
|||||||
@@ -99,11 +99,17 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
|
|||||||
export function FolderSettings() {
|
export function FolderSettings() {
|
||||||
const t = useTranslations('settings.folders');
|
const t = useTranslations('settings.folders');
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
|
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
|
||||||
const { folderIcons, setFolderIcon } = useSettingsStore();
|
const { folderIcons, setFolderIcon } = useSettingsStore();
|
||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
|
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (client && mailboxes.length === 0) {
|
||||||
|
fetchMailboxes(client);
|
||||||
|
}
|
||||||
|
}, [client, mailboxes.length, fetchMailboxes]);
|
||||||
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [creatingParentId, setCreatingParentId] = useState<string | null>(null);
|
const [creatingParentId, setCreatingParentId] = useState<string | null>(null);
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
@@ -200,8 +206,18 @@ export function FolderSettings() {
|
|||||||
await deleteMailbox(client, mailboxId);
|
await deleteMailbox(client, mailboxId);
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
toast.success(t('folder_deleted'));
|
toast.success(t('folder_deleted'));
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
toast.error(t('error_delete'));
|
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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record<PluginStatus, string> = {
|
|||||||
|
|
||||||
export function PluginsSettings() {
|
export function PluginsSettings() {
|
||||||
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
|
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 [isUploading, setIsUploading] = useState(false);
|
||||||
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -75,6 +75,13 @@ export function PluginsSettings() {
|
|||||||
return;
|
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) {
|
if (plugin.enabled) {
|
||||||
disablePlugin(plugin.id);
|
disablePlugin(plugin.id);
|
||||||
toast.info(`Plugin "${plugin.name}" disabled`);
|
toast.info(`Plugin "${plugin.name}" disabled`);
|
||||||
@@ -108,20 +115,26 @@ export function PluginsSettings() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<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
|
<PluginCard
|
||||||
key={plugin.id}
|
key={plugin.id}
|
||||||
plugin={plugin}
|
plugin={plugin}
|
||||||
isExpanded={expandedPlugin === plugin.id}
|
isExpanded={expandedPlugin === plugin.id}
|
||||||
isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)}
|
isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)}
|
||||||
isManaged={Boolean(plugin.managed)}
|
isManaged={Boolean(plugin.managed)}
|
||||||
|
needsApproval={needsApproval}
|
||||||
controlsDisabled={!initialized}
|
controlsDisabled={!initialized}
|
||||||
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
||||||
onToggle={() => handleToggle(plugin)}
|
onToggle={() => handleToggle(plugin)}
|
||||||
onUninstall={() => handleUninstall(plugin)}
|
onUninstall={() => handleUninstall(plugin)}
|
||||||
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -166,6 +179,7 @@ interface PluginCardProps {
|
|||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
isForceEnabled: boolean;
|
isForceEnabled: boolean;
|
||||||
isManaged: boolean;
|
isManaged: boolean;
|
||||||
|
needsApproval: boolean;
|
||||||
controlsDisabled: boolean;
|
controlsDisabled: boolean;
|
||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
@@ -173,7 +187,7 @@ interface PluginCardProps {
|
|||||||
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
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 (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'rounded-lg border transition-colors',
|
'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
|
<Server className="w-2.5 h-2.5" /> Managed
|
||||||
</span>
|
</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>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<span className="text-xs text-muted-foreground">{plugin.author}</span>
|
<span className="text-xs text-muted-foreground">{plugin.author}</span>
|
||||||
@@ -206,7 +225,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<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>
|
||||||
</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>
|
<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 */}
|
{/* Description */}
|
||||||
{plugin.description && (
|
{plugin.description && (
|
||||||
<p className="text-xs text-muted-foreground">{plugin.description}</p>
|
<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 { useSettingsStore, type SidebarApp } from "@/stores/settings-store";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
interface SidebarAppFormData {
|
interface SidebarAppFormData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -188,7 +188,7 @@ export function SidebarAppsSettings() {
|
|||||||
const draggedIndexRef = useRef<number | null>(null);
|
const draggedIndexRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const handleAdd = useCallback((data: SidebarAppFormData) => {
|
const handleAdd = useCallback((data: SidebarAppFormData) => {
|
||||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
const id = `app-${generateUUID()}`;
|
||||||
addSidebarApp({ id, ...data });
|
addSidebarApp({ id, ...data });
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
}, [addSidebarApp]);
|
}, [addSidebarApp]);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,12 +12,16 @@ export function LanguageSwitcher({ className }: { className?: string }) {
|
|||||||
{ value: 'en', label: 'English' },
|
{ value: 'en', label: 'English' },
|
||||||
{ value: 'fr', label: 'Français' },
|
{ value: 'fr', label: 'Français' },
|
||||||
{ value: 'ja', label: '日本語' },
|
{ value: 'ja', label: '日本語' },
|
||||||
|
{ value: 'ko', label: '한국어' },
|
||||||
{ value: 'es', label: 'Español' },
|
{ value: 'es', label: 'Español' },
|
||||||
{ value: 'it', label: 'Italiano' },
|
{ value: 'it', label: 'Italiano' },
|
||||||
{ value: 'de', label: 'Deutsch' },
|
{ value: 'de', label: 'Deutsch' },
|
||||||
|
{ value: 'lv', label: 'Latviešu' },
|
||||||
{ value: 'nl', label: 'Nederlands' },
|
{ value: 'nl', label: 'Nederlands' },
|
||||||
|
{ value: 'pl', label: 'Polski' },
|
||||||
{ value: 'pt', label: 'Português' },
|
{ value: 'pt', label: 'Português' },
|
||||||
{ value: 'ru', label: 'Русский' }
|
{ value: 'ru', label: 'Русский' },
|
||||||
|
{ value: 'zh', label: '简体中文' }
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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':
|
case 'ja':
|
||||||
messages = (await import('../locales/ja/common.json')).default;
|
messages = (await import('../locales/ja/common.json')).default;
|
||||||
break;
|
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':
|
case 'nl':
|
||||||
messages = (await import('../locales/nl/common.json')).default;
|
messages = (await import('../locales/nl/common.json')).default;
|
||||||
break;
|
break;
|
||||||
|
case 'pl':
|
||||||
|
messages = (await import('../locales/pl/common.json')).default;
|
||||||
|
break;
|
||||||
case 'pt':
|
case 'pt':
|
||||||
messages = (await import('../locales/pt/common.json')).default;
|
messages = (await import('../locales/pt/common.json')).default;
|
||||||
break;
|
break;
|
||||||
case 'ru':
|
case 'ru':
|
||||||
messages = (await import('../locales/ru/common.json')).default;
|
messages = (await import('../locales/ru/common.json')).default;
|
||||||
break;
|
break;
|
||||||
|
case 'zh':
|
||||||
|
messages = (await import('../locales/zh/common.json')).default;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
messages = (await import('../locales/en/common.json')).default;
|
messages = (await import('../locales/en/common.json')).default;
|
||||||
}
|
}
|
||||||
@@ -45,4 +57,4 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
|||||||
timeZone: 'Europe/Paris',
|
timeZone: 'Europe/Paris',
|
||||||
now: new Date()
|
now: new Date()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { defineRouting } from 'next-intl/routing';
|
import { defineRouting } from 'next-intl/routing';
|
||||||
|
|
||||||
export const routing = defineRouting({
|
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',
|
defaultLocale: 'en',
|
||||||
localePrefix: 'never'
|
localePrefix: 'never'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -263,48 +263,37 @@ describe('mailbox deep nesting (depth 4+)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mailbox deduplication side effects on nesting', () => {
|
describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () => {
|
||||||
it('should not remove an intermediate parent whose name matches a role mailbox', () => {
|
it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
|
||||||
// Scenario: A non-role folder named "Sent" is an intermediate parent.
|
// Reporter's exact scenario: two subfolders with the same name.
|
||||||
// The deduplication logic might remove it because it matches the role "sent" mailbox 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 = [
|
const mailboxes = [
|
||||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
||||||
// A user-created folder also named "Sent" that's a child of Inbox
|
// User-created subfolder also named "Sent" nested under Inbox
|
||||||
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
|
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
|
||||||
// Child of the custom "Sent" folder
|
// Child of the custom "Sent" folder — becomes orphaned if parent is deduped
|
||||||
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
|
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const tree = buildMailboxTree(mailboxes);
|
const tree = buildMailboxTree(mailboxes);
|
||||||
const flat = flattenMailboxTree(tree);
|
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
|
||||||
|
|
||||||
// The custom "Sent" (sent-custom) might be filtered by deduplication.
|
|
||||||
// If it IS removed, then "sent-child" loses its parent and becomes root — BAD.
|
|
||||||
const sentChild = flat.find(n => n.id === 'sent-child');
|
const sentChild = flat.find(n => n.id === 'sent-child');
|
||||||
expect(sentChild).toBeDefined();
|
expect(sentChild).toBeDefined();
|
||||||
|
expect(sentChild!.depth).toBe(2); // nested under sent-custom
|
||||||
// Check if sent-child is orphaned at root (the bug)
|
expect(rootIds).not.toContain('sent-child'); // must NOT be orphaned at root
|
||||||
const rootIds = tree.map(n => n.id);
|
|
||||||
const isOrphaned = rootIds.includes('sent-child');
|
|
||||||
|
|
||||||
if (isOrphaned) {
|
|
||||||
// This demonstrates the deduplication bug: removing an intermediate parent
|
|
||||||
// causes its children to become orphaned at root level
|
|
||||||
console.warn(
|
|
||||||
'BUG DETECTED: Deduplication removed intermediate parent "sent-custom", ' +
|
|
||||||
'orphaning "sent-child" to root level.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Document the current behavior (this test is diagnostic)
|
|
||||||
// Ideally: sent-child should be nested under sent-custom at depth 2
|
|
||||||
// If dedup removes sent-custom: sent-child ends up at root with depth 0
|
|
||||||
expect(sentChild!.depth).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not remove a parent folder whose name is a substring of a role name', () => {
|
it('should keep nested folders when name is substring of a role name', () => {
|
||||||
// Folder named "Draft" (substring of "Drafts" role) used as intermediate parent
|
// "Draft" is a substring of "Drafts" — dedup removes it, orphaning children
|
||||||
const mailboxes = [
|
const mailboxes = [
|
||||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
|
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
|
||||||
@@ -318,16 +307,66 @@ describe('mailbox deduplication side effects on nesting', () => {
|
|||||||
|
|
||||||
const draftChild = flat.find(n => n.id === 'draft-child');
|
const draftChild = flat.find(n => n.id === 'draft-child');
|
||||||
expect(draftChild).toBeDefined();
|
expect(draftChild).toBeDefined();
|
||||||
|
expect(draftChild!.depth).toBe(2);
|
||||||
|
expect(rootIds).not.toContain('draft-child');
|
||||||
|
});
|
||||||
|
|
||||||
const isOrphaned = rootIds.includes('draft-child');
|
it('should handle the exact reported structure with duplicate names at different depths', () => {
|
||||||
if (isOrphaned) {
|
// Stalwart allows creating subfolders with the same name at different levels.
|
||||||
console.warn(
|
// If any of those names match a role mailbox name, dedup could remove them.
|
||||||
'BUG DETECTED: Deduplication removed "draft-folder" (substring match with "Drafts"), ' +
|
const mailboxes = [
|
||||||
'orphaning "draft-child" to root level.'
|
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' }),
|
||||||
|
];
|
||||||
|
|
||||||
expect(draftChild!.depth).toBeGreaterThanOrEqual(0);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -148,3 +148,46 @@ describe('toast bridge', () => {
|
|||||||
expect(api.toast.warning).toBeInstanceOf(Function);
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -375,5 +375,111 @@ describe('expandRecurringEvents', () => {
|
|||||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
|
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
|
||||||
expect(result[0].originalId).toBe('evt1');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+22
-1
@@ -8,9 +8,17 @@ const ALGORITHM = 'aes-256-gcm';
|
|||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const TAG_LENGTH = 16;
|
const TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET;
|
const secret = process.env.SESSION_SECRET;
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||||
|
if (secret.length < MIN_SECRET_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
|
||||||
|
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
||||||
|
);
|
||||||
|
}
|
||||||
return createHash('sha256').update(secret).digest();
|
return createHash('sha256').update(secret).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,11 +124,24 @@ export async function clearAdminSessionCookie(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the client IP from the request headers.
|
* Get the client IP from the request headers.
|
||||||
|
*
|
||||||
|
* Proxies typically *append* to X-Forwarded-For, so the last entry
|
||||||
|
* before our trusted proxy is the most reliable client IP. When a
|
||||||
|
* single reverse proxy sits in front of the app the rightmost entry
|
||||||
|
* is the one added by that proxy. We take the rightmost entry to
|
||||||
|
* avoid trusting attacker-controlled values prepended to the header.
|
||||||
|
*
|
||||||
|
* If you run behind multiple trusted proxies, set TRUSTED_PROXY_DEPTH
|
||||||
|
* to the number of trusted proxies (default 1).
|
||||||
*/
|
*/
|
||||||
export function getClientIP(request: Request): string {
|
export function getClientIP(request: Request): string {
|
||||||
const forwarded = request.headers.get('x-forwarded-for');
|
const forwarded = request.headers.get('x-forwarded-for');
|
||||||
if (forwarded) {
|
if (forwarded) {
|
||||||
return forwarded.split(',')[0].trim();
|
const parts = forwarded.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const depth = Math.max(1, parseInt(process.env.TRUSTED_PROXY_DEPTH || '1', 10));
|
||||||
|
// Take the entry at position (length - depth), clamped to 0
|
||||||
|
const index = Math.max(0, parts.length - depth);
|
||||||
|
return parts[index] || '0.0.0.0';
|
||||||
}
|
}
|
||||||
return request.headers.get('x-real-ip') || '0.0.0.0';
|
return request.headers.get('x-real-ip') || '0.0.0.0';
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -25,6 +25,7 @@ export interface SettingRestriction {
|
|||||||
export interface FeatureGates {
|
export interface FeatureGates {
|
||||||
pluginsEnabled: boolean;
|
pluginsEnabled: boolean;
|
||||||
pluginsUploadEnabled: boolean;
|
pluginsUploadEnabled: boolean;
|
||||||
|
requirePluginApproval: boolean;
|
||||||
themesEnabled: boolean;
|
themesEnabled: boolean;
|
||||||
sidebarAppsEnabled: boolean;
|
sidebarAppsEnabled: boolean;
|
||||||
userThemesEnabled: boolean;
|
userThemesEnabled: boolean;
|
||||||
@@ -37,11 +38,13 @@ export interface FeatureGates {
|
|||||||
debugModeEnabled: boolean;
|
debugModeEnabled: boolean;
|
||||||
folderIconsEnabled: boolean;
|
folderIconsEnabled: boolean;
|
||||||
hoverActionsConfigEnabled: boolean;
|
hoverActionsConfigEnabled: boolean;
|
||||||
|
filesEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||||
pluginsEnabled: true,
|
pluginsEnabled: false,
|
||||||
pluginsUploadEnabled: true,
|
pluginsUploadEnabled: true,
|
||||||
|
requirePluginApproval: true,
|
||||||
themesEnabled: true,
|
themesEnabled: true,
|
||||||
sidebarAppsEnabled: true,
|
sidebarAppsEnabled: true,
|
||||||
userThemesEnabled: true,
|
userThemesEnabled: true,
|
||||||
@@ -54,6 +57,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
|||||||
debugModeEnabled: true,
|
debugModeEnabled: true,
|
||||||
folderIconsEnabled: true,
|
folderIconsEnabled: true,
|
||||||
hoverActionsConfigEnabled: true,
|
hoverActionsConfigEnabled: true,
|
||||||
|
filesEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ThemePolicy {
|
export interface ThemePolicy {
|
||||||
@@ -78,6 +82,8 @@ export interface SettingsPolicy {
|
|||||||
themePolicy: ThemePolicy;
|
themePolicy: ThemePolicy;
|
||||||
/** Plugin IDs that are force-enabled (users cannot disable) */
|
/** Plugin IDs that are force-enabled (users cannot disable) */
|
||||||
forceEnabledPlugins: string[];
|
forceEnabledPlugins: string[];
|
||||||
|
/** Plugin IDs that have been approved by admin (users can enable) */
|
||||||
|
approvedPlugins: string[];
|
||||||
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
||||||
forceEnabledThemes: string[];
|
forceEnabledThemes: string[];
|
||||||
}
|
}
|
||||||
@@ -88,6 +94,7 @@ export const DEFAULT_POLICY: SettingsPolicy = {
|
|||||||
defaults: {},
|
defaults: {},
|
||||||
themePolicy: { ...DEFAULT_THEME_POLICY },
|
themePolicy: { ...DEFAULT_THEME_POLICY },
|
||||||
forceEnabledPlugins: [],
|
forceEnabledPlugins: [],
|
||||||
|
approvedPlugins: [],
|
||||||
forceEnabledThemes: [],
|
forceEnabledThemes: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useAccountStore } from '@/stores/account-store';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
export function getActiveAccountSlot(): number | null {
|
||||||
|
const authState = useAuthStore.getState();
|
||||||
|
const accountState = useAccountStore.getState();
|
||||||
|
const activeAccountId = authState.activeAccountId ?? accountState.activeAccountId;
|
||||||
|
const activeAccount = activeAccountId
|
||||||
|
? accountState.getAccountById(activeAccountId)
|
||||||
|
: accountState.getActiveAccount();
|
||||||
|
|
||||||
|
return typeof activeAccount?.cookieSlot === 'number' ? activeAccount.cookieSlot : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveAccountSlotHeaders(): Record<string, string> {
|
||||||
|
const slot = getActiveAccountSlot();
|
||||||
|
return slot === null ? {} : { 'X-JMAP-Cookie-Slot': String(slot) };
|
||||||
|
}
|
||||||
@@ -5,9 +5,17 @@ const ALGORITHM = 'aes-256-gcm';
|
|||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const TAG_LENGTH = 16;
|
const TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET;
|
const secret = process.env.SESSION_SECRET;
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||||
|
if (secret.length < MIN_SECRET_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
|
||||||
|
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
||||||
|
);
|
||||||
|
}
|
||||||
return createHash('sha256').update(secret).digest();
|
return createHash('sha256').update(secret).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const VERIFY_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
export class JmapAuthVerificationError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'JmapAuthVerificationError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedProtocol(protocol: string): boolean {
|
||||||
|
return protocol === 'http:' || protocol === 'https:';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeJmapServerUrl(serverUrl: string): string {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(serverUrl);
|
||||||
|
} catch {
|
||||||
|
throw new JmapAuthVerificationError('Invalid server URL', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupportedProtocol(url.protocol)) {
|
||||||
|
throw new JmapAuthVerificationError('Unsupported server URL protocol', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
url.hash = '';
|
||||||
|
url.search = '';
|
||||||
|
return url.toString().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateProxyAuthHeader(authHeader: string): void {
|
||||||
|
if (!/^(?:Basic|Bearer)\s+\S+$/i.test(authHeader)) {
|
||||||
|
throw new JmapAuthVerificationError('Invalid Authorization header', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise<string> {
|
||||||
|
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
|
||||||
|
validateProxyAuthHeader(authHeader);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: authHeader },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new JmapAuthVerificationError(
|
||||||
|
response.status === 401 || response.status === 403
|
||||||
|
? 'Authentication failed'
|
||||||
|
: 'Failed to verify JMAP session',
|
||||||
|
response.status === 401 || response.status === 403 ? 401 : 502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await response.json().catch(() => null) as { apiUrl?: unknown; accounts?: unknown } | null;
|
||||||
|
if (!session || typeof session.apiUrl !== 'string' || typeof session.accounts !== 'object' || session.accounts === null) {
|
||||||
|
throw new JmapAuthVerificationError('Invalid JMAP session response', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedServerUrl;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new JmapAuthVerificationError('JMAP session verification timed out', 504);
|
||||||
|
}
|
||||||
|
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import type { ContactCard, CalendarEvent, Calendar, PartialDate, Timestamp } from '@/lib/jmap/types';
|
||||||
|
import { getContactDisplayName } from '@/stores/contact-store';
|
||||||
|
import { format, eachYearOfInterval, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
|
||||||
|
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // Yellow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtual calendar object for the contact birthday calendar.
|
||||||
|
*/
|
||||||
|
export function createBirthdayCalendar(name?: string, color?: string): Calendar {
|
||||||
|
return {
|
||||||
|
id: BIRTHDAY_CALENDAR_ID,
|
||||||
|
name: name || 'Birthdays',
|
||||||
|
description: null,
|
||||||
|
color: color || BIRTHDAY_CALENDAR_COLOR,
|
||||||
|
sortOrder: 999,
|
||||||
|
isSubscribed: true,
|
||||||
|
isVisible: true,
|
||||||
|
isDefault: false,
|
||||||
|
includeInAvailability: 'none',
|
||||||
|
defaultAlertsWithTime: null,
|
||||||
|
defaultAlertsWithoutTime: null,
|
||||||
|
timeZone: null,
|
||||||
|
shareWith: null,
|
||||||
|
myRights: {
|
||||||
|
mayReadFreeBusy: true,
|
||||||
|
mayReadItems: true,
|
||||||
|
mayWriteAll: false,
|
||||||
|
mayWriteOwn: false,
|
||||||
|
mayUpdatePrivate: false,
|
||||||
|
mayRSVP: false,
|
||||||
|
mayAdmin: false,
|
||||||
|
mayDelete: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract month and day from an AnniversaryDate.
|
||||||
|
* Returns null if the date cannot be parsed into month/day.
|
||||||
|
*/
|
||||||
|
function parseBirthdayDate(date: string | PartialDate | Timestamp): { month: number; day: number; year?: number } | null {
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
// Could be ISO date string like "1990-05-15" or partial "--05-15"
|
||||||
|
if (date.startsWith('--')) {
|
||||||
|
// Partial date: --MM-DD
|
||||||
|
const match = date.match(/^--(\d{2})-(\d{2})$/);
|
||||||
|
if (match) {
|
||||||
|
return { month: parseInt(match[1], 10), day: parseInt(match[2], 10) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = parseISO(date);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
return {
|
||||||
|
month: parsed.getMonth() + 1,
|
||||||
|
day: parsed.getDate(),
|
||||||
|
year: parsed.getFullYear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('utc' in date && date['@type'] === 'Timestamp') {
|
||||||
|
// Timestamp type
|
||||||
|
try {
|
||||||
|
const parsed = parseISO(date.utc);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
return {
|
||||||
|
month: parsed.getMonth() + 1,
|
||||||
|
day: parsed.getDate(),
|
||||||
|
year: parsed.getFullYear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartialDate type
|
||||||
|
const partial = date as PartialDate;
|
||||||
|
if (partial.month && partial.day) {
|
||||||
|
return {
|
||||||
|
month: partial.month,
|
||||||
|
day: partial.day,
|
||||||
|
year: partial.year || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate virtual CalendarEvent objects from contacts that have birthday anniversaries.
|
||||||
|
* Events are generated for each year in the given date range.
|
||||||
|
*/
|
||||||
|
export function generateBirthdayEvents(
|
||||||
|
contacts: ContactCard[],
|
||||||
|
rangeStart: string,
|
||||||
|
rangeEnd: string,
|
||||||
|
): CalendarEvent[] {
|
||||||
|
const events: CalendarEvent[] = [];
|
||||||
|
const start = parseISO(rangeStart);
|
||||||
|
const end = parseISO(rangeEnd);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
const years = eachYearOfInterval({ start, end });
|
||||||
|
// Also include the end date's year if not already covered
|
||||||
|
const endYear = end.getFullYear();
|
||||||
|
if (!years.some(y => y.getFullYear() === endYear)) {
|
||||||
|
years.push(new Date(endYear, 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const contact of contacts) {
|
||||||
|
if (!contact.anniversaries) continue;
|
||||||
|
|
||||||
|
for (const [key, anniversary] of Object.entries(contact.anniversaries)) {
|
||||||
|
if (anniversary.kind !== 'birth') continue;
|
||||||
|
|
||||||
|
const parsed = parseBirthdayDate(anniversary.date);
|
||||||
|
if (!parsed) continue;
|
||||||
|
|
||||||
|
const displayName = getContactDisplayName(contact);
|
||||||
|
if (!displayName) continue;
|
||||||
|
|
||||||
|
for (const yearDate of years) {
|
||||||
|
const year = yearDate.getFullYear();
|
||||||
|
// Handle Feb 29 in non-leap years: JS silently rolls over to Mar 1,
|
||||||
|
// but the ISO eventStart string would be invalid. Clamp to Feb 28.
|
||||||
|
let occMonth = parsed.month;
|
||||||
|
let occDay = parsed.day;
|
||||||
|
const occurrenceDate = new Date(year, occMonth - 1, occDay);
|
||||||
|
if (occurrenceDate.getMonth() !== occMonth - 1) {
|
||||||
|
occurrenceDate.setDate(0); // last day of previous month
|
||||||
|
occMonth = occurrenceDate.getMonth() + 1;
|
||||||
|
occDay = occurrenceDate.getDate();
|
||||||
|
}
|
||||||
|
if (occurrenceDate < start || occurrenceDate > end) continue;
|
||||||
|
|
||||||
|
const monthStr = String(occMonth).padStart(2, '0');
|
||||||
|
const dayStr = String(occDay).padStart(2, '0');
|
||||||
|
const eventStart = `${year}-${monthStr}-${dayStr}T00:00:00`;
|
||||||
|
|
||||||
|
const age = parsed.year ? year - parsed.year : undefined;
|
||||||
|
const ageText = age && age > 0 ? ` (${age})` : '';
|
||||||
|
|
||||||
|
const event: CalendarEvent = {
|
||||||
|
id: `birthday-${contact.id}-${key}-${year}`,
|
||||||
|
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
|
||||||
|
isDraft: false,
|
||||||
|
isOrigin: false,
|
||||||
|
utcStart: null,
|
||||||
|
utcEnd: null,
|
||||||
|
'@type': 'Event',
|
||||||
|
uid: `birthday-${contact.id}-${key}`,
|
||||||
|
title: `🎂 ${displayName}${ageText}`,
|
||||||
|
description: '',
|
||||||
|
descriptionContentType: 'text/plain',
|
||||||
|
created: null,
|
||||||
|
updated: '',
|
||||||
|
sequence: 0,
|
||||||
|
start: eventStart,
|
||||||
|
duration: 'P1D',
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
status: 'confirmed',
|
||||||
|
freeBusyStatus: 'free',
|
||||||
|
privacy: 'public',
|
||||||
|
color: null,
|
||||||
|
keywords: null,
|
||||||
|
categories: null,
|
||||||
|
locale: null,
|
||||||
|
replyTo: null,
|
||||||
|
organizerCalendarAddress: null,
|
||||||
|
participants: null,
|
||||||
|
mayInviteSelf: false,
|
||||||
|
mayInviteOthers: false,
|
||||||
|
hideAttendees: false,
|
||||||
|
recurrenceId: null,
|
||||||
|
recurrenceIdTimeZone: null,
|
||||||
|
recurrenceRules: null,
|
||||||
|
recurrenceOverrides: null,
|
||||||
|
excludedRecurrenceRules: null,
|
||||||
|
useDefaultAlerts: false,
|
||||||
|
alerts: null,
|
||||||
|
locations: null,
|
||||||
|
virtualLocations: null,
|
||||||
|
links: null,
|
||||||
|
relatedTo: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
|
||||||
export interface ParticipantInfo {
|
export interface ParticipantInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -104,9 +105,7 @@ export function buildParticipantMap(
|
|||||||
): Record<string, Partial<CalendarParticipant>> {
|
): Record<string, Partial<CalendarParticipant>> {
|
||||||
const participants: Record<string, Partial<CalendarParticipant>> = {};
|
const participants: Record<string, Partial<CalendarParticipant>> = {};
|
||||||
|
|
||||||
const generateId = () => typeof crypto !== 'undefined' && crypto.randomUUID
|
const generateId = () => generateUUID();
|
||||||
? crypto.randomUUID()
|
|
||||||
: `p-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
||||||
|
|
||||||
participants[generateId()] = {
|
participants[generateId()] = {
|
||||||
'@type': 'Participant',
|
'@type': 'Participant',
|
||||||
|
|||||||
+57
-24
@@ -1,25 +1,53 @@
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import type { DebugCategory } from '@/stores/settings-store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debug logger that respects the debugMode setting.
|
* Check if debug logging is enabled, optionally for a specific category.
|
||||||
|
* When a category is provided, both debugMode AND that category must be enabled.
|
||||||
|
*/
|
||||||
|
function isEnabled(category?: DebugCategory): boolean {
|
||||||
|
const state = useSettingsStore.getState();
|
||||||
|
if (!state.debugMode) return false;
|
||||||
|
if (!category) return true;
|
||||||
|
return state.debugCategories?.[category] !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug logger that respects the debugMode setting and category filters.
|
||||||
* Use this instead of console.log for conditional debug output.
|
* Use this instead of console.log for conditional debug output.
|
||||||
|
*
|
||||||
|
* Each method accepts an optional category as the first argument.
|
||||||
|
* When a category is provided, the message only logs if that category is enabled
|
||||||
|
* in Settings > Advanced > Debug Categories.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* debug.log('calendar', 'Event created', event); // Only logs when 'calendar' category is on
|
||||||
|
* debug.log('Uncategorized message'); // Logs whenever debugMode is on
|
||||||
*/
|
*/
|
||||||
export const debug = {
|
export const debug = {
|
||||||
/**
|
/**
|
||||||
* Log a debug message (only when debugMode is enabled)
|
* Log a debug message (only when debugMode is enabled and category is active)
|
||||||
*/
|
*/
|
||||||
log: (...args: unknown[]) => {
|
log: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
|
||||||
console.log('[DEBUG]', ...args);
|
if (isEnabled(categoryOrMsg)) {
|
||||||
|
console.log(`[DEBUG:${categoryOrMsg}]`, ...args);
|
||||||
|
}
|
||||||
|
} else if (isEnabled()) {
|
||||||
|
console.log('[DEBUG]', categoryOrMsg, ...args);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a warning message (only when debugMode is enabled)
|
* Log a warning message (only when debugMode is enabled and category is active)
|
||||||
*/
|
*/
|
||||||
warn: (...args: unknown[]) => {
|
warn: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
|
||||||
console.warn('[DEBUG]', ...args);
|
if (isEnabled(categoryOrMsg)) {
|
||||||
|
console.warn(`[DEBUG:${categoryOrMsg}]`, ...args);
|
||||||
|
}
|
||||||
|
} else if (isEnabled()) {
|
||||||
|
console.warn('[DEBUG]', categoryOrMsg, ...args);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -31,11 +59,11 @@ export const debug = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a collapsed console group (only when debugMode is enabled)
|
* Start a collapsed console group (only when debugMode is enabled and category is active)
|
||||||
*/
|
*/
|
||||||
group: (label: string) => {
|
group: (label: string, category?: DebugCategory) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (isEnabled(category)) {
|
||||||
console.group(`[DEBUG] ${label}`);
|
console.group(`[DEBUG${category ? ':' + category : ''}] ${label}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -43,35 +71,40 @@ export const debug = {
|
|||||||
* End a console group (only when debugMode is enabled)
|
* End a console group (only when debugMode is enabled)
|
||||||
*/
|
*/
|
||||||
groupEnd: () => {
|
groupEnd: () => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (isEnabled()) {
|
||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a performance timer (only when debugMode is enabled)
|
* Start a performance timer (only when debugMode is enabled and category is active)
|
||||||
*/
|
*/
|
||||||
time: (label: string) => {
|
time: (label: string, category?: DebugCategory) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (isEnabled(category)) {
|
||||||
console.time(`[DEBUG] ${label}`);
|
console.time(`[DEBUG${category ? ':' + category : ''}] ${label}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* End a performance timer (only when debugMode is enabled)
|
* End a performance timer (only when debugMode is enabled)
|
||||||
*/
|
*/
|
||||||
timeEnd: (label: string) => {
|
timeEnd: (label: string, category?: DebugCategory) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (isEnabled(category)) {
|
||||||
console.timeEnd(`[DEBUG] ${label}`);
|
console.timeEnd(`[DEBUG${category ? ':' + category : ''}] ${label}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a table (only when debugMode is enabled)
|
* Log a table (only when debugMode is enabled and category is active)
|
||||||
*/
|
*/
|
||||||
table: (data: unknown) => {
|
table: (data: unknown, category?: DebugCategory) => {
|
||||||
if (useSettingsStore.getState().debugMode) {
|
if (isEnabled(category)) {
|
||||||
console.table(data);
|
console.table(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']);
|
||||||
|
function isCategoryKey(value: string): value is DebugCategory {
|
||||||
|
return CATEGORY_KEYS.has(value);
|
||||||
|
}
|
||||||
|
|||||||
@@ -481,6 +481,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||||
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||||
|
|
||||||
|
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>): Promise<void> {
|
||||||
|
const book = this.data.addressBooks.find(b => b.id === addressBookId);
|
||||||
|
if (book) Object.assign(book, updates);
|
||||||
|
}
|
||||||
|
|
||||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||||
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||||
return [...this.data.contacts];
|
return [...this.data.contacts];
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ export const EMAIL_SANITIZE_CONFIG = {
|
|||||||
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
||||||
ALLOW_DATA_ATTR: false,
|
ALLOW_DATA_ATTR: false,
|
||||||
FORCE_BODY: true,
|
FORCE_BODY: true,
|
||||||
// Allow blob: URIs so authenticated inline images (CID) are not stripped
|
// Allow blob: URIs so authenticated inline images (CID) are not stripped.
|
||||||
|
// data: is restricted to image/* MIME types to prevent SVG script injection.
|
||||||
// eslint-disable-next-line no-useless-escape
|
// eslint-disable-next-line no-useless-escape
|
||||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
FORBID_TAGS: [
|
FORBID_TAGS: [
|
||||||
'script', 'iframe', 'object', 'embed', 'form',
|
'script', 'iframe', 'object', 'embed', 'form',
|
||||||
'input', 'button', 'meta', 'link', 'base',
|
'input', 'button', 'meta', 'link', 'base',
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export interface IJMAPClient {
|
|||||||
getContactsAccountId(): string;
|
getContactsAccountId(): string;
|
||||||
getAddressBooks(): Promise<AddressBook[]>;
|
getAddressBooks(): Promise<AddressBook[]>;
|
||||||
getAllAddressBooks(): Promise<AddressBook[]>;
|
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||||
|
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
||||||
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||||
getAllContacts(): Promise<ContactCard[]>;
|
getAllContacts(): Promise<ContactCard[]>;
|
||||||
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
||||||
|
|||||||
+171
-91
@@ -670,12 +670,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
||||||
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
||||||
|
|
||||||
debug.log(`[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`);
|
debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`);
|
||||||
|
|
||||||
// Warn if response might be truncated
|
// Warn if response might be truncated
|
||||||
const maxObjects = this.getMaxObjectsInGet();
|
const maxObjects = this.getMaxObjectsInGet();
|
||||||
if (rawMailboxes.length >= maxObjects) {
|
if (rawMailboxes.length >= maxObjects) {
|
||||||
debug.warn(
|
debug.warn('jmap',
|
||||||
`[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
|
`[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
|
||||||
`Some mailboxes may be missing — nested folders could appear orphaned at root level.`
|
`Some mailboxes may be missing — nested folders could appear orphaned at root level.`
|
||||||
);
|
);
|
||||||
@@ -685,7 +685,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const returnedIds = new Set(rawMailboxes.map(mb => mb.id));
|
const returnedIds = new Set(rawMailboxes.map(mb => mb.id));
|
||||||
const missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId));
|
const missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId));
|
||||||
if (missingParents.length > 0) {
|
if (missingParents.length > 0) {
|
||||||
debug.warn(
|
debug.warn('jmap',
|
||||||
`[JMAP Mailbox] ${missingParents.length} mailbox(es) reference parentId not in response (will be orphaned):`,
|
`[JMAP Mailbox] ${missingParents.length} mailbox(es) reference parentId not in response (will be orphaned):`,
|
||||||
missingParents.map(mb => ({ id: mb.id, name: mb.name, parentId: mb.parentId }))
|
missingParents.map(mb => ({ id: mb.id, name: mb.name, parentId: mb.parentId }))
|
||||||
);
|
);
|
||||||
@@ -755,12 +755,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
||||||
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
||||||
|
|
||||||
debug.log(`[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`);
|
debug.log('jmap', `[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`);
|
||||||
|
|
||||||
// Warn if response might be truncated
|
// Warn if response might be truncated
|
||||||
const maxObjects = this.getMaxObjectsInGet();
|
const maxObjects = this.getMaxObjectsInGet();
|
||||||
if (rawMailboxes.length >= maxObjects) {
|
if (rawMailboxes.length >= maxObjects) {
|
||||||
debug.warn(
|
debug.warn('jmap',
|
||||||
`[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
|
`[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
|
||||||
`Some mailboxes may be missing.`
|
`Some mailboxes may be missing.`
|
||||||
);
|
);
|
||||||
@@ -1320,7 +1320,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
const result = response.methodResponses?.[0]?.[1];
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
if (result?.notDestroyed?.[mailboxId]) {
|
if (result?.notDestroyed?.[mailboxId]) {
|
||||||
throw new Error(`Failed to delete mailbox: ${result.notDestroyed[mailboxId].type || 'unknown error'}`);
|
const err = result.notDestroyed[mailboxId];
|
||||||
|
const error = new Error(err.description || `Failed to delete mailbox: ${err.type || 'unknown error'}`);
|
||||||
|
(error as Error & { jmapType?: string }).jmapType = err.type;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1995,7 +1998,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
lines.push('END:VCALENDAR');
|
lines.push('END:VCALENDAR');
|
||||||
const icsContent = lines.join('\r\n') + '\r\n';
|
const icsContent = lines.join('\r\n') + '\r\n';
|
||||||
|
|
||||||
debug.log('[iMIP] Generated ICS:\n' + icsContent);
|
debug.log('calendar', '[iMIP] Generated ICS:\n' + icsContent);
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
ACCEPTED: 'Accepted',
|
ACCEPTED: 'Accepted',
|
||||||
@@ -2005,7 +2008,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const statusLabel = statusLabels[opts.status] || opts.status;
|
const statusLabel = statusLabels[opts.status] || opts.status;
|
||||||
const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
|
const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
|
||||||
|
|
||||||
debug.log('[iMIP] identityId:', finalIdentityId);
|
debug.log('calendar', '[iMIP] identityId:', finalIdentityId);
|
||||||
|
|
||||||
const emailId = `imip-reply-${Date.now()}`;
|
const emailId = `imip-reply-${Date.now()}`;
|
||||||
const emailCreate: Record<string, unknown> = {
|
const emailCreate: Record<string, unknown> = {
|
||||||
@@ -2038,12 +2041,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}, "1"],
|
}, "1"],
|
||||||
];
|
];
|
||||||
|
|
||||||
debug.log('[iMIP] Sending JMAP request with', methodCalls.length, 'method calls');
|
debug.log('calendar', '[iMIP] Sending JMAP request with', methodCalls.length, 'method calls');
|
||||||
debug.log('[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2));
|
debug.log('calendar', '[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2));
|
||||||
|
|
||||||
const response = await this.request(methodCalls);
|
const response = await this.request(methodCalls);
|
||||||
|
|
||||||
debug.log('[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
|
debug.log('calendar', '[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
|
||||||
|
|
||||||
if (response.methodResponses) {
|
if (response.methodResponses) {
|
||||||
for (const [methodName, result] of response.methodResponses) {
|
for (const [methodName, result] of response.methodResponses) {
|
||||||
@@ -2058,7 +2061,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
debug.log('[iMIP] sendImipReply completed successfully');
|
debug.log('calendar', '[iMIP] sendImipReply completed successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2222,7 +2225,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
async sendImipCancellation(event: CalendarEvent): Promise<void> {
|
async sendImipCancellation(event: CalendarEvent): Promise<void> {
|
||||||
if (!event.participants) return;
|
if (!event.participants) return;
|
||||||
if (event.status && event.status !== 'cancelled') {
|
if (event.status && event.status !== 'cancelled') {
|
||||||
debug.warn('sendImipCancellation called on non-cancelled event, status:', event.status);
|
debug.warn('calendar', 'sendImipCancellation called on non-cancelled event, status:', event.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mailboxes = await this.getMailboxes();
|
const mailboxes = await this.getMailboxes();
|
||||||
@@ -2815,6 +2818,35 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void> {
|
||||||
|
const accountId = targetAccountId || this.getContactsAccountId();
|
||||||
|
// Only forward server-settable properties
|
||||||
|
const { name, description, sortOrder, isDefault, color } = updates as Record<string, unknown>;
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
if (name !== undefined) patch.name = name;
|
||||||
|
if (description !== undefined) patch.description = description;
|
||||||
|
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
|
||||||
|
if (isDefault !== undefined) patch.isDefault = isDefault;
|
||||||
|
if (color !== undefined) patch.color = color;
|
||||||
|
|
||||||
|
const response = await this.request([
|
||||||
|
["AddressBook/set", {
|
||||||
|
accountId,
|
||||||
|
update: { [addressBookId]: patch },
|
||||||
|
}, "0"]
|
||||||
|
], this.contactUsing());
|
||||||
|
|
||||||
|
if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
|
||||||
|
const result = response.methodResponses[0][1];
|
||||||
|
if (result.notUpdated?.[addressBookId]) {
|
||||||
|
const error = result.notUpdated[addressBookId];
|
||||||
|
throw new Error(error.description || "Failed to update address book");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("Failed to update address book");
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchPaginatedContacts(
|
private async fetchPaginatedContacts(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
filter?: Record<string, unknown>,
|
filter?: Record<string, unknown>,
|
||||||
@@ -3220,33 +3252,48 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
|
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
|
||||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
|
||||||
|
|
||||||
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
||||||
if (calendarIds && calendarIds.length > 0) {
|
if (calendarIds && calendarIds.length > 0) {
|
||||||
queryArgs.filter = { inCalendars: calendarIds };
|
queryArgs.filter = { inCalendars: calendarIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.request([
|
// First, query to get all IDs
|
||||||
|
const queryResponse = await this.request([
|
||||||
["CalendarEvent/query", queryArgs, "0"],
|
["CalendarEvent/query", queryArgs, "0"],
|
||||||
["CalendarEvent/get", {
|
|
||||||
accountId,
|
|
||||||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
|
||||||
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
|
|
||||||
}, "1"]
|
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|
||||||
// Check for JMAP method-level errors
|
// Check for JMAP method-level errors
|
||||||
if (response.methodResponses?.[0]?.[0] === "error") {
|
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
|
||||||
const error = response.methodResponses[0][1];
|
const error = queryResponse.methodResponses[0][1];
|
||||||
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
|
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
|
||||||
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
|
if (ids.length === 0) return [];
|
||||||
.filter((event) => !isTaskObject(event))
|
|
||||||
.map((event) => normalizeCalendarEventLike(event));
|
// Batch the /get calls to stay within server max-objects limit
|
||||||
|
const allEvents: CalendarEvent[] = [];
|
||||||
|
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
|
||||||
|
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
|
||||||
|
const getResponse = await this.request([
|
||||||
|
["CalendarEvent/get", {
|
||||||
|
accountId,
|
||||||
|
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||||
|
ids: batchIds,
|
||||||
|
}, "0"]
|
||||||
|
], this.calendarUsing());
|
||||||
|
|
||||||
|
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||||
|
const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[];
|
||||||
|
allEvents.push(...events);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
|
return allEvents
|
||||||
|
.filter((event) => !isTaskObject(event))
|
||||||
|
.map((event) => normalizeCalendarEventLike(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryAllCalendarEvents(
|
async queryAllCalendarEvents(
|
||||||
@@ -3311,21 +3358,42 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
queryArgs.sort = sort;
|
queryArgs.sort = sort;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.request([
|
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
|
||||||
|
|
||||||
|
// First, query to get IDs
|
||||||
|
const queryResponse = await this.request([
|
||||||
["CalendarEvent/query", queryArgs, "0"],
|
["CalendarEvent/query", queryArgs, "0"],
|
||||||
["CalendarEvent/get", {
|
|
||||||
accountId,
|
|
||||||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
|
||||||
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
|
|
||||||
}, "1"]
|
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|
||||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
|
||||||
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
|
const error = queryResponse.methodResponses[0][1];
|
||||||
.filter((event) => !isTaskObject(event))
|
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
|
||||||
.map((event) => normalizeCalendarEventLike(event));
|
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
|
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
// Batch the /get calls to stay within server max-objects limit
|
||||||
|
const allEvents: CalendarEvent[] = [];
|
||||||
|
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
|
||||||
|
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
|
||||||
|
const getResponse = await this.request([
|
||||||
|
["CalendarEvent/get", {
|
||||||
|
accountId,
|
||||||
|
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||||
|
ids: batchIds,
|
||||||
|
}, "0"]
|
||||||
|
], this.calendarUsing());
|
||||||
|
|
||||||
|
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||||
|
const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[];
|
||||||
|
allEvents.push(...events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEvents
|
||||||
|
.filter((event) => !isTaskObject(event))
|
||||||
|
.map((event) => normalizeCalendarEventLike(event));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to query calendar events:', error);
|
console.error('Failed to query calendar events:', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -3361,8 +3429,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
|
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
|
||||||
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
|
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
|
||||||
|
|
||||||
debug.group('CalendarEvent/create');
|
debug.group('CalendarEvent/create', 'calendar');
|
||||||
debug.log('CalendarEvent/create outgoing payload', {
|
debug.log('calendar', 'CalendarEvent/create outgoing payload', {
|
||||||
accountId,
|
accountId,
|
||||||
sendSchedulingMessages,
|
sendSchedulingMessages,
|
||||||
eventKeys: Object.keys(cleanEvent),
|
eventKeys: Object.keys(cleanEvent),
|
||||||
@@ -3382,40 +3450,40 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
["CalendarEvent/set", setArgs, "0"]
|
["CalendarEvent/set", setArgs, "0"]
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|
||||||
debug.log('CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null);
|
debug.log('calendar', 'CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null);
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||||
const result = response.methodResponses[0][1];
|
const result = response.methodResponses[0][1];
|
||||||
|
|
||||||
if (result.notCreated?.["new-event"]) {
|
if (result.notCreated?.["new-event"]) {
|
||||||
const error = result.notCreated["new-event"];
|
const error = result.notCreated["new-event"];
|
||||||
debug.warn('CalendarEvent/create notCreated', error);
|
debug.warn('calendar', 'CalendarEvent/create notCreated', error);
|
||||||
debug.warn('CalendarEvent/create invalid properties', error.properties);
|
debug.warn('calendar', 'CalendarEvent/create invalid properties', error.properties);
|
||||||
debug.warn('CalendarEvent/create sent keys', Object.keys(cleanEvent));
|
debug.warn('calendar', 'CalendarEvent/create sent keys', Object.keys(cleanEvent));
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
throw new Error(error.description || "Failed to create calendar event");
|
throw new Error(error.description || "Failed to create calendar event");
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdId = result.created?.["new-event"]?.id;
|
const createdId = result.created?.["new-event"]?.id;
|
||||||
debug.log('CalendarEvent/create server acknowledged created id', {
|
debug.log('calendar', 'CalendarEvent/create server acknowledged created id', {
|
||||||
createdId,
|
createdId,
|
||||||
created: result.created?.['new-event'] || null,
|
created: result.created?.['new-event'] || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (createdId) {
|
if (createdId) {
|
||||||
const created = await this.getCalendarEvent(createdId, targetAccountId);
|
const created = await this.getCalendarEvent(createdId, targetAccountId);
|
||||||
debug.log('CalendarEvent/create fetched created event', getCalendarEventDebugSnapshot(created));
|
debug.log('calendar', 'CalendarEvent/create fetched created event', getCalendarEventDebugSnapshot(created));
|
||||||
|
|
||||||
if (created?.uid) {
|
if (created?.uid) {
|
||||||
try {
|
try {
|
||||||
const verificationMatches = await this.queryCalendarEvents({ uid: created.uid }, undefined, undefined, targetAccountId);
|
const verificationMatches = await this.queryCalendarEvents({ uid: created.uid }, undefined, undefined, targetAccountId);
|
||||||
debug.log('CalendarEvent/create verification query by uid', {
|
debug.log('calendar', 'CalendarEvent/create verification query by uid', {
|
||||||
uid: created.uid,
|
uid: created.uid,
|
||||||
matchCount: verificationMatches.length,
|
matchCount: verificationMatches.length,
|
||||||
matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)),
|
matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)),
|
||||||
});
|
});
|
||||||
} catch (verificationError) {
|
} catch (verificationError) {
|
||||||
debug.warn('CalendarEvent/create verification query failed', verificationError);
|
debug.warn('calendar', 'CalendarEvent/create verification query failed', verificationError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3424,7 +3492,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.warn('CalendarEvent/create server returned created id but CalendarEvent/get returned null', {
|
debug.warn('calendar', 'CalendarEvent/create server returned created id but CalendarEvent/get returned null', {
|
||||||
createdId,
|
createdId,
|
||||||
targetAccountId,
|
targetAccountId,
|
||||||
});
|
});
|
||||||
@@ -3455,7 +3523,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
createMap[`new-${i}`] = clean;
|
createMap[`new-${i}`] = clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('CalendarEvent/batchCreate', { count: events.length, accountId });
|
debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["CalendarEvent/set", { accountId, create: createMap }, "0"]
|
["CalendarEvent/set", { accountId, create: createMap }, "0"]
|
||||||
@@ -3471,7 +3539,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (result.created?.[key]?.id) {
|
if (result.created?.[key]?.id) {
|
||||||
createdIds.push(result.created[key].id);
|
createdIds.push(result.created[key].id);
|
||||||
} else if (result.notCreated?.[key]) {
|
} else if (result.notCreated?.[key]) {
|
||||||
debug.warn(`CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
|
debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
|
||||||
failed.push(key);
|
failed.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3496,7 +3564,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
|
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('CalendarEvent/batchCreate result', {
|
debug.log('calendar', 'CalendarEvent/batchCreate result', {
|
||||||
requested: events.length,
|
requested: events.length,
|
||||||
created: createdEvents.length,
|
created: createdEvents.length,
|
||||||
failed: failed.length,
|
failed: failed.length,
|
||||||
@@ -3513,8 +3581,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
// Strip client-only shared fields before sending to JMAP
|
// Strip client-only and server-immutable fields before sending to JMAP
|
||||||
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
|
const { id: _id, uid: _uid, '@type': _typ, created: _cr, updated: _up, sequence: _sq, isOrigin: _io, isDraft: _idr, originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
|
||||||
cleanRecurrenceRules(cleanUpdates as unknown as Record<string, unknown>);
|
cleanRecurrenceRules(cleanUpdates as unknown as Record<string, unknown>);
|
||||||
|
|
||||||
const setArgs: Record<string, unknown> = {
|
const setArgs: Record<string, unknown> = {
|
||||||
@@ -3527,7 +3595,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages });
|
debug.log('calendar', 'CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages });
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["CalendarEvent/set", setArgs, "0"]
|
["CalendarEvent/set", setArgs, "0"]
|
||||||
@@ -3549,7 +3617,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
debug.error('CalendarEvent/set notUpdated', { eventId, error });
|
debug.error('CalendarEvent/set notUpdated', { eventId, error });
|
||||||
throw new Error(error.description || "Failed to update calendar event");
|
throw new Error(error.description || "Failed to update calendar event");
|
||||||
}
|
}
|
||||||
debug.log('CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null });
|
debug.log('calendar', 'CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3600,7 +3668,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages });
|
debug.log('calendar', 'CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages });
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["CalendarEvent/set", setArgs, "0"]
|
["CalendarEvent/set", setArgs, "0"]
|
||||||
@@ -3622,7 +3690,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
debug.error('CalendarEvent/set notDestroyed', { eventId, error });
|
debug.error('CalendarEvent/set notDestroyed', { eventId, error });
|
||||||
throw new Error(error.description || "Failed to delete calendar event");
|
throw new Error(error.description || "Failed to delete calendar event");
|
||||||
}
|
}
|
||||||
debug.log('CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed });
|
debug.log('calendar', 'CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3654,8 +3722,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
||||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
debug.group('CalendarTask/fetch');
|
debug.group('CalendarTask/fetch', 'tasks');
|
||||||
debug.log('CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
|
debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Strategy 1: query with types filter (JMAP spec compliant)
|
// Strategy 1: query with types filter (JMAP spec compliant)
|
||||||
@@ -3664,7 +3732,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
filter.inCalendars = calendarIds;
|
filter.inCalendars = calendarIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('CalendarTask/fetch query filter', filter);
|
debug.log('tasks', 'CalendarTask/fetch query filter', filter);
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"],
|
["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"],
|
||||||
@@ -3678,13 +3746,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const queryResponse = response.methodResponses?.[0];
|
const queryResponse = response.methodResponses?.[0];
|
||||||
const getResponse = response.methodResponses?.[1];
|
const getResponse = response.methodResponses?.[1];
|
||||||
|
|
||||||
debug.log('CalendarTask/fetch query method', queryResponse?.[0]);
|
debug.log('tasks', 'CalendarTask/fetch query method', queryResponse?.[0]);
|
||||||
debug.log('CalendarTask/fetch query result', queryResponse?.[1]);
|
debug.log('tasks', 'CalendarTask/fetch query result', queryResponse?.[1]);
|
||||||
|
|
||||||
if (queryResponse?.[0] === "error") {
|
if (queryResponse?.[0] === "error") {
|
||||||
debug.warn('CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]);
|
debug.warn('tasks', 'CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]);
|
||||||
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
|
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
|
||||||
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks');
|
debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
@@ -3692,22 +3760,22 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (getResponse?.[0] === "CalendarEvent/get") {
|
if (getResponse?.[0] === "CalendarEvent/get") {
|
||||||
const list = (getResponse[1].list || []) as CalendarTask[];
|
const list = (getResponse[1].list || []) as CalendarTask[];
|
||||||
const queryIds = queryResponse?.[1]?.ids || [];
|
const queryIds = queryResponse?.[1]?.ids || [];
|
||||||
debug.log('CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds);
|
debug.log('calendar', 'CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds);
|
||||||
debug.log('CalendarTask/fetch get returned', list.length, 'objects');
|
debug.log('calendar', 'CalendarTask/fetch get returned', list.length, 'objects');
|
||||||
|
|
||||||
// If the types filter returned 0 results, the server may have silently
|
// If the types filter returned 0 results, the server may have silently
|
||||||
// ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to
|
// ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to
|
||||||
// a full scan so we can detect tasks by their properties.
|
// a full scan so we can detect tasks by their properties.
|
||||||
if (queryIds.length === 0) {
|
if (queryIds.length === 0) {
|
||||||
debug.warn('CalendarTask/fetch types filter returned 0 results, falling back to full scan');
|
debug.warn('tasks', 'CalendarTask/fetch types filter returned 0 results, falling back to full scan');
|
||||||
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
|
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
|
||||||
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks');
|
debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.forEach((task, i) => {
|
list.forEach((task, i) => {
|
||||||
debug.log(`CalendarTask/fetch [${i}]`, {
|
debug.log('tasks', `CalendarTask/fetch [${i}]`, {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
uid: task.uid,
|
uid: task.uid,
|
||||||
'@type': task['@type'],
|
'@type': task['@type'],
|
||||||
@@ -3724,12 +3792,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
...task,
|
...task,
|
||||||
'@type': 'Task' as const,
|
'@type': 'Task' as const,
|
||||||
}));
|
}));
|
||||||
debug.log('CalendarTask/fetch complete,', results.length, 'tasks');
|
debug.log('tasks', 'CalendarTask/fetch complete,', results.length, 'tasks');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.warn('CalendarTask/fetch unexpected response shape', response.methodResponses);
|
debug.warn('tasks', 'CalendarTask/fetch unexpected response shape', response.methodResponses);
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return [];
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3746,7 +3814,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
*/
|
*/
|
||||||
private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
||||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
debug.log('CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects');
|
debug.log('calendar', 'CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects');
|
||||||
|
|
||||||
// CalendarEvent/get with ids:null returns ALL calendar objects regardless of @type
|
// CalendarEvent/get with ids:null returns ALL calendar objects regardless of @type
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
@@ -3758,12 +3826,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") {
|
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") {
|
||||||
debug.warn('CalendarTask/fallback unexpected response', response.methodResponses?.[0]);
|
debug.warn('calendar', 'CalendarTask/fallback unexpected response', response.methodResponses?.[0]);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const allObjects = (response.methodResponses[0][1].list || []) as Record<string, unknown>[];
|
const allObjects = (response.methodResponses[0][1].list || []) as Record<string, unknown>[];
|
||||||
debug.log('CalendarTask/fallback total calendar objects returned:', allObjects.length);
|
debug.log('tasks', 'CalendarTask/fallback total calendar objects returned:', allObjects.length);
|
||||||
|
|
||||||
const tasks: CalendarTask[] = [];
|
const tasks: CalendarTask[] = [];
|
||||||
const calendarIdSet = calendarIds ? new Set(calendarIds) : null;
|
const calendarIdSet = calendarIds ? new Set(calendarIds) : null;
|
||||||
@@ -3779,7 +3847,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|| ('percentComplete' in obj);
|
|| ('percentComplete' in obj);
|
||||||
const isCalDavTask = type !== 'Event' && hasTaskFields;
|
const isCalDavTask = type !== 'Event' && hasTaskFields;
|
||||||
|
|
||||||
debug.log('CalendarTask/fallback scan', {
|
debug.log('tasks', 'CalendarTask/fallback scan', {
|
||||||
id: obj.id,
|
id: obj.id,
|
||||||
'@type': type,
|
'@type': type,
|
||||||
title: obj.title,
|
title: obj.title,
|
||||||
@@ -3796,7 +3864,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (calendarIdSet) {
|
if (calendarIdSet) {
|
||||||
const objCalendarIds = obj.calendarIds as Record<string, boolean> | undefined;
|
const objCalendarIds = obj.calendarIds as Record<string, boolean> | undefined;
|
||||||
if (objCalendarIds && !Object.keys(objCalendarIds).some(id => calendarIdSet.has(id))) {
|
if (objCalendarIds && !Object.keys(objCalendarIds).some(id => calendarIdSet.has(id))) {
|
||||||
debug.log('CalendarTask/fallback skipping task (not in requested calendars)', obj.id);
|
debug.log('tasks', 'CalendarTask/fallback skipping task (not in requested calendars)', obj.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3804,9 +3872,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask);
|
tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask);
|
||||||
});
|
});
|
||||||
|
|
||||||
debug.log('CalendarTask/fallback detected', tasks.length, 'tasks');
|
debug.log('tasks', 'CalendarTask/fallback detected', tasks.length, 'tasks');
|
||||||
tasks.forEach((t, i) => {
|
tasks.forEach((t, i) => {
|
||||||
debug.log(`CalendarTask/fallback [${i}]`, {
|
debug.log('tasks', `CalendarTask/fallback [${i}]`, {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
uid: t.uid,
|
uid: t.uid,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
@@ -3825,9 +3893,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const { '@type': _type, ...taskData } = task;
|
const { '@type': _type, ...taskData } = task;
|
||||||
const cleanTask = { ...taskData, '@type': 'Task' };
|
const cleanTask = { ...taskData, '@type': 'Task' };
|
||||||
|
|
||||||
debug.group('CalendarTask/create');
|
debug.group('CalendarTask/create', 'tasks');
|
||||||
debug.log('CalendarTask/create accountId', accountId);
|
debug.log('tasks', 'CalendarTask/create accountId', accountId);
|
||||||
debug.log('CalendarTask/create outgoing payload', cleanTask);
|
debug.log('tasks', 'CalendarTask/create outgoing payload', cleanTask);
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["CalendarEvent/set", {
|
["CalendarEvent/set", {
|
||||||
@@ -3838,27 +3906,27 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|
||||||
const result = response.methodResponses?.[0]?.[1];
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
debug.log('CalendarTask/create raw set response', result);
|
debug.log('tasks', 'CalendarTask/create raw set response', result);
|
||||||
|
|
||||||
if (result?.notCreated?.["new-task"]) {
|
if (result?.notCreated?.["new-task"]) {
|
||||||
const error = result.notCreated["new-task"];
|
const error = result.notCreated["new-task"];
|
||||||
debug.warn('CalendarTask/create REJECTED by server', error);
|
debug.warn('tasks', 'CalendarTask/create REJECTED by server', error);
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
throw new Error(error.description || "Failed to create task");
|
throw new Error(error.description || "Failed to create task");
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdId = result?.created?.["new-task"]?.id;
|
const createdId = result?.created?.["new-task"]?.id;
|
||||||
const serverCreated = result?.created?.["new-task"];
|
const serverCreated = result?.created?.["new-task"];
|
||||||
debug.log('CalendarTask/create server acknowledged', { createdId, serverCreated });
|
debug.log('tasks', 'CalendarTask/create server acknowledged', { createdId, serverCreated });
|
||||||
|
|
||||||
if (!createdId) {
|
if (!createdId) {
|
||||||
debug.warn('CalendarTask/create no id in server response');
|
debug.warn('tasks', 'CalendarTask/create no id in server response');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
throw new Error("Failed to create task — no id returned");
|
throw new Error("Failed to create task — no id returned");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch back with task-specific properties
|
// Fetch back with task-specific properties
|
||||||
debug.log('CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] });
|
debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] });
|
||||||
const getResponse = await this.request([
|
const getResponse = await this.request([
|
||||||
["CalendarEvent/get", {
|
["CalendarEvent/get", {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -3870,10 +3938,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||||
const list = getResponse.methodResponses[0][1].list || [];
|
const list = getResponse.methodResponses[0][1].list || [];
|
||||||
const notFound = getResponse.methodResponses[0][1].notFound || [];
|
const notFound = getResponse.methodResponses[0][1].notFound || [];
|
||||||
debug.log('CalendarTask/create get response', { found: list.length, notFound });
|
debug.log('calendar', 'CalendarTask/create get response', { found: list.length, notFound });
|
||||||
if (list[0]) {
|
if (list[0]) {
|
||||||
const created = { ...list[0], '@type': 'Task' as const } as CalendarTask;
|
const created = { ...list[0], '@type': 'Task' as const } as CalendarTask;
|
||||||
debug.log('CalendarTask/create final task object', {
|
debug.log('tasks', 'CalendarTask/create final task object', {
|
||||||
id: created.id,
|
id: created.id,
|
||||||
uid: created.uid,
|
uid: created.uid,
|
||||||
'@type': created['@type'],
|
'@type': created['@type'],
|
||||||
@@ -3889,7 +3957,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.warn('CalendarTask/create re-fetch returned nothing for id', createdId);
|
debug.warn('tasks', 'CalendarTask/create re-fetch returned nothing for id', createdId);
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
throw new Error("Failed to fetch created task");
|
throw new Error("Failed to fetch created task");
|
||||||
}
|
}
|
||||||
@@ -4058,7 +4126,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
|
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
|
||||||
const accountId = this.getFilesAccountId();
|
const accountId = this.getFilesAccountId();
|
||||||
|
|
||||||
const fileProps: Record<string, unknown> = { name, type, blobId, size };
|
//fall back for long MIME types
|
||||||
|
const safeType = type.length > 30 ? 'application/octet-stream' : type;
|
||||||
|
const fileProps: Record<string, unknown> = { name, type: safeType, blobId, size };
|
||||||
if (parentId !== null) {
|
if (parentId !== null) {
|
||||||
fileProps.parentId = parentId;
|
fileProps.parentId = parentId;
|
||||||
}
|
}
|
||||||
@@ -4112,6 +4182,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
[["FileNode/set", {
|
[["FileNode/set", {
|
||||||
accountId,
|
accountId,
|
||||||
destroy: ids,
|
destroy: ids,
|
||||||
|
onDestroyRemoveChildren: true,
|
||||||
}, "fns0"]],
|
}, "fns0"]],
|
||||||
this.fileUsing(),
|
this.fileUsing(),
|
||||||
);
|
);
|
||||||
@@ -4120,9 +4191,18 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (!result || result[0] === "error") {
|
if (!result || result[0] === "error") {
|
||||||
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
|
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
|
||||||
|
const notDestroyedIds = Object.keys(notDestroyedMap);
|
||||||
|
|
||||||
|
if (notDestroyedIds.length > 0) {
|
||||||
|
const firstError = notDestroyedMap[notDestroyedIds[0]];
|
||||||
|
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroyed: result[1].destroyed || [],
|
destroyed: result[1].destroyed || [],
|
||||||
notDestroyed: result[1].notDestroyed ? Object.keys(result[1].notDestroyed) : [],
|
notDestroyed: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function playFile(file: string) {
|
|||||||
const audio = new Audio(file);
|
const audio = new Audio(file);
|
||||||
audio.volume = 0.3;
|
audio.volume = 0.3;
|
||||||
audio.play().catch((e) => {
|
audio.play().catch((e) => {
|
||||||
debug.log('Could not play audio file, falling back to beep:', e);
|
debug.log('push', 'Could not play audio file, falling back to beep:', e);
|
||||||
playBeep();
|
playBeep();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,6 @@ export function playNotificationSound(sound?: NotificationSoundChoice) {
|
|||||||
playBeep();
|
playBeep();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debug.log('Could not play notification sound:', e);
|
debug.log('push', 'Could not play notification sound:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict';
|
const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict';
|
||||||
|
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
|
||||||
|
? process.env.COOKIE_SECURE === 'true'
|
||||||
|
: (COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production');
|
||||||
|
|
||||||
export function getCookieOptions() {
|
export function getCookieOptions() {
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production',
|
secure: COOKIE_SECURE,
|
||||||
sameSite: COOKIE_SAME_SITE,
|
sameSite: COOKIE_SAME_SITE,
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 30 * 24 * 60 * 60,
|
maxAge: 30 * 24 * 60 * 60,
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
export const OAUTH_SCOPES = 'openid email profile';
|
const DEFAULT_SCOPES = 'openid email profile';
|
||||||
|
const EXTRA_SCOPES = process.env.OAUTH_EXTRA_SCOPES || '';
|
||||||
|
export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAULT_SCOPES} ${EXTRA_SCOPES}`.trim() : DEFAULT_SCOPES);
|
||||||
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
|
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
|
||||||
|
|
||||||
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
||||||
|
|||||||
+37
-7
@@ -25,8 +25,9 @@ import {
|
|||||||
sidebarAppHooks,
|
sidebarAppHooks,
|
||||||
} from './plugin-hooks';
|
} from './plugin-hooks';
|
||||||
import { toast as appToast } from '@/stores/toast-store';
|
import { toast as appToast } from '@/stores/toast-store';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
// ─── Permission helpers ──────────────────────────────────────
|
// --- Permission helpers --------------------------------------
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function getPluginExternals(): any {
|
function getPluginExternals(): any {
|
||||||
@@ -60,7 +61,7 @@ function guardedHook<T extends (...args: never[]) => unknown>(
|
|||||||
return bus.register(plugin.id, handler, order);
|
return bus.register(plugin.id, handler, order);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Plugin-scoped storage ───────────────────────────────────
|
// --- Plugin-scoped storage -----------------------------------
|
||||||
|
|
||||||
function createPluginStorage(pluginId: string) {
|
function createPluginStorage(pluginId: string) {
|
||||||
const prefix = `plugin:${pluginId}:`;
|
const prefix = `plugin:${pluginId}:`;
|
||||||
@@ -92,7 +93,7 @@ function createPluginStorage(pluginId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Plugin-scoped logger ────────────────────────────────────
|
// --- Plugin-scoped logger ------------------------------------
|
||||||
|
|
||||||
function createPluginLogger(pluginId: string) {
|
function createPluginLogger(pluginId: string) {
|
||||||
const tag = `[plugin:${pluginId}]`;
|
const tag = `[plugin:${pluginId}]`;
|
||||||
@@ -104,7 +105,7 @@ function createPluginLogger(pluginId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PluginAPI interface ─────────────────────────────────────
|
// --- PluginAPI interface -------------------------------------
|
||||||
|
|
||||||
export interface PluginAPI {
|
export interface PluginAPI {
|
||||||
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
||||||
@@ -128,6 +129,9 @@ export interface PluginAPI {
|
|||||||
info: (message: string) => void;
|
info: (message: string) => void;
|
||||||
warning: (message: string) => void;
|
warning: (message: string) => void;
|
||||||
};
|
};
|
||||||
|
http: {
|
||||||
|
post: (path: string, body: Record<string, unknown>) => Promise<{ ok: boolean; status: number; data: unknown }>;
|
||||||
|
};
|
||||||
storage: ReturnType<typeof createPluginStorage>;
|
storage: ReturnType<typeof createPluginStorage>;
|
||||||
log: ReturnType<typeof createPluginLogger>;
|
log: ReturnType<typeof createPluginLogger>;
|
||||||
admin: {
|
admin: {
|
||||||
@@ -312,7 +316,7 @@ export interface PluginHooksAPI {
|
|||||||
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Permission mapping for hooks ────────────────────────────
|
// --- Permission mapping for hooks ----------------------------
|
||||||
|
|
||||||
const HOOK_PERMISSIONS: Record<string, Permission> = {
|
const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||||
// Email
|
// Email
|
||||||
@@ -461,7 +465,7 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
|
|||||||
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Slot registration bridge ────────────────────────────────
|
// --- Slot registration bridge --------------------------------
|
||||||
// Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly
|
// Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly
|
||||||
|
|
||||||
let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType<Record<string, unknown>>; order: number }) => Disposable) | null = null;
|
let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType<Record<string, unknown>>; order: number }) => Disposable) | null = null;
|
||||||
@@ -483,7 +487,7 @@ function registerSlot(
|
|||||||
return registerSlotFn(slotName, { pluginId, component, order });
|
return registerSlotFn(slotName, { pluginId, component, order });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Factory ─────────────────────────────────────────────────
|
// --- Factory -------------------------------------------------
|
||||||
|
|
||||||
export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
||||||
// Build hooks proxy — each hook method checks permission and registers on the right bus
|
// Build hooks proxy — each hook method checks permission and registers on the right bus
|
||||||
@@ -640,6 +644,32 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
|||||||
warning: (message: string) => appToast.warning(message),
|
warning: (message: string) => appToast.warning(message),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
http: {
|
||||||
|
post: async (path: string, body: Record<string, unknown>) => {
|
||||||
|
requirePermission(plugin, 'http:post');
|
||||||
|
if (typeof path !== 'string' || !path.startsWith('/api/')) {
|
||||||
|
throw new Error('path must start with /api/');
|
||||||
|
}
|
||||||
|
const url = new URL(path, globalThis.location.origin);
|
||||||
|
if (url.origin !== globalThis.location.origin) {
|
||||||
|
throw new Error('path must resolve to the same origin');
|
||||||
|
}
|
||||||
|
const { client } = useAuthStore.getState();
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (client) {
|
||||||
|
headers['Authorization'] = client.getAuthHeader();
|
||||||
|
headers['X-JMAP-Username'] = client.getUsername();
|
||||||
|
}
|
||||||
|
const res = await fetch(url.pathname + url.search, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
return { ok: res.ok, status: res.status, data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
storage: createPluginStorage(plugin.id),
|
storage: createPluginStorage(plugin.id),
|
||||||
log: createPluginLogger(plugin.id),
|
log: createPluginLogger(plugin.id),
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import * as ReactJSX from 'react/jsx-runtime';
|
import * as ReactJSX from 'react/jsx-runtime';
|
||||||
|
|
||||||
// ─── Shared React (window.__PLUGIN_EXTERNALS__) ─────────────
|
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
|
||||||
|
|
||||||
export function exposePluginExternals(): void {
|
export function exposePluginExternals(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -20,7 +20,7 @@ export function exposePluginExternals(): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Active plugin tracking ──────────────────────────────────
|
// --- Active plugin tracking ----------------------------------
|
||||||
|
|
||||||
interface ActivePlugin {
|
interface ActivePlugin {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,7 +31,7 @@ interface ActivePlugin {
|
|||||||
|
|
||||||
const activePlugins = new Map<string, ActivePlugin>();
|
const activePlugins = new Map<string, ActivePlugin>();
|
||||||
|
|
||||||
// ─── Load a single plugin ────────────────────────────────────
|
// --- Load a single plugin ------------------------------------
|
||||||
|
|
||||||
type PluginStoreAccessor = {
|
type PluginStoreAccessor = {
|
||||||
setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void;
|
setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void;
|
||||||
@@ -101,7 +101,7 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Deactivate a single plugin ──────────────────────────────
|
// --- Deactivate a single plugin ------------------------------
|
||||||
|
|
||||||
export function deactivatePlugin(pluginId: string): void {
|
export function deactivatePlugin(pluginId: string): void {
|
||||||
const active = activePlugins.get(pluginId);
|
const active = activePlugins.get(pluginId);
|
||||||
@@ -127,7 +127,7 @@ export function deactivatePlugin(pluginId: string): void {
|
|||||||
console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
|
console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Activate all enabled plugins ────────────────────────────
|
// --- Activate all enabled plugins ----------------------------
|
||||||
|
|
||||||
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
|
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
|
||||||
// Ensure externals are exposed
|
// Ensure externals are exposed
|
||||||
@@ -139,7 +139,7 @@ export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Deactivate all plugins ─────────────────────────────────
|
// --- Deactivate all plugins ---------------------------------
|
||||||
|
|
||||||
export function deactivateAllPlugins(): void {
|
export function deactivateAllPlugins(): void {
|
||||||
for (const pluginId of [...activePlugins.keys()]) {
|
for (const pluginId of [...activePlugins.keys()]) {
|
||||||
@@ -147,13 +147,13 @@ export function deactivateAllPlugins(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Check if a plugin is active ─────────────────────────────
|
// --- Check if a plugin is active -----------------------------
|
||||||
|
|
||||||
export function isPluginActive(pluginId: string): boolean {
|
export function isPluginActive(pluginId: string): boolean {
|
||||||
return activePlugins.has(pluginId);
|
return activePlugins.has(pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Setup auto-disable callback ─────────────────────────────
|
// --- Setup auto-disable callback -----------------------------
|
||||||
|
|
||||||
export function setupAutoDisable(): void {
|
export function setupAutoDisable(): void {
|
||||||
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
|
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export interface InstalledPlugin {
|
|||||||
managed?: boolean;
|
managed?: boolean;
|
||||||
// True when plugin is admin-enforced and cannot be disabled locally.
|
// True when plugin is admin-enforced and cannot be disabled locally.
|
||||||
forceEnabled?: boolean;
|
forceEnabled?: boolean;
|
||||||
|
// True when plugin has been approved by an admin. Unapproved plugins cannot be enabled.
|
||||||
|
adminApproved?: boolean;
|
||||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -402,6 +404,7 @@ export const ALL_PERMISSIONS = [
|
|||||||
'settings:read', 'settings:write',
|
'settings:read', 'settings:write',
|
||||||
'security:read',
|
'security:read',
|
||||||
'auth:observe',
|
'auth:observe',
|
||||||
|
'http:post',
|
||||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
||||||
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
||||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||||
@@ -427,6 +430,7 @@ export const ALLOWED_PLUGIN_FILES = new Set([
|
|||||||
export const DISALLOWED_CSS_PATTERNS = [
|
export const DISALLOWED_CSS_PATTERNS = [
|
||||||
/@import\b/i,
|
/@import\b/i,
|
||||||
/url\s*\(\s*['"]?https?:/i,
|
/url\s*\(\s*['"]?https?:/i,
|
||||||
|
/url\s*\(\s*['"]?data:/i,
|
||||||
/expression\s*\(/i,
|
/expression\s*\(/i,
|
||||||
/javascript\s*:/i,
|
/javascript\s*:/i,
|
||||||
/-moz-binding/i,
|
/-moz-binding/i,
|
||||||
|
|||||||
@@ -35,7 +35,23 @@ export function expandRecurringEvents(
|
|||||||
const end = parseISO(rangeEnd);
|
const end = parseISO(rangeEnd);
|
||||||
const result: CalendarEvent[] = [];
|
const result: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
// Collect UIDs of master recurring events so we can skip their
|
||||||
|
// server-returned override instances (which have recurrenceId set).
|
||||||
|
// The master's recurrenceOverrides already accounts for them.
|
||||||
|
const recurringUids = new Set<string>();
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
if (event.recurrenceRules?.length && event.uid && !event.recurrenceId) {
|
||||||
|
recurringUids.add(event.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
// Skip override instances returned by the server — they belong to a
|
||||||
|
// master recurring event and are already handled via recurrenceOverrides.
|
||||||
|
if (event.recurrenceId && event.uid && recurringUids.has(event.uid)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!event.recurrenceRules?.length) {
|
if (!event.recurrenceRules?.length) {
|
||||||
result.push(event);
|
result.push(event);
|
||||||
continue;
|
continue;
|
||||||
@@ -112,6 +128,25 @@ function createOccurrence(
|
|||||||
? format(date, "yyyy-MM-dd'T'00:00:00")
|
? format(date, "yyyy-MM-dd'T'00:00:00")
|
||||||
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
|
||||||
|
// Compute utcStart/utcEnd for this occurrence so that getEventStartDate()
|
||||||
|
// and getEventEndDate() (which prefer utcStart/utcEnd for timed events)
|
||||||
|
// return the correct dates instead of the master's original UTC times.
|
||||||
|
let utcStart: string | undefined;
|
||||||
|
let utcEnd: string | undefined;
|
||||||
|
if (!master.showWithoutTime && master.utcStart && master.start) {
|
||||||
|
const masterLocal = parseISO(master.start);
|
||||||
|
const masterUtc = parseISO(master.utcStart);
|
||||||
|
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
|
||||||
|
utcStart = new Date(date.getTime() + offsetMs).toISOString();
|
||||||
|
|
||||||
|
// Shift utcEnd by the same amount as utcStart
|
||||||
|
if (master.utcEnd) {
|
||||||
|
const masterUtcEnd = parseISO(master.utcEnd);
|
||||||
|
const durationMs = masterUtcEnd.getTime() - masterUtc.getTime();
|
||||||
|
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...master,
|
...master,
|
||||||
...(override || {}),
|
...(override || {}),
|
||||||
@@ -120,6 +155,8 @@ function createOccurrence(
|
|||||||
uid: master.uid,
|
uid: master.uid,
|
||||||
calendarIds: master.calendarIds,
|
calendarIds: master.calendarIds,
|
||||||
start: (override?.start) || startStr,
|
start: (override?.start) || startStr,
|
||||||
|
...(utcStart && !override?.utcStart ? { utcStart } : {}),
|
||||||
|
...(utcEnd && !override?.utcEnd ? { utcEnd } : {}),
|
||||||
recurrenceId,
|
recurrenceId,
|
||||||
recurrenceRules: master.recurrenceRules,
|
recurrenceRules: master.recurrenceRules,
|
||||||
recurrenceOverrides: master.recurrenceOverrides,
|
recurrenceOverrides: master.recurrenceOverrides,
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
|
|||||||
|
|
||||||
for (const rule of enabledRules) {
|
for (const rule of enabledRules) {
|
||||||
if (rule.conditions.length === 0 || rule.actions.length === 0) {
|
if (rule.conditions.length === 0 || rule.actions.length === 0) {
|
||||||
debug.warn(`Skipping rule "${rule.name}": empty conditions or actions`);
|
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ export function parseScript(content: string): ParseResult {
|
|||||||
try {
|
try {
|
||||||
metadata = JSON.parse(jsonStr);
|
metadata = JSON.parse(jsonStr);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debug.warn('Failed to parse Sieve metadata JSON:', e);
|
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
|
||||||
return OPAQUE;
|
return OPAQUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* All line endings are CRLF per RFC 5322.
|
* All line endings are CRLF per RFC 5322.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
|
||||||
const CRLF = '\r\n';
|
const CRLF = '\r\n';
|
||||||
|
|
||||||
export interface MimeAttachment {
|
export interface MimeAttachment {
|
||||||
@@ -43,7 +45,7 @@ export function buildMimeMessage(input: MimeMessageInput): Uint8Array {
|
|||||||
// BCC is intentionally omitted from the MIME headers per RFC 5322
|
// BCC is intentionally omitted from the MIME headers per RFC 5322
|
||||||
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
|
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
|
||||||
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
|
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
|
||||||
lines.push(formatHeader('Message-ID', input.messageId ?? `<${crypto.randomUUID()}@smime.local>`));
|
lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
|
||||||
if (input.inReplyTo) {
|
if (input.inReplyTo) {
|
||||||
lines.push(formatHeader('In-Reply-To', input.inReplyTo));
|
lines.push(formatHeader('In-Reply-To', input.inReplyTo));
|
||||||
}
|
}
|
||||||
@@ -245,7 +247,7 @@ export function wrapCmsAsSmimeMessage(cmsBlob: Blob | ArrayBuffer | Uint8Array,
|
|||||||
}
|
}
|
||||||
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
|
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
|
||||||
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
|
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
|
||||||
lines.push(formatHeader('Message-ID', input.messageId ?? `<${crypto.randomUUID()}@smime.local>`));
|
lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
|
||||||
if (input.inReplyTo) {
|
if (input.inReplyTo) {
|
||||||
lines.push(formatHeader('In-Reply-To', input.inReplyTo));
|
lines.push(formatHeader('In-Reply-To', input.inReplyTo));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as asn1js from 'asn1js';
|
import * as asn1js from 'asn1js';
|
||||||
import * as pkijs from 'pkijs';
|
import * as pkijs from 'pkijs';
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
extractCertificateInfo,
|
extractCertificateInfo,
|
||||||
classifyCapabilities,
|
classifyCapabilities,
|
||||||
@@ -154,7 +155,7 @@ export async function importPkcs12(
|
|||||||
const email = certInfo.emailAddresses[0] ?? '';
|
const email = certInfo.emailAddresses[0] ?? '';
|
||||||
|
|
||||||
const keyRecord: SmimeKeyRecord = {
|
const keyRecord: SmimeKeyRecord = {
|
||||||
id: crypto.randomUUID(),
|
id: generateUUID(),
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
certificate: leafCertDer,
|
certificate: leafCertDer,
|
||||||
certificateChain: chainCertsDer,
|
certificateChain: chainCertsDer,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Verify CMS SignedData (opaque signed) and extract the inner content.
|
* Verify CMS SignedData (opaque signed) and extract the inner content.
|
||||||
*
|
*
|
||||||
* v1 performs cryptographic signature validation and cert validity checks
|
* Performs cryptographic signature validation, cert validity checks,
|
||||||
* but does NOT implement full trust-chain or revocation validation.
|
* and trust-chain verification.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as pkijs from 'pkijs';
|
import * as pkijs from 'pkijs';
|
||||||
@@ -61,7 +61,7 @@ export async function smimeVerify(
|
|||||||
const verifyResult = await signedData.verify(
|
const verifyResult = await signedData.verify(
|
||||||
{
|
{
|
||||||
signer: 0,
|
signer: 0,
|
||||||
checkChain: false, // v1: no trust-chain validation
|
checkChain: true,
|
||||||
},
|
},
|
||||||
cryptoEngine,
|
cryptoEngine,
|
||||||
);
|
);
|
||||||
@@ -108,6 +108,11 @@ export async function smimeVerify(
|
|||||||
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
|
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect self-signed certificates (issuer === subject)
|
||||||
|
const issuerDer = new Uint8Array(signerCert.issuer.toSchema().toBER(false));
|
||||||
|
const subjectDer = new Uint8Array(signerCert.subject.toSchema().toBER(false));
|
||||||
|
const selfSigned = arraysEqual(issuerDer, subjectDer);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mimeBytes: innerContent,
|
mimeBytes: innerContent,
|
||||||
status: {
|
status: {
|
||||||
@@ -117,6 +122,7 @@ export async function smimeVerify(
|
|||||||
signatureError,
|
signatureError,
|
||||||
signerCert: signerPublicCert,
|
signerCert: signerPublicCert,
|
||||||
signerEmailMatch,
|
signerEmailMatch,
|
||||||
|
selfSigned,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface SmimeStatus {
|
|||||||
signatureError?: string;
|
signatureError?: string;
|
||||||
signerCert?: SmimePublicCert;
|
signerCert?: SmimePublicCert;
|
||||||
signerEmailMatch?: boolean;
|
signerEmailMatch?: boolean;
|
||||||
|
/** True when the signer certificate is self-signed (not chained to a trusted CA). */
|
||||||
|
selfSigned?: boolean;
|
||||||
decryptionSuccess?: boolean;
|
decryptionSuccess?: boolean;
|
||||||
decryptionError?: string;
|
decryptionError?: string;
|
||||||
unsupportedReason?: string;
|
unsupportedReason?: string;
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
|
const STALWART_AUTH_CONTEXT_COOKIE = 'jmap_stalwart_ctx';
|
||||||
|
|
||||||
|
export interface StalwartAuthContext {
|
||||||
|
serverUrl: string;
|
||||||
|
username: string;
|
||||||
|
authHeader: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieStore = Awaited<ReturnType<typeof cookies>>;
|
||||||
|
|
||||||
|
export function stalwartAuthContextCookieName(slot: number): string {
|
||||||
|
return slot === 0 ? STALWART_AUTH_CONTEXT_COOKIE : `${STALWART_AUTH_CONTEXT_COOKIE}_${slot}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidContext(payload: unknown): payload is StalwartAuthContext {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = payload as Record<string, unknown>;
|
||||||
|
return typeof candidate.serverUrl === 'string'
|
||||||
|
&& typeof candidate.username === 'string'
|
||||||
|
&& typeof candidate.authHeader === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionCookieOptions() {
|
||||||
|
const { maxAge: _maxAge, ...cookieOptions } = getCookieOptions();
|
||||||
|
return cookieOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStalwartAuthContextFromStore(
|
||||||
|
cookieStore: CookieStore,
|
||||||
|
slot: number,
|
||||||
|
): StalwartAuthContext | null {
|
||||||
|
const token = cookieStore.get(stalwartAuthContextCookieName(slot))?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const payload = decryptPayload(token);
|
||||||
|
return isValidContext(payload) ? payload : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readStalwartAuthContext(slot: number): Promise<StalwartAuthContext | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
return readStalwartAuthContextFromStore(cookieStore, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStalwartAuthContextInStore(
|
||||||
|
cookieStore: CookieStore,
|
||||||
|
slot: number,
|
||||||
|
context: StalwartAuthContext,
|
||||||
|
): void {
|
||||||
|
cookieStore.set(
|
||||||
|
stalwartAuthContextCookieName(slot),
|
||||||
|
encryptPayload(context as unknown as Record<string, unknown>),
|
||||||
|
getSessionCookieOptions(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStalwartAuthContext(slot: number, context: StalwartAuthContext): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStalwartAuthContextInStore(cookieStore: CookieStore, slot: number): void {
|
||||||
|
cookieStore.delete(stalwartAuthContextCookieName(slot));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearStalwartAuthContext(slot: number): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
|
}
|
||||||
+30
-30
@@ -1,7 +1,7 @@
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { decryptSession } from '@/lib/auth/crypto';
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
|
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||||
|
|
||||||
export interface StalwartCredentials {
|
export interface StalwartCredentials {
|
||||||
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
|
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
|
||||||
@@ -11,6 +11,7 @@ export interface StalwartCredentials {
|
|||||||
authHeader: string;
|
authHeader: string;
|
||||||
username: string;
|
username: string;
|
||||||
hasSessionCookie: boolean;
|
hasSessionCookie: boolean;
|
||||||
|
slot: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,39 +31,38 @@ function getStalwartApiUrl(jmapServerUrl: string): string {
|
|||||||
/**
|
/**
|
||||||
* Extract credentials from the incoming request.
|
* Extract credentials from the incoming request.
|
||||||
*
|
*
|
||||||
* Tries the explicit headers first (`Authorization`, `X-JMAP-Server-URL`,
|
* Credentials are read from a verified, httpOnly auth-context cookie that is
|
||||||
* `X-JMAP-Username`), then falls back to the encrypted session cookie.
|
* populated after a successful JMAP login or token refresh.
|
||||||
*/
|
*/
|
||||||
|
function parseSlot(raw: string | null): number | null {
|
||||||
|
if (raw === null) return null;
|
||||||
|
const slot = parseInt(raw, 10);
|
||||||
|
return Number.isNaN(slot) || slot < 0 || slot > 4 ? null : slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCandidateSlots(request: NextRequest): number[] {
|
||||||
|
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
|
||||||
|
?? parseSlot(request.nextUrl.searchParams.get('slot'));
|
||||||
|
|
||||||
|
return requestedSlot === null ? [0, 1, 2, 3, 4] : [requestedSlot];
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
|
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
|
||||||
const authHeader = request.headers.get('Authorization');
|
const cookieStore = await cookies();
|
||||||
const serverUrl = request.headers.get('X-JMAP-Server-URL');
|
|
||||||
const username = request.headers.get('X-JMAP-Username');
|
for (const slot of getCandidateSlots(request)) {
|
||||||
|
const context = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||||
|
if (!context) continue;
|
||||||
|
|
||||||
if (authHeader && serverUrl && username) {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const hasSessionCookie = !!cookieStore.get(SESSION_COOKIE)?.value;
|
|
||||||
return {
|
return {
|
||||||
apiUrl: getStalwartApiUrl(serverUrl),
|
apiUrl: getStalwartApiUrl(context.serverUrl),
|
||||||
serverUrl,
|
serverUrl: context.serverUrl,
|
||||||
authHeader,
|
authHeader: context.authHeader,
|
||||||
username,
|
username: context.username,
|
||||||
hasSessionCookie,
|
hasSessionCookie: !!cookieStore.get(sessionCookieName(slot))?.value,
|
||||||
|
slot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
return null;
|
||||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
const credentials = decryptSession(token);
|
|
||||||
if (!credentials) return null;
|
|
||||||
|
|
||||||
const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
|
|
||||||
return {
|
|
||||||
apiUrl: getStalwartApiUrl(credentials.serverUrl),
|
|
||||||
serverUrl: credentials.serverUrl,
|
|
||||||
authHeader: basic,
|
|
||||||
username: credentials.username,
|
|
||||||
hasSessionCookie: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import type { EmailTemplate } from './template-types';
|
import type { EmailTemplate } from './template-types';
|
||||||
import { BUILT_IN_PLACEHOLDERS } from './template-types';
|
import { BUILT_IN_PLACEHOLDERS } from './template-types';
|
||||||
|
import { generateUUID } from './utils';
|
||||||
|
|
||||||
const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g;
|
const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g;
|
||||||
const MAX_TEMPLATE_NAME_LENGTH = 200;
|
const MAX_TEMPLATE_NAME_LENGTH = 200;
|
||||||
@@ -149,7 +150,7 @@ export function importTemplates(json: string): ImportResult {
|
|||||||
const recipients = t.defaultRecipients as Record<string, unknown> | undefined;
|
const recipients = t.defaultRecipients as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
templates.push({
|
templates.push({
|
||||||
id: crypto.randomUUID(),
|
id: generateUUID(),
|
||||||
name: sanitizeText(t.name),
|
name: sanitizeText(t.name),
|
||||||
subject: sanitizeText(t.subject),
|
subject: sanitizeText(t.subject),
|
||||||
body: sanitizeText(t.body),
|
body: sanitizeText(t.body),
|
||||||
|
|||||||
+27
-27
@@ -236,11 +236,11 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
attachments: [],
|
attachments: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
debug.group('TNEF Parser');
|
debug.group('TNEF Parser', 'email');
|
||||||
debug.log('Input data size:', data.byteLength, 'bytes');
|
debug.log('email', 'Input data size:', data.byteLength, 'bytes');
|
||||||
|
|
||||||
if (data.byteLength < 6) {
|
if (data.byteLength < 6) {
|
||||||
debug.warn('TNEF data too small (< 6 bytes), skipping');
|
debug.warn('email', 'TNEF data too small (< 6 bytes), skipping');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -249,11 +249,11 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
|
|
||||||
const signature = r.readUint32LE();
|
const signature = r.readUint32LE();
|
||||||
if (signature !== TNEF_SIGNATURE) {
|
if (signature !== TNEF_SIGNATURE) {
|
||||||
debug.warn('Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
|
debug.warn('email', 'Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
debug.log('TNEF signature valid');
|
debug.log('email', 'TNEF signature valid');
|
||||||
|
|
||||||
r.skip(2); // legacy key
|
r.skip(2); // legacy key
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
attrCount++;
|
attrCount++;
|
||||||
|
|
||||||
if (attrLen > r.remaining - 2) {
|
if (attrLen > r.remaining - 2) {
|
||||||
debug.warn('Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
|
debug.warn('email', 'Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,17 +276,17 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
r.skip(2); // checksum
|
r.skip(2); // checksum
|
||||||
|
|
||||||
const levelName = level === LVL_MESSAGE ? 'MESSAGE' : level === LVL_ATTACHMENT ? 'ATTACHMENT' : 'UNKNOWN(' + level + ')';
|
const levelName = level === LVL_MESSAGE ? 'MESSAGE' : level === LVL_ATTACHMENT ? 'ATTACHMENT' : 'UNKNOWN(' + level + ')';
|
||||||
debug.log('Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
|
debug.log('email', 'Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
|
||||||
|
|
||||||
if (level === LVL_MESSAGE) {
|
if (level === LVL_MESSAGE) {
|
||||||
if (attrID === attBody) {
|
if (attrID === attBody) {
|
||||||
result.body = new TextDecoder('utf-8').decode(attrData);
|
result.body = new TextDecoder('utf-8').decode(attrData);
|
||||||
debug.log(' → Extracted plain text body (' + result.body.length + ' chars)');
|
debug.log('email', ' → Extracted plain text body (' + result.body.length + ' chars)');
|
||||||
} else if (attrID === attMAPIProps) {
|
} else if (attrID === attMAPIProps) {
|
||||||
const props = parseMAPIProps(attrData);
|
const props = parseMAPIProps(attrData);
|
||||||
debug.log(' → Parsed', props.size, 'MAPI properties from message');
|
debug.log('email', ' → Parsed', props.size, 'MAPI properties from message');
|
||||||
props.forEach((val, propID) => {
|
props.forEach((val, propID) => {
|
||||||
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
// HTML body
|
// HTML body
|
||||||
@@ -298,9 +298,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
} else {
|
} else {
|
||||||
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
|
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
|
||||||
}
|
}
|
||||||
debug.log(' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
|
debug.log('email', ' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
|
||||||
} else {
|
} else {
|
||||||
debug.log(' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
|
debug.log('email', ' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain text body from MAPI props (fallback)
|
// Plain text body from MAPI props (fallback)
|
||||||
@@ -308,9 +308,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
const bodyProp = props.get(PR_BODY);
|
const bodyProp = props.get(PR_BODY);
|
||||||
if (bodyProp?.value instanceof Uint8Array) {
|
if (bodyProp?.value instanceof Uint8Array) {
|
||||||
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
|
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
|
||||||
debug.log(' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
|
debug.log('email', ' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
|
||||||
} else {
|
} else {
|
||||||
debug.log(' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
|
debug.log('email', ' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +318,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
if (attrID === attAttachRenddata) {
|
if (attrID === attAttachRenddata) {
|
||||||
// Start of a new attachment — flush previous
|
// Start of a new attachment — flush previous
|
||||||
if (curAttach?.data) {
|
if (curAttach?.data) {
|
||||||
debug.log(' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
debug.log('email', ' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||||
result.attachments.push({
|
result.attachments.push({
|
||||||
name: curAttach.name,
|
name: curAttach.name,
|
||||||
mimeType: curAttach.mimeType,
|
mimeType: curAttach.mimeType,
|
||||||
@@ -326,40 +326,40 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
|
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
|
||||||
debug.log(' → New attachment started');
|
debug.log('email', ' → New attachment started');
|
||||||
} else if (attrID === attAttachTitle && curAttach) {
|
} else if (attrID === attAttachTitle && curAttach) {
|
||||||
let len = attrData.byteLength;
|
let len = attrData.byteLength;
|
||||||
if (len > 0 && attrData[len - 1] === 0) len--;
|
if (len > 0 && attrData[len - 1] === 0) len--;
|
||||||
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
|
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
|
||||||
debug.log(' → Attachment short name:', curAttach.name);
|
debug.log('email', ' → Attachment short name:', curAttach.name);
|
||||||
} else if (attrID === attAttachData && curAttach) {
|
} else if (attrID === attAttachData && curAttach) {
|
||||||
curAttach.data = attrData;
|
curAttach.data = attrData;
|
||||||
debug.log(' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
|
debug.log('email', ' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
|
||||||
} else if (attrID === attAttachment && curAttach) {
|
} else if (attrID === attAttachment && curAttach) {
|
||||||
const props = parseMAPIProps(attrData);
|
const props = parseMAPIProps(attrData);
|
||||||
debug.log(' → Parsed', props.size, 'MAPI properties from attachment');
|
debug.log('email', ' → Parsed', props.size, 'MAPI properties from attachment');
|
||||||
props.forEach((val, propID) => {
|
props.forEach((val, propID) => {
|
||||||
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
const longName = props.get(PR_ATTACH_LONG_FILENAME);
|
const longName = props.get(PR_ATTACH_LONG_FILENAME);
|
||||||
if (longName?.value instanceof Uint8Array) {
|
if (longName?.value instanceof Uint8Array) {
|
||||||
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
|
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
|
||||||
debug.log(' → Attachment long filename:', curAttach.name);
|
debug.log('email', ' → Attachment long filename:', curAttach.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
|
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
|
||||||
if (mimeTag?.value instanceof Uint8Array) {
|
if (mimeTag?.value instanceof Uint8Array) {
|
||||||
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
|
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
|
||||||
debug.log(' → Attachment MIME type:', curAttach.mimeType);
|
debug.log('email', ' → Attachment MIME type:', curAttach.mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
||||||
if (attachData?.value instanceof Uint8Array) {
|
if (attachData?.value instanceof Uint8Array) {
|
||||||
curAttach.data = attachData.value;
|
curAttach.data = attachData.value;
|
||||||
debug.log(' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
|
debug.log('email', ' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
|
||||||
} else {
|
} else {
|
||||||
debug.log(' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
|
debug.log('email', ' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,7 +367,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
|
|
||||||
// Flush last attachment
|
// Flush last attachment
|
||||||
if (curAttach?.data) {
|
if (curAttach?.data) {
|
||||||
debug.log('Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
debug.log('email', 'Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||||
result.attachments.push({
|
result.attachments.push({
|
||||||
name: curAttach.name,
|
name: curAttach.name,
|
||||||
mimeType: curAttach.mimeType,
|
mimeType: curAttach.mimeType,
|
||||||
@@ -375,9 +375,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
|
debug.log('email', 'TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
|
||||||
if (result.attachments.length > 0) {
|
if (result.attachments.length > 0) {
|
||||||
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })));
|
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })), 'email');
|
||||||
}
|
}
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
|
|
||||||
|
|||||||
+43
-50
@@ -7,6 +7,19 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateUUID(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Fallback: construct a v4 UUID from crypto.getRandomValues
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||||
|
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(date: Date | string): string {
|
export function formatDate(date: Date | string): string {
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -125,7 +138,16 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a duplicate of a role-based mailbox in the SAME account
|
// Never deduplicate nested mailboxes — only root-level folders can be
|
||||||
|
// duplicates of role-based mailboxes. Removing a nested folder that happens
|
||||||
|
// to share a name with a role folder (e.g. a subfolder named "Sent") would
|
||||||
|
// orphan its children to root level. (GitHub #118)
|
||||||
|
if (mb.parentId) {
|
||||||
|
result.push(mb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this root-level mailbox is a duplicate of a role-based mailbox in the SAME account
|
||||||
const accountKey = mb.accountId || '';
|
const accountKey = mb.accountId || '';
|
||||||
const accountRoles = rolesByAccount.get(accountKey) || [];
|
const accountRoles = rolesByAccount.get(accountKey) || [];
|
||||||
const lowerName = mb.name.toLowerCase();
|
const lowerName = mb.name.toLowerCase();
|
||||||
@@ -143,8 +165,7 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
|||||||
removed.push({ id: mb.id, name: mb.name, matchedRole: matchedRole!.name, parentId: mb.parentId });
|
removed.push({ id: mb.id, name: mb.name, matchedRole: matchedRole!.name, parentId: mb.parentId });
|
||||||
// Warn if this removed mailbox is a parent of other mailboxes (orphan risk)
|
// Warn if this removed mailbox is a parent of other mailboxes (orphan risk)
|
||||||
if (referencedParentIds.has(mb.id)) {
|
if (referencedParentIds.has(mb.id)) {
|
||||||
debug.warn(
|
debug.warn('jmap', `[Mailbox Tree] Deduplication removed mailbox "${mb.name}" (id: ${mb.id}) which is a parent of other mailboxes. ` +
|
||||||
`[Mailbox Tree] Deduplication removed mailbox "${mb.name}" (id: ${mb.id}) which is a parent of other mailboxes. ` +
|
|
||||||
`Matched role mailbox: "${matchedRole!.name}" (role: ${matchedRole!.role}). ` +
|
`Matched role mailbox: "${matchedRole!.name}" (role: ${matchedRole!.role}). ` +
|
||||||
`Children referencing parentId "${mb.id}" will be orphaned to root level.`
|
`Children referencing parentId "${mb.id}" will be orphaned to root level.`
|
||||||
);
|
);
|
||||||
@@ -153,7 +174,7 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (removed.length > 0) {
|
if (removed.length > 0) {
|
||||||
debug.log(`[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed);
|
debug.log('jmap', `[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -161,13 +182,13 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
|||||||
|
|
||||||
// Build a hierarchical tree structure from flat mailbox array
|
// Build a hierarchical tree structure from flat mailbox array
|
||||||
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||||
debug.log(`[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`);
|
debug.log('jmap', `[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`);
|
||||||
|
|
||||||
// Deduplicate mailboxes first
|
// Deduplicate mailboxes first
|
||||||
const deduplicated = deduplicateMailboxes(mailboxes);
|
const deduplicated = deduplicateMailboxes(mailboxes);
|
||||||
|
|
||||||
if (deduplicated.length !== mailboxes.length) {
|
if (deduplicated.length !== mailboxes.length) {
|
||||||
debug.log(`[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`);
|
debug.log('jmap', `[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate own and shared mailboxes
|
// Separate own and shared mailboxes
|
||||||
@@ -214,8 +235,7 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (orphanedMailboxes.length > 0) {
|
if (orphanedMailboxes.length > 0) {
|
||||||
debug.warn(
|
debug.warn('jmap', `[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`,
|
||||||
`[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`,
|
|
||||||
orphanedMailboxes
|
orphanedMailboxes
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,13 +252,15 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
|||||||
}
|
}
|
||||||
return max;
|
return max;
|
||||||
};
|
};
|
||||||
debug.log(
|
debug.log('jmap', `[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` +
|
||||||
`[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` +
|
|
||||||
`max depth: ${maxDepth(rootMailboxes)}, ` +
|
`max depth: ${maxDepth(rootMailboxes)}, ` +
|
||||||
`total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}`
|
`total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// If we have shared mailboxes, create a virtual "Shared Folders" parent
|
// For each shared account, create a virtual top-level account node
|
||||||
|
// containing that account's mailboxes. This places shared accounts as
|
||||||
|
// peers of the primary account's folders rather than nesting them under
|
||||||
|
// a "Shared Folders" wrapper. (GitHub #151)
|
||||||
if (sharedMailboxes.length > 0) {
|
if (sharedMailboxes.length > 0) {
|
||||||
// Group shared mailboxes by account
|
// Group shared mailboxes by account
|
||||||
const accountGroups = new Map<string, Mailbox[]>();
|
const accountGroups = new Map<string, Mailbox[]>();
|
||||||
@@ -250,20 +272,16 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
|||||||
accountGroups.get(accountId)!.push(mb);
|
accountGroups.get(accountId)!.push(mb);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create virtual nodes for each shared account
|
|
||||||
const sharedAccountNodes: MailboxNode[] = [];
|
|
||||||
|
|
||||||
accountGroups.forEach((accountMailboxes, accountId) => {
|
accountGroups.forEach((accountMailboxes, accountId) => {
|
||||||
// Create account nodes
|
// Create nodes for this account's mailboxes
|
||||||
const accountMailboxMap = new Map<string, MailboxNode>();
|
const accountMailboxMap = new Map<string, MailboxNode>();
|
||||||
const accountRootNodes: MailboxNode[] = [];
|
const accountRootNodes: MailboxNode[] = [];
|
||||||
|
|
||||||
// Create nodes for this account's mailboxes
|
|
||||||
accountMailboxes.forEach(mailbox => {
|
accountMailboxes.forEach(mailbox => {
|
||||||
accountMailboxMap.set(mailbox.id, {
|
accountMailboxMap.set(mailbox.id, {
|
||||||
...mailbox,
|
...mailbox,
|
||||||
children: [],
|
children: [],
|
||||||
depth: 2 // Account level is depth 1, these are depth 2
|
depth: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,10 +297,13 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Correctly calculate depths from account root level down
|
// Render the shared account's mailboxes flush with primary-account
|
||||||
recalculateDepths(accountRootNodes, 2);
|
// mailboxes (depth 0) so the indents line up. The virtual account
|
||||||
|
// node visually wraps them via its chevron/header rather than via
|
||||||
|
// an extra indent level. (GitHub #151)
|
||||||
|
recalculateDepths(accountRootNodes, 0);
|
||||||
|
|
||||||
// Create virtual account folder node
|
// Create virtual account folder node at top level (depth 0)
|
||||||
const accountName = accountMailboxes[0]?.accountName || accountId;
|
const accountName = accountMailboxes[0]?.accountName || accountId;
|
||||||
const accountNode: MailboxNode = {
|
const accountNode: MailboxNode = {
|
||||||
id: `shared-account-${accountId}`,
|
id: `shared-account-${accountId}`,
|
||||||
@@ -308,39 +329,11 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
|||||||
accountName: accountName,
|
accountName: accountName,
|
||||||
isShared: true,
|
isShared: true,
|
||||||
children: accountRootNodes,
|
children: accountRootNodes,
|
||||||
depth: 1,
|
depth: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
sharedAccountNodes.push(accountNode);
|
rootMailboxes.push(accountNode);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create virtual "Shared Folders" root node
|
|
||||||
const sharedFoldersNode: MailboxNode = {
|
|
||||||
id: 'shared-folders-root',
|
|
||||||
name: 'Shared Folders',
|
|
||||||
sortOrder: 999, // After all own folders
|
|
||||||
totalEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.totalEmails, 0),
|
|
||||||
unreadEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.unreadEmails, 0),
|
|
||||||
totalThreads: 0,
|
|
||||||
unreadThreads: 0,
|
|
||||||
myRights: {
|
|
||||||
mayReadItems: true,
|
|
||||||
mayAddItems: false,
|
|
||||||
mayRemoveItems: false,
|
|
||||||
maySetSeen: false,
|
|
||||||
maySetKeywords: false,
|
|
||||||
mayCreateChild: false,
|
|
||||||
mayRename: false,
|
|
||||||
mayDelete: false,
|
|
||||||
maySubmit: false,
|
|
||||||
},
|
|
||||||
isSubscribed: true,
|
|
||||||
isShared: true,
|
|
||||||
children: sharedAccountNodes,
|
|
||||||
depth: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
rootMailboxes.push(sharedFoldersNode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smart multi-level sorting
|
// Smart multi-level sorting
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { ContactCard, NameComponent, ContactOnlineService, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
import type { ContactCard, NameComponent, ContactOnlineService, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string
|
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string
|
||||||
function anniversaryDateToVcardString(date: AnniversaryDate): string {
|
function anniversaryDateToVcardString(date: AnniversaryDate): string {
|
||||||
@@ -153,7 +154,7 @@ export function parseVCard(vcfString: string): ContactCard[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||||
const id = `import-${crypto.randomUUID()}`;
|
const id = `import-${generateUUID()}`;
|
||||||
const card: ContactCard = { id, addressBookIds: {} };
|
const card: ContactCard = { id, addressBookIds: {} };
|
||||||
|
|
||||||
for (const [fullKey, values] of Object.entries(raw)) {
|
for (const [fullKey, values] of Object.entries(raw)) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* The server-side proxy handles auth and forwards requests to Stalwart's /dav/file/ endpoint.
|
* The server-side proxy handles auth and forwards requests to Stalwart's /dav/file/ endpoint.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
export interface WebDAVResource {
|
export interface WebDAVResource {
|
||||||
href: string;
|
href: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -26,6 +28,7 @@ export class WebDAVClient {
|
|||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'X-WebDAV-Method': method,
|
'X-WebDAV-Method': method,
|
||||||
'X-WebDAV-Path': path,
|
'X-WebDAV-Path': path,
|
||||||
|
...getActiveAccountSlotHeaders(),
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,6 +121,10 @@ export class WebDAVClient {
|
|||||||
xhr.open('POST', this.proxyUrl);
|
xhr.open('POST', this.proxyUrl);
|
||||||
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
|
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
|
||||||
xhr.setRequestHeader('X-WebDAV-Path', path);
|
xhr.setRequestHeader('X-WebDAV-Path', path);
|
||||||
|
const slotHeaders = getActiveAccountSlotHeaders();
|
||||||
|
if (slotHeaders['X-JMAP-Cookie-Slot']) {
|
||||||
|
xhr.setRequestHeader('X-JMAP-Cookie-Slot', slotHeaders['X-JMAP-Cookie-Slot']);
|
||||||
|
}
|
||||||
xhr.setRequestHeader('Content-Type',
|
xhr.setRequestHeader('Content-Type',
|
||||||
contentType || (file instanceof File ? file.type : 'application/octet-stream'));
|
contentType || (file instanceof File ? file.type : 'application/octet-stream'));
|
||||||
|
|
||||||
|
|||||||
+58
-5
@@ -18,6 +18,7 @@
|
|||||||
"cors_blocked": "Der Server ist erreichbar, blockiert aber Cross-Origin-Anfragen. Überprüfen Sie die CORS-Einstellungen Ihres JMAP-Servers und erlauben Sie diese Domain.",
|
"cors_blocked": "Der Server ist erreichbar, blockiert aber Cross-Origin-Anfragen. Überprüfen Sie die CORS-Einstellungen Ihres JMAP-Servers und erlauben Sie diese Domain.",
|
||||||
"server_error": "Der Server ist vorübergehend nicht erreichbar. Bitte versuchen Sie es später erneut.",
|
"server_error": "Der Server ist vorübergehend nicht erreichbar. Bitte versuchen Sie es später erneut.",
|
||||||
"generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
|
"generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
|
||||||
|
"totp_required": "Ein Zwei-Faktor-Authentifizierungscode ist erforderlich. Bitte geben Sie Ihren Code unten ein.",
|
||||||
"totp_invalid": "Ungültiger Authentifizierungscode. Überprüfen Sie Ihre Authenticator-App.",
|
"totp_invalid": "Ungültiger Authentifizierungscode. Überprüfen Sie Ihre Authenticator-App.",
|
||||||
"oauth_discovery_failed": "SSO ist aktiviert, aber der Identitätsanbieter ist nicht erreichbar. Überprüfen Sie Ihre OAuth-Konfiguration."
|
"oauth_discovery_failed": "SSO ist aktiviert, aber der Identitätsanbieter ist nicht erreichbar. Überprüfen Sie Ihre OAuth-Konfiguration."
|
||||||
},
|
},
|
||||||
@@ -992,7 +993,7 @@
|
|||||||
"title": "Kontosicherheit",
|
"title": "Kontosicherheit",
|
||||||
"description": "Verwalten Sie Ihr Passwort, Zwei-Faktor-Authentifizierung und Sicherheitseinstellungen",
|
"description": "Verwalten Sie Ihr Passwort, Zwei-Faktor-Authentifizierung und Sicherheitseinstellungen",
|
||||||
"detecting": "Serverfunktionen werden erkannt...",
|
"detecting": "Serverfunktionen werden erkannt...",
|
||||||
"not_available": "Kontosicherheitsverwaltung ist für diesen Mailserver nicht verfügbar.",
|
"not_available": "Kontosicherheitsverwaltung ist für diesen Mailserver nicht verfügbar. Erforderliche Berechtigungen könnten deaktiviert sein. Siehe die <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">Dokumentation</a> für Details.",
|
||||||
"password": {
|
"password": {
|
||||||
"title": "Passwort ändern",
|
"title": "Passwort ändern",
|
||||||
"current": "Aktuelles Passwort",
|
"current": "Aktuelles Passwort",
|
||||||
@@ -1008,6 +1009,7 @@
|
|||||||
"display_name": {
|
"display_name": {
|
||||||
"label": "Anzeigename",
|
"label": "Anzeigename",
|
||||||
"description": "Ihr Name, wie er auf dem Server angezeigt wird",
|
"description": "Ihr Name, wie er auf dem Server angezeigt wird",
|
||||||
|
"placeholder": "Geben Sie Ihren Anzeigenamen ein",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"success": "Anzeigename aktualisiert",
|
"success": "Anzeigename aktualisiert",
|
||||||
"error": "Anzeigename konnte nicht aktualisiert werden"
|
"error": "Anzeigename konnte nicht aktualisiert werden"
|
||||||
@@ -1154,6 +1156,8 @@
|
|||||||
"error_create": "Ordner konnte nicht erstellt werden",
|
"error_create": "Ordner konnte nicht erstellt werden",
|
||||||
"error_rename": "Ordner konnte nicht umbenannt werden",
|
"error_rename": "Ordner konnte nicht umbenannt werden",
|
||||||
"error_delete": "Ordner konnte nicht gelöscht werden",
|
"error_delete": "Ordner konnte nicht gelöscht werden",
|
||||||
|
"error_delete_has_children": "Ordner kann nicht gelöscht werden: Er enthält noch Unterordner. Löschen oder verschieben Sie diese zuerst.",
|
||||||
|
"error_delete_has_email": "Ordner kann nicht gelöscht werden: Er enthält noch E-Mails. Verschieben oder löschen Sie diese zuerst.",
|
||||||
"error_role": "Ordnerrolle konnte nicht aktualisiert werden"
|
"error_role": "Ordnerrolle konnte nicht aktualisiert werden"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
@@ -1163,6 +1167,23 @@
|
|||||||
"label": "Debug-Modus",
|
"label": "Debug-Modus",
|
||||||
"description": "Detaillierte Protokollierung zur Fehlerbehebung aktivieren"
|
"description": "Detaillierte Protokollierung zur Fehlerbehebung aktivieren"
|
||||||
},
|
},
|
||||||
|
"debug_categories": {
|
||||||
|
"description": "Wählen Sie aus, welche Kategorien protokolliert werden sollen. Deaktivieren Sie nicht benötigte Kategorien, um die Konsolenausgabe zu reduzieren.",
|
||||||
|
"jmap": "JMAP-Client",
|
||||||
|
"jmap_description": "Postfachvorgänge, E-Mail-Abruf und JMAP-Protokollanfragen",
|
||||||
|
"calendar": "Kalender",
|
||||||
|
"calendar_description": "Kalenderereignisse, Importe und Terminplanungsnachrichten",
|
||||||
|
"tasks": "Aufgaben",
|
||||||
|
"tasks_description": "Erstellung, Abruf und Aktualisierung von Kalenderaufgaben",
|
||||||
|
"auth": "Authentifizierung",
|
||||||
|
"auth_description": "Anmeldung, TOTP, Token-Austausch und Sitzungsverwaltung",
|
||||||
|
"filters": "Filter",
|
||||||
|
"filters_description": "Sieve-Filterregeln und Urlaubsskripte",
|
||||||
|
"email": "E-Mail-Anzeige",
|
||||||
|
"email_description": "E-Mail-Darstellung, TNEF-Verarbeitung und Als-gelesen-Markierung",
|
||||||
|
"push": "Push-Benachrichtigungen",
|
||||||
|
"push_description": "Einrichtung und Zustellung von Push-Benachrichtigungen"
|
||||||
|
},
|
||||||
"settings_sync": {
|
"settings_sync": {
|
||||||
"label": "Einstellungen synchronisieren",
|
"label": "Einstellungen synchronisieren",
|
||||||
"description": "Synchronisieren Sie Ihre Einstellungen über Browser und Geräte hinweg"
|
"description": "Synchronisieren Sie Ihre Einstellungen über Browser und Geräte hinweg"
|
||||||
@@ -1190,6 +1211,9 @@
|
|||||||
"label": "Einstellungen importieren",
|
"label": "Einstellungen importieren",
|
||||||
"description": "Einstellungen aus JSON-Datei hochladen",
|
"description": "Einstellungen aus JSON-Datei hochladen",
|
||||||
"button": "Importieren"
|
"button": "Importieren"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Bulwark Webmail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar_apps": {
|
"sidebar_apps": {
|
||||||
@@ -1206,7 +1230,13 @@
|
|||||||
"import_label": "Import Contacts",
|
"import_label": "Import Contacts",
|
||||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||||
"export_label": "Export Contacts",
|
"export_label": "Export Contacts",
|
||||||
"export_description": "Export all contacts as a vCard (.vcf) file"
|
"export_description": "Export all contacts as a vCard (.vcf) file",
|
||||||
|
"manage_title": "Adressbücher",
|
||||||
|
"manage_description": "Adressbücher umbenennen",
|
||||||
|
"no_address_books": "Keine Adressbücher gefunden",
|
||||||
|
"categories_title": "Kategorien",
|
||||||
|
"categories_description": "Kontaktkategorien umbenennen",
|
||||||
|
"no_categories": "Keine Kategorien gefunden"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "E-Mail-Filter",
|
"title": "E-Mail-Filter",
|
||||||
@@ -1611,6 +1641,10 @@
|
|||||||
"search_placeholder": "Kontakte suchen...",
|
"search_placeholder": "Kontakte suchen...",
|
||||||
"create_new": "Neuer Kontakt",
|
"create_new": "Neuer Kontakt",
|
||||||
"no_category": "Ohne Kategorie",
|
"no_category": "Ohne Kategorie",
|
||||||
|
"rename_category": "Kategorie umbenennen",
|
||||||
|
"category_name_label": "Kategoriename",
|
||||||
|
"category_renamed": "Kategorie umbenannt",
|
||||||
|
"category_rename_failed": "Kategorie konnte nicht umbenannt werden",
|
||||||
"category_added": "Kontakt zu {name} hinzugefügt",
|
"category_added": "Kontakt zu {name} hinzugefügt",
|
||||||
"category_added_plural": "{count} Kontakte zu {name} hinzugefügt",
|
"category_added_plural": "{count} Kontakte zu {name} hinzugefügt",
|
||||||
"empty_state": "Keine Kontakte",
|
"empty_state": "Keine Kontakte",
|
||||||
@@ -1637,7 +1671,13 @@
|
|||||||
"moved": "Kontakt verschoben nach {name}",
|
"moved": "Kontakt verschoben nach {name}",
|
||||||
"moved_plural": "{count} Kontakte verschoben nach {name}",
|
"moved_plural": "{count} Kontakte verschoben nach {name}",
|
||||||
"move_failed": "Kontakt konnte nicht verschoben werden",
|
"move_failed": "Kontakt konnte nicht verschoben werden",
|
||||||
"address_book": "Adressbuch"
|
"address_book": "Adressbuch",
|
||||||
|
"rename": "Adressbuch umbenennen",
|
||||||
|
"name_label": "Name des Adressbuchs",
|
||||||
|
"renamed": "Adressbuch umbenannt",
|
||||||
|
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
|
||||||
|
"default": "Standard",
|
||||||
|
"manage": "Adressbücher verwalten"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-Mail-Adressen",
|
"emails": "E-Mail-Adressen",
|
||||||
@@ -1852,6 +1892,7 @@
|
|||||||
"back_to_email": "Zurück zu E-Mails",
|
"back_to_email": "Zurück zu E-Mails",
|
||||||
"back_to_month": "Zurück zur Monatsansicht",
|
"back_to_month": "Zurück zur Monatsansicht",
|
||||||
"my_calendars": "Meine Kalender",
|
"my_calendars": "Meine Kalender",
|
||||||
|
"birthday_calendar": "Geburtstage",
|
||||||
"mini_calendar_change": "Klicken, um den Monat zu wechseln",
|
"mini_calendar_change": "Klicken, um den Monat zu wechseln",
|
||||||
"views": {
|
"views": {
|
||||||
"month": "Monat",
|
"month": "Monat",
|
||||||
@@ -1989,7 +2030,9 @@
|
|||||||
"hover_preview_delay_500ms": "0,5 Sekunden Verzögerung",
|
"hover_preview_delay_500ms": "0,5 Sekunden Verzögerung",
|
||||||
"hover_preview_delay_1s": "1 Sekunde Verzögerung",
|
"hover_preview_delay_1s": "1 Sekunde Verzögerung",
|
||||||
"hover_preview_delay_2s": "2 Sekunden Verzögerung",
|
"hover_preview_delay_2s": "2 Sekunden Verzögerung",
|
||||||
"hover_preview_off": "Deaktiviert"
|
"hover_preview_off": "Deaktiviert",
|
||||||
|
"show_birthday_calendar": "Geburtstags-Kalender",
|
||||||
|
"show_birthday_calendar_desc": "Zeigt einen virtuellen Kalender mit Geburtstagen aus deinen Kontakten"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Montag",
|
"monday": "Montag",
|
||||||
@@ -2108,6 +2151,12 @@
|
|||||||
"interval_1440": "Jeden Tag",
|
"interval_1440": "Jeden Tag",
|
||||||
"subscribe": "Abonnieren",
|
"subscribe": "Abonnieren",
|
||||||
"subscribing": "Abonniere...",
|
"subscribing": "Abonniere...",
|
||||||
|
"save": "Änderungen speichern",
|
||||||
|
"saving": "Speichert...",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"edit_title": "Abonnement bearbeiten",
|
||||||
|
"updated": "\"{name}\" aktualisiert",
|
||||||
|
"update_error": "Abonnement konnte nicht aktualisiert werden",
|
||||||
"invalid_url": "Bitte geben Sie eine gültige URL ein",
|
"invalid_url": "Bitte geben Sie eine gültige URL ein",
|
||||||
"success": "\"{name}\" abonniert",
|
"success": "\"{name}\" abonniert",
|
||||||
"error": "Abonnement konnte nicht hinzugefügt werden",
|
"error": "Abonnement konnte nicht hinzugefügt werden",
|
||||||
@@ -2315,7 +2364,10 @@
|
|||||||
"settings_folder_layout": "Folder Navigation",
|
"settings_folder_layout": "Folder Navigation",
|
||||||
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
|
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
|
||||||
"settings_folder_layout_inline": "Inline",
|
"settings_folder_layout_inline": "Inline",
|
||||||
"settings_folder_layout_sidebar": "Sidebar"
|
"settings_folder_layout_sidebar": "Sidebar",
|
||||||
|
"disabled_title": "Die Dateifunktion wurde von Ihrem Administrator deaktiviert",
|
||||||
|
"disabled_description": "Große Datei-Uploads über WebDAV können Stalwart/RocksDB-Instabilität verursachen, einschließlich Out-of-Memory-Abstürzen und nicht wiederherstellbarer Festplattennutzung. Gelöschte Dateien werden möglicherweise nicht sofort aus dem Blob-Speicher entfernt. Diese Funktion wird für Produktionsumgebungen nicht empfohlen.",
|
||||||
|
"stability_warning": "Große Datei-Uploads können zu Serverinstabilität führen. Gelöschte Dateien werden möglicherweise nicht sofort aus dem Speicher entfernt. Mit Vorsicht verwenden."
|
||||||
},
|
},
|
||||||
"smime": {
|
"smime": {
|
||||||
"your_certificates": "Ihre Zertifikate",
|
"your_certificates": "Ihre Zertifikate",
|
||||||
@@ -2384,6 +2436,7 @@
|
|||||||
"status_signed_valid": "Signatur überprüft",
|
"status_signed_valid": "Signatur überprüft",
|
||||||
"status_signed_invalid": "Signaturprüfung fehlgeschlagen",
|
"status_signed_invalid": "Signaturprüfung fehlgeschlagen",
|
||||||
"status_signed_expired_cert": "Mit einem abgelaufenen Zertifikat signiert",
|
"status_signed_expired_cert": "Mit einem abgelaufenen Zertifikat signiert",
|
||||||
|
"status_signed_self_signed": "Signatur gültig, aber das Zertifikat ist selbstsigniert (nicht vertrauenswürdig)",
|
||||||
"status_signed_mismatch": "Signatur ist gültig, aber der Unterzeichner stimmt nicht mit dem Absender überein",
|
"status_signed_mismatch": "Signatur ist gültig, aber der Unterzeichner stimmt nicht mit dem Absender überein",
|
||||||
"status_unsupported": "Nicht unterstütztes S/MIME-Format",
|
"status_unsupported": "Nicht unterstütztes S/MIME-Format",
|
||||||
"auto_import_signer_certs": "Unterzeichnerzertifikate automatisch importieren",
|
"auto_import_signer_certs": "Unterzeichnerzertifikate automatisch importieren",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user