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 |
@@ -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,59 @@
|
|||||||
# 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)
|
## 1.4.11 (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) => {
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ export default function FilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
{!filesEnabled ? (
|
{!filesEnabled ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="max-w-lg text-center space-y-3 px-4">
|
<div className="max-w-lg text-center space-y-3 px-4">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+112
-5
@@ -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: () => {
|
||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -79,10 +79,14 @@ export async function POST(request: NextRequest) {
|
|||||||
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, {
|
||||||
@@ -90,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') {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -533,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;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ 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 { 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');
|
||||||
@@ -18,6 +23,20 @@ export function AdvancedSettings() {
|
|||||||
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();
|
||||||
@@ -65,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') && (
|
||||||
@@ -147,5 +204,6 @@ export function AdvancedSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-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;
|
||||||
@@ -41,8 +42,9 @@ export interface FeatureGates {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -80,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[];
|
||||||
}
|
}
|
||||||
@@ -90,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,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',
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
+107
-27
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 [];
|
||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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
-40
@@ -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();
|
||||||
@@ -244,7 +257,10 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
|||||||
`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[]>();
|
||||||
@@ -256,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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -285,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}`,
|
||||||
@@ -314,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)) {
|
||||||
|
|||||||
+29
-4
@@ -993,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",
|
||||||
@@ -1009,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"
|
||||||
@@ -1155,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": {
|
||||||
@@ -1208,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": {
|
||||||
@@ -1224,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",
|
||||||
@@ -1629,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",
|
||||||
@@ -1655,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",
|
||||||
@@ -1870,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",
|
||||||
@@ -2007,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",
|
||||||
|
|||||||
+29
-4
@@ -993,7 +993,7 @@
|
|||||||
"title": "Account Security",
|
"title": "Account Security",
|
||||||
"description": "Manage your password, two-factor authentication, and security settings",
|
"description": "Manage your password, two-factor authentication, and security settings",
|
||||||
"detecting": "Detecting server capabilities...",
|
"detecting": "Detecting server capabilities...",
|
||||||
"not_available": "Account security management is not available for this mail server.",
|
"not_available": "Account security management is not available for this mail server. Required permissions may be disabled. See the <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">documentation</a> for details.",
|
||||||
"password": {
|
"password": {
|
||||||
"title": "Change Password",
|
"title": "Change Password",
|
||||||
"current": "Current Password",
|
"current": "Current Password",
|
||||||
@@ -1009,6 +1009,7 @@
|
|||||||
"display_name": {
|
"display_name": {
|
||||||
"label": "Display Name",
|
"label": "Display Name",
|
||||||
"description": "Your name as it appears on the server",
|
"description": "Your name as it appears on the server",
|
||||||
|
"placeholder": "Enter your display name",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"success": "Display name updated",
|
"success": "Display name updated",
|
||||||
"error": "Failed to update display name"
|
"error": "Failed to update display name"
|
||||||
@@ -1155,6 +1156,8 @@
|
|||||||
"error_create": "Failed to create folder",
|
"error_create": "Failed to create folder",
|
||||||
"error_rename": "Failed to rename folder",
|
"error_rename": "Failed to rename folder",
|
||||||
"error_delete": "Failed to delete folder",
|
"error_delete": "Failed to delete folder",
|
||||||
|
"error_delete_has_children": "Cannot delete folder: it still contains subfolders. Delete or move them first.",
|
||||||
|
"error_delete_has_email": "Cannot delete folder: it still contains emails. Move or delete them first.",
|
||||||
"error_role": "Failed to update folder role"
|
"error_role": "Failed to update folder role"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
@@ -1208,6 +1211,9 @@
|
|||||||
"label": "Import Settings",
|
"label": "Import Settings",
|
||||||
"description": "Upload settings from JSON file",
|
"description": "Upload settings from JSON file",
|
||||||
"button": "Import"
|
"button": "Import"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Bulwark Webmail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar_apps": {
|
"sidebar_apps": {
|
||||||
@@ -1224,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": "Address Books",
|
||||||
|
"manage_description": "Rename your address books",
|
||||||
|
"no_address_books": "No address books found",
|
||||||
|
"categories_title": "Categories",
|
||||||
|
"categories_description": "Rename contact categories",
|
||||||
|
"no_categories": "No categories found"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "Email Filters",
|
"title": "Email Filters",
|
||||||
@@ -1629,6 +1641,10 @@
|
|||||||
"search_placeholder": "Search contacts...",
|
"search_placeholder": "Search contacts...",
|
||||||
"create_new": "New Contact",
|
"create_new": "New Contact",
|
||||||
"no_category": "No Category",
|
"no_category": "No Category",
|
||||||
|
"rename_category": "Rename category",
|
||||||
|
"category_name_label": "Category name",
|
||||||
|
"category_renamed": "Category renamed",
|
||||||
|
"category_rename_failed": "Failed to rename category",
|
||||||
"category_added": "Contact added to {name}",
|
"category_added": "Contact added to {name}",
|
||||||
"category_added_plural": "{count} contacts added to {name}",
|
"category_added_plural": "{count} contacts added to {name}",
|
||||||
"empty_state": "No contacts yet",
|
"empty_state": "No contacts yet",
|
||||||
@@ -1655,7 +1671,13 @@
|
|||||||
"moved": "Contact moved to {name}",
|
"moved": "Contact moved to {name}",
|
||||||
"moved_plural": "{count} contacts moved to {name}",
|
"moved_plural": "{count} contacts moved to {name}",
|
||||||
"move_failed": "Failed to move contact",
|
"move_failed": "Failed to move contact",
|
||||||
"address_book": "Address Book"
|
"address_book": "Address Book",
|
||||||
|
"rename": "Rename address book",
|
||||||
|
"name_label": "Address book name",
|
||||||
|
"renamed": "Address book renamed",
|
||||||
|
"rename_failed": "Failed to rename address book",
|
||||||
|
"default": "Default",
|
||||||
|
"manage": "Manage address books"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Email Addresses",
|
"emails": "Email Addresses",
|
||||||
@@ -1870,6 +1892,7 @@
|
|||||||
"back_to_email": "Back to email",
|
"back_to_email": "Back to email",
|
||||||
"back_to_month": "Back to month",
|
"back_to_month": "Back to month",
|
||||||
"my_calendars": "Calendars",
|
"my_calendars": "Calendars",
|
||||||
|
"birthday_calendar": "Birthdays",
|
||||||
"mini_calendar_change": "Click to change month",
|
"mini_calendar_change": "Click to change month",
|
||||||
"views": {
|
"views": {
|
||||||
"month": "Month",
|
"month": "Month",
|
||||||
@@ -2007,7 +2030,9 @@
|
|||||||
"hover_preview_delay_500ms": "0.5-second delay",
|
"hover_preview_delay_500ms": "0.5-second delay",
|
||||||
"hover_preview_delay_1s": "1-second delay",
|
"hover_preview_delay_1s": "1-second delay",
|
||||||
"hover_preview_delay_2s": "2-second delay",
|
"hover_preview_delay_2s": "2-second delay",
|
||||||
"hover_preview_off": "Disabled"
|
"hover_preview_off": "Disabled",
|
||||||
|
"show_birthday_calendar": "Contact birthday calendar",
|
||||||
|
"show_birthday_calendar_desc": "Show a virtual calendar with birthdays from your contacts"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Monday",
|
"monday": "Monday",
|
||||||
|
|||||||
+29
-4
@@ -993,7 +993,7 @@
|
|||||||
"title": "Seguridad de la cuenta",
|
"title": "Seguridad de la cuenta",
|
||||||
"description": "Administre su contraseña, autenticación de dos factores y configuración de seguridad",
|
"description": "Administre su contraseña, autenticación de dos factores y configuración de seguridad",
|
||||||
"detecting": "Detectando capacidades del servidor...",
|
"detecting": "Detectando capacidades del servidor...",
|
||||||
"not_available": "La gestión de seguridad de la cuenta no está disponible para este servidor de correo.",
|
"not_available": "La gestión de seguridad de la cuenta no está disponible para este servidor de correo. Es posible que los permisos requeridos estén deshabilitados. Consulte la <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">documentación</a> para obtener más detalles.",
|
||||||
"password": {
|
"password": {
|
||||||
"title": "Cambiar contraseña",
|
"title": "Cambiar contraseña",
|
||||||
"current": "Contraseña actual",
|
"current": "Contraseña actual",
|
||||||
@@ -1009,6 +1009,7 @@
|
|||||||
"display_name": {
|
"display_name": {
|
||||||
"label": "Nombre para mostrar",
|
"label": "Nombre para mostrar",
|
||||||
"description": "Su nombre tal como aparece en el servidor",
|
"description": "Su nombre tal como aparece en el servidor",
|
||||||
|
"placeholder": "Ingrese su nombre para mostrar",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"success": "Nombre para mostrar actualizado",
|
"success": "Nombre para mostrar actualizado",
|
||||||
"error": "No se pudo actualizar el nombre para mostrar"
|
"error": "No se pudo actualizar el nombre para mostrar"
|
||||||
@@ -1155,6 +1156,8 @@
|
|||||||
"error_create": "Error al crear la carpeta",
|
"error_create": "Error al crear la carpeta",
|
||||||
"error_rename": "Error al renombrar la carpeta",
|
"error_rename": "Error al renombrar la carpeta",
|
||||||
"error_delete": "Error al eliminar la carpeta",
|
"error_delete": "Error al eliminar la carpeta",
|
||||||
|
"error_delete_has_children": "No se puede eliminar la carpeta: aún contiene subcarpetas. Elimínelas o muévalas primero.",
|
||||||
|
"error_delete_has_email": "No se puede eliminar la carpeta: aún contiene correos. Muévalos o elimínelos primero.",
|
||||||
"error_role": "Error al actualizar el rol de la carpeta"
|
"error_role": "Error al actualizar el rol de la carpeta"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
@@ -1208,6 +1211,9 @@
|
|||||||
"label": "Importar Configuración",
|
"label": "Importar Configuración",
|
||||||
"description": "Cargar configuración desde archivo JSON",
|
"description": "Cargar configuración desde archivo JSON",
|
||||||
"button": "Importar"
|
"button": "Importar"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Bulwark Webmail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar_apps": {
|
"sidebar_apps": {
|
||||||
@@ -1224,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": "Libretas de direcciones",
|
||||||
|
"manage_description": "Renombrar tus libretas de direcciones",
|
||||||
|
"no_address_books": "No se encontraron libretas de direcciones",
|
||||||
|
"categories_title": "Categorías",
|
||||||
|
"categories_description": "Renombrar categorías de contactos",
|
||||||
|
"no_categories": "No se encontraron categorías"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "Filtros de correo",
|
"title": "Filtros de correo",
|
||||||
@@ -1629,6 +1641,10 @@
|
|||||||
"search_placeholder": "Buscar contactos...",
|
"search_placeholder": "Buscar contactos...",
|
||||||
"create_new": "Nuevo contacto",
|
"create_new": "Nuevo contacto",
|
||||||
"no_category": "Sin categoría",
|
"no_category": "Sin categoría",
|
||||||
|
"rename_category": "Renombrar categoría",
|
||||||
|
"category_name_label": "Nombre de la categoría",
|
||||||
|
"category_renamed": "Categoría renombrada",
|
||||||
|
"category_rename_failed": "Error al renombrar la categoría",
|
||||||
"category_added": "Contacto añadido a {name}",
|
"category_added": "Contacto añadido a {name}",
|
||||||
"category_added_plural": "{count} contactos añadidos a {name}",
|
"category_added_plural": "{count} contactos añadidos a {name}",
|
||||||
"empty_state": "No hay contactos",
|
"empty_state": "No hay contactos",
|
||||||
@@ -1655,7 +1671,13 @@
|
|||||||
"moved": "Contacto movido a {name}",
|
"moved": "Contacto movido a {name}",
|
||||||
"moved_plural": "{count} contactos movidos a {name}",
|
"moved_plural": "{count} contactos movidos a {name}",
|
||||||
"move_failed": "Error al mover el contacto",
|
"move_failed": "Error al mover el contacto",
|
||||||
"address_book": "Libreta de direcciones"
|
"address_book": "Libreta de direcciones",
|
||||||
|
"rename": "Renombrar libreta de direcciones",
|
||||||
|
"name_label": "Nombre de la libreta de direcciones",
|
||||||
|
"renamed": "Libreta de direcciones renombrada",
|
||||||
|
"rename_failed": "Error al renombrar la libreta de direcciones",
|
||||||
|
"default": "Predeterminada",
|
||||||
|
"manage": "Administrar libretas de direcciones"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Direcciones de correo",
|
"emails": "Direcciones de correo",
|
||||||
@@ -1870,6 +1892,7 @@
|
|||||||
"back_to_email": "Volver al correo",
|
"back_to_email": "Volver al correo",
|
||||||
"back_to_month": "Volver al mes",
|
"back_to_month": "Volver al mes",
|
||||||
"my_calendars": "Mis calendarios",
|
"my_calendars": "Mis calendarios",
|
||||||
|
"birthday_calendar": "Cumpleaños",
|
||||||
"mini_calendar_change": "Clic para cambiar de mes",
|
"mini_calendar_change": "Clic para cambiar de mes",
|
||||||
"views": {
|
"views": {
|
||||||
"month": "Mes",
|
"month": "Mes",
|
||||||
@@ -2007,7 +2030,9 @@
|
|||||||
"hover_preview_delay_500ms": "Retraso de 0,5 segundos",
|
"hover_preview_delay_500ms": "Retraso de 0,5 segundos",
|
||||||
"hover_preview_delay_1s": "Retraso de 1 segundo",
|
"hover_preview_delay_1s": "Retraso de 1 segundo",
|
||||||
"hover_preview_delay_2s": "Retraso de 2 segundos",
|
"hover_preview_delay_2s": "Retraso de 2 segundos",
|
||||||
"hover_preview_off": "Desactivado"
|
"hover_preview_off": "Desactivado",
|
||||||
|
"show_birthday_calendar": "Calendario de cumpleaños",
|
||||||
|
"show_birthday_calendar_desc": "Mostrar un calendario virtual con los cumpleaños de tus contactos"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Lunes",
|
"monday": "Lunes",
|
||||||
|
|||||||
+29
-4
@@ -993,7 +993,7 @@
|
|||||||
"title": "Sécurité du compte",
|
"title": "Sécurité du compte",
|
||||||
"description": "Gérez votre mot de passe, l'authentification à deux facteurs et les paramètres de sécurité",
|
"description": "Gérez votre mot de passe, l'authentification à deux facteurs et les paramètres de sécurité",
|
||||||
"detecting": "Détection des capacités du serveur...",
|
"detecting": "Détection des capacités du serveur...",
|
||||||
"not_available": "La gestion de la sécurité du compte n'est pas disponible pour ce serveur de messagerie.",
|
"not_available": "La gestion de la sécurité du compte n'est pas disponible pour ce serveur de messagerie. Les permissions requises peuvent être désactivées. Consultez la <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">documentation</a> pour plus de détails.",
|
||||||
"password": {
|
"password": {
|
||||||
"title": "Changer le mot de passe",
|
"title": "Changer le mot de passe",
|
||||||
"current": "Mot de passe actuel",
|
"current": "Mot de passe actuel",
|
||||||
@@ -1009,6 +1009,7 @@
|
|||||||
"display_name": {
|
"display_name": {
|
||||||
"label": "Nom d'affichage",
|
"label": "Nom d'affichage",
|
||||||
"description": "Votre nom tel qu'il apparaît sur le serveur",
|
"description": "Votre nom tel qu'il apparaît sur le serveur",
|
||||||
|
"placeholder": "Entrez votre nom d'affichage",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"success": "Nom d'affichage mis à jour",
|
"success": "Nom d'affichage mis à jour",
|
||||||
"error": "Impossible de mettre à jour le nom d'affichage"
|
"error": "Impossible de mettre à jour le nom d'affichage"
|
||||||
@@ -1155,6 +1156,8 @@
|
|||||||
"error_create": "Échec de la création du dossier",
|
"error_create": "Échec de la création du dossier",
|
||||||
"error_rename": "Échec du renommage du dossier",
|
"error_rename": "Échec du renommage du dossier",
|
||||||
"error_delete": "Échec de la suppression du dossier",
|
"error_delete": "Échec de la suppression du dossier",
|
||||||
|
"error_delete_has_children": "Impossible de supprimer le dossier : il contient encore des sous-dossiers. Supprimez-les ou déplacez-les d'abord.",
|
||||||
|
"error_delete_has_email": "Impossible de supprimer le dossier : il contient encore des e-mails. Déplacez-les ou supprimez-les d'abord.",
|
||||||
"error_role": "Échec de la mise à jour du rôle du dossier"
|
"error_role": "Échec de la mise à jour du rôle du dossier"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
@@ -1208,6 +1211,9 @@
|
|||||||
"label": "Importer les paramètres",
|
"label": "Importer les paramètres",
|
||||||
"description": "Charger les paramètres depuis un fichier JSON",
|
"description": "Charger les paramètres depuis un fichier JSON",
|
||||||
"button": "Importer"
|
"button": "Importer"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Bulwark Webmail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar_apps": {
|
"sidebar_apps": {
|
||||||
@@ -1224,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": "Carnets d'adresses",
|
||||||
|
"manage_description": "Renommer vos carnets d'adresses",
|
||||||
|
"no_address_books": "Aucun carnet d'adresses trouvé",
|
||||||
|
"categories_title": "Catégories",
|
||||||
|
"categories_description": "Renommer les catégories de contacts",
|
||||||
|
"no_categories": "Aucune catégorie trouvée"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "Filtres de courrier",
|
"title": "Filtres de courrier",
|
||||||
@@ -1629,6 +1641,10 @@
|
|||||||
"search_placeholder": "Rechercher des contacts...",
|
"search_placeholder": "Rechercher des contacts...",
|
||||||
"create_new": "Nouveau contact",
|
"create_new": "Nouveau contact",
|
||||||
"no_category": "Sans catégorie",
|
"no_category": "Sans catégorie",
|
||||||
|
"rename_category": "Renommer la catégorie",
|
||||||
|
"category_name_label": "Nom de la catégorie",
|
||||||
|
"category_renamed": "Catégorie renommée",
|
||||||
|
"category_rename_failed": "Échec du renommage de la catégorie",
|
||||||
"category_added": "Contact ajouté à {name}",
|
"category_added": "Contact ajouté à {name}",
|
||||||
"category_added_plural": "{count} contacts ajoutés à {name}",
|
"category_added_plural": "{count} contacts ajoutés à {name}",
|
||||||
"empty_state": "Aucun contact",
|
"empty_state": "Aucun contact",
|
||||||
@@ -1655,7 +1671,13 @@
|
|||||||
"moved": "Contact déplacé vers {name}",
|
"moved": "Contact déplacé vers {name}",
|
||||||
"moved_plural": "{count} contacts déplacés vers {name}",
|
"moved_plural": "{count} contacts déplacés vers {name}",
|
||||||
"move_failed": "Échec du déplacement du contact",
|
"move_failed": "Échec du déplacement du contact",
|
||||||
"address_book": "Carnet d'adresses"
|
"address_book": "Carnet d'adresses",
|
||||||
|
"rename": "Renommer le carnet d'adresses",
|
||||||
|
"name_label": "Nom du carnet d'adresses",
|
||||||
|
"renamed": "Carnet d'adresses renommé",
|
||||||
|
"rename_failed": "Échec du renommage du carnet d'adresses",
|
||||||
|
"default": "Par défaut",
|
||||||
|
"manage": "Gérer les carnets d'adresses"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Adresses e-mail",
|
"emails": "Adresses e-mail",
|
||||||
@@ -1870,6 +1892,7 @@
|
|||||||
"back_to_email": "Retour aux e-mails",
|
"back_to_email": "Retour aux e-mails",
|
||||||
"back_to_month": "Retour au mois",
|
"back_to_month": "Retour au mois",
|
||||||
"my_calendars": "Mes calendriers",
|
"my_calendars": "Mes calendriers",
|
||||||
|
"birthday_calendar": "Anniversaires",
|
||||||
"mini_calendar_change": "Cliquer pour changer de mois",
|
"mini_calendar_change": "Cliquer pour changer de mois",
|
||||||
"views": {
|
"views": {
|
||||||
"month": "Mois",
|
"month": "Mois",
|
||||||
@@ -2007,7 +2030,9 @@
|
|||||||
"hover_preview_delay_500ms": "Délai de 0,5 seconde",
|
"hover_preview_delay_500ms": "Délai de 0,5 seconde",
|
||||||
"hover_preview_delay_1s": "Délai de 1 seconde",
|
"hover_preview_delay_1s": "Délai de 1 seconde",
|
||||||
"hover_preview_delay_2s": "Délai de 2 secondes",
|
"hover_preview_delay_2s": "Délai de 2 secondes",
|
||||||
"hover_preview_off": "Désactivé"
|
"hover_preview_off": "Désactivé",
|
||||||
|
"show_birthday_calendar": "Calendrier des anniversaires",
|
||||||
|
"show_birthday_calendar_desc": "Afficher un calendrier virtuel avec les anniversaires de vos contacts"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Lundi",
|
"monday": "Lundi",
|
||||||
|
|||||||
+29
-4
@@ -993,7 +993,7 @@
|
|||||||
"title": "Sicurezza dell'account",
|
"title": "Sicurezza dell'account",
|
||||||
"description": "Gestisci la tua password, l'autenticazione a due fattori e le impostazioni di sicurezza",
|
"description": "Gestisci la tua password, l'autenticazione a due fattori e le impostazioni di sicurezza",
|
||||||
"detecting": "Rilevamento delle funzionalità del server...",
|
"detecting": "Rilevamento delle funzionalità del server...",
|
||||||
"not_available": "La gestione della sicurezza dell'account non è disponibile per questo server di posta.",
|
"not_available": "La gestione della sicurezza dell'account non è disponibile per questo server di posta. I permessi richiesti potrebbero essere disabilitati. Consultare la <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">documentazione</a> per i dettagli.",
|
||||||
"password": {
|
"password": {
|
||||||
"title": "Cambia password",
|
"title": "Cambia password",
|
||||||
"current": "Password attuale",
|
"current": "Password attuale",
|
||||||
@@ -1009,6 +1009,7 @@
|
|||||||
"display_name": {
|
"display_name": {
|
||||||
"label": "Nome visualizzato",
|
"label": "Nome visualizzato",
|
||||||
"description": "Il tuo nome come appare sul server",
|
"description": "Il tuo nome come appare sul server",
|
||||||
|
"placeholder": "Inserisci il tuo nome visualizzato",
|
||||||
"save": "Salva",
|
"save": "Salva",
|
||||||
"success": "Nome visualizzato aggiornato",
|
"success": "Nome visualizzato aggiornato",
|
||||||
"error": "Impossibile aggiornare il nome visualizzato"
|
"error": "Impossibile aggiornare il nome visualizzato"
|
||||||
@@ -1155,6 +1156,8 @@
|
|||||||
"error_create": "Impossibile creare la cartella",
|
"error_create": "Impossibile creare la cartella",
|
||||||
"error_rename": "Impossibile rinominare la cartella",
|
"error_rename": "Impossibile rinominare la cartella",
|
||||||
"error_delete": "Impossibile eliminare la cartella",
|
"error_delete": "Impossibile eliminare la cartella",
|
||||||
|
"error_delete_has_children": "Impossibile eliminare la cartella: contiene ancora sottocartelle. Eliminale o spostale prima.",
|
||||||
|
"error_delete_has_email": "Impossibile eliminare la cartella: contiene ancora email. Spostale o eliminale prima.",
|
||||||
"error_role": "Impossibile aggiornare il ruolo della cartella"
|
"error_role": "Impossibile aggiornare il ruolo della cartella"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
@@ -1208,6 +1211,9 @@
|
|||||||
"label": "Importa impostazioni",
|
"label": "Importa impostazioni",
|
||||||
"description": "Carica impostazioni da file JSON",
|
"description": "Carica impostazioni da file JSON",
|
||||||
"button": "Importa"
|
"button": "Importa"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Bulwark Webmail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar_apps": {
|
"sidebar_apps": {
|
||||||
@@ -1224,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": "Rubriche",
|
||||||
|
"manage_description": "Rinomina le tue rubriche",
|
||||||
|
"no_address_books": "Nessuna rubrica trovata",
|
||||||
|
"categories_title": "Categorie",
|
||||||
|
"categories_description": "Rinomina le categorie dei contatti",
|
||||||
|
"no_categories": "Nessuna categoria trovata"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "Filtri email",
|
"title": "Filtri email",
|
||||||
@@ -1629,6 +1641,10 @@
|
|||||||
"search_placeholder": "Cerca contatti...",
|
"search_placeholder": "Cerca contatti...",
|
||||||
"create_new": "Nuovo contatto",
|
"create_new": "Nuovo contatto",
|
||||||
"no_category": "Senza categoria",
|
"no_category": "Senza categoria",
|
||||||
|
"rename_category": "Rinomina categoria",
|
||||||
|
"category_name_label": "Nome della categoria",
|
||||||
|
"category_renamed": "Categoria rinominata",
|
||||||
|
"category_rename_failed": "Impossibile rinominare la categoria",
|
||||||
"category_added": "Contatto aggiunto a {name}",
|
"category_added": "Contatto aggiunto a {name}",
|
||||||
"category_added_plural": "{count} contatti aggiunti a {name}",
|
"category_added_plural": "{count} contatti aggiunti a {name}",
|
||||||
"empty_state": "Nessun contatto",
|
"empty_state": "Nessun contatto",
|
||||||
@@ -1655,7 +1671,13 @@
|
|||||||
"moved": "Contatto spostato in {name}",
|
"moved": "Contatto spostato in {name}",
|
||||||
"moved_plural": "{count} contatti spostati in {name}",
|
"moved_plural": "{count} contatti spostati in {name}",
|
||||||
"move_failed": "Impossibile spostare il contatto",
|
"move_failed": "Impossibile spostare il contatto",
|
||||||
"address_book": "Rubrica"
|
"address_book": "Rubrica",
|
||||||
|
"rename": "Rinomina rubrica",
|
||||||
|
"name_label": "Nome della rubrica",
|
||||||
|
"renamed": "Rubrica rinominata",
|
||||||
|
"rename_failed": "Impossibile rinominare la rubrica",
|
||||||
|
"default": "Predefinita",
|
||||||
|
"manage": "Gestisci rubriche"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Indirizzi email",
|
"emails": "Indirizzi email",
|
||||||
@@ -1870,6 +1892,7 @@
|
|||||||
"back_to_email": "Torna alla posta",
|
"back_to_email": "Torna alla posta",
|
||||||
"back_to_month": "Torna al mese",
|
"back_to_month": "Torna al mese",
|
||||||
"my_calendars": "I miei calendari",
|
"my_calendars": "I miei calendari",
|
||||||
|
"birthday_calendar": "Compleanni",
|
||||||
"mini_calendar_change": "Clicca per cambiare mese",
|
"mini_calendar_change": "Clicca per cambiare mese",
|
||||||
"views": {
|
"views": {
|
||||||
"month": "Mese",
|
"month": "Mese",
|
||||||
@@ -2007,7 +2030,9 @@
|
|||||||
"hover_preview_delay_500ms": "Ritardo di 0,5 secondi",
|
"hover_preview_delay_500ms": "Ritardo di 0,5 secondi",
|
||||||
"hover_preview_delay_1s": "Ritardo di 1 secondo",
|
"hover_preview_delay_1s": "Ritardo di 1 secondo",
|
||||||
"hover_preview_delay_2s": "Ritardo di 2 secondi",
|
"hover_preview_delay_2s": "Ritardo di 2 secondi",
|
||||||
"hover_preview_off": "Disabilitato"
|
"hover_preview_off": "Disabilitato",
|
||||||
|
"show_birthday_calendar": "Calendario compleanni",
|
||||||
|
"show_birthday_calendar_desc": "Mostra un calendario virtuale con i compleanni dei tuoi contatti"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Lunedì",
|
"monday": "Lunedì",
|
||||||
|
|||||||
+29
-4
@@ -993,7 +993,7 @@
|
|||||||
"title": "アカウントセキュリティ",
|
"title": "アカウントセキュリティ",
|
||||||
"description": "パスワード、二要素認証、セキュリティ設定を管理します",
|
"description": "パスワード、二要素認証、セキュリティ設定を管理します",
|
||||||
"detecting": "サーバー機能を検出中...",
|
"detecting": "サーバー機能を検出中...",
|
||||||
"not_available": "このメールサーバーではアカウントセキュリティ管理は利用できません。",
|
"not_available": "このメールサーバーではアカウントセキュリティ管理は利用できません。必要な権限が無効になっている可能性があります。詳細は<a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">ドキュメント</a>を参照してください。",
|
||||||
"password": {
|
"password": {
|
||||||
"title": "パスワードの変更",
|
"title": "パスワードの変更",
|
||||||
"current": "現在のパスワード",
|
"current": "現在のパスワード",
|
||||||
@@ -1009,6 +1009,7 @@
|
|||||||
"display_name": {
|
"display_name": {
|
||||||
"label": "表示名",
|
"label": "表示名",
|
||||||
"description": "サーバー上に表示される名前",
|
"description": "サーバー上に表示される名前",
|
||||||
|
"placeholder": "表示名を入力",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"success": "表示名が更新されました",
|
"success": "表示名が更新されました",
|
||||||
"error": "表示名を更新できませんでした"
|
"error": "表示名を更新できませんでした"
|
||||||
@@ -1155,6 +1156,8 @@
|
|||||||
"error_create": "フォルダーの作成に失敗しました",
|
"error_create": "フォルダーの作成に失敗しました",
|
||||||
"error_rename": "フォルダー名の変更に失敗しました",
|
"error_rename": "フォルダー名の変更に失敗しました",
|
||||||
"error_delete": "フォルダーの削除に失敗しました",
|
"error_delete": "フォルダーの削除に失敗しました",
|
||||||
|
"error_delete_has_children": "フォルダーを削除できません:サブフォルダーが含まれています。先にサブフォルダーを削除または移動してください。",
|
||||||
|
"error_delete_has_email": "フォルダーを削除できません:メールが含まれています。先にメールを移動または削除してください。",
|
||||||
"error_role": "フォルダーの役割の更新に失敗しました"
|
"error_role": "フォルダーの役割の更新に失敗しました"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
@@ -1208,6 +1211,9 @@
|
|||||||
"label": "設定をインポート",
|
"label": "設定をインポート",
|
||||||
"description": "JSONファイルから設定をアップロード",
|
"description": "JSONファイルから設定をアップロード",
|
||||||
"button": "インポート"
|
"button": "インポート"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Bulwark Webmail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar_apps": {
|
"sidebar_apps": {
|
||||||
@@ -1224,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": "アドレス帳",
|
||||||
|
"manage_description": "アドレス帳の名前を変更",
|
||||||
|
"no_address_books": "アドレス帳が見つかりません",
|
||||||
|
"categories_title": "カテゴリ",
|
||||||
|
"categories_description": "連絡先カテゴリの名前を変更",
|
||||||
|
"no_categories": "カテゴリが見つかりません"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "メールフィルター",
|
"title": "メールフィルター",
|
||||||
@@ -1629,6 +1641,10 @@
|
|||||||
"search_placeholder": "連絡先を検索...",
|
"search_placeholder": "連絡先を検索...",
|
||||||
"create_new": "新しい連絡先",
|
"create_new": "新しい連絡先",
|
||||||
"no_category": "カテゴリなし",
|
"no_category": "カテゴリなし",
|
||||||
|
"rename_category": "カテゴリの名前を変更",
|
||||||
|
"category_name_label": "カテゴリ名",
|
||||||
|
"category_renamed": "カテゴリの名前を変更しました",
|
||||||
|
"category_rename_failed": "カテゴリの名前変更に失敗しました",
|
||||||
"category_added": "{name} に連絡先を追加しました",
|
"category_added": "{name} に連絡先を追加しました",
|
||||||
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
|
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
|
||||||
"empty_state": "連絡先がありません",
|
"empty_state": "連絡先がありません",
|
||||||
@@ -1655,7 +1671,13 @@
|
|||||||
"moved": "連絡先を {name} に移動しました",
|
"moved": "連絡先を {name} に移動しました",
|
||||||
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
|
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
|
||||||
"move_failed": "連絡先の移動に失敗しました",
|
"move_failed": "連絡先の移動に失敗しました",
|
||||||
"address_book": "アドレス帳"
|
"address_book": "アドレス帳",
|
||||||
|
"rename": "アドレス帳の名前を変更",
|
||||||
|
"name_label": "アドレス帳名",
|
||||||
|
"renamed": "アドレス帳の名前を変更しました",
|
||||||
|
"rename_failed": "アドレス帳の名前変更に失敗しました",
|
||||||
|
"default": "デフォルト",
|
||||||
|
"manage": "アドレス帳を管理"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "メールアドレス",
|
"emails": "メールアドレス",
|
||||||
@@ -1870,6 +1892,7 @@
|
|||||||
"back_to_email": "メールに戻る",
|
"back_to_email": "メールに戻る",
|
||||||
"back_to_month": "月表示に戻る",
|
"back_to_month": "月表示に戻る",
|
||||||
"my_calendars": "マイカレンダー",
|
"my_calendars": "マイカレンダー",
|
||||||
|
"birthday_calendar": "誕生日",
|
||||||
"mini_calendar_change": "クリックで月を変更",
|
"mini_calendar_change": "クリックで月を変更",
|
||||||
"views": {
|
"views": {
|
||||||
"month": "月",
|
"month": "月",
|
||||||
@@ -2007,7 +2030,9 @@
|
|||||||
"hover_preview_delay_500ms": "0.5秒遅延",
|
"hover_preview_delay_500ms": "0.5秒遅延",
|
||||||
"hover_preview_delay_1s": "1秒遅延",
|
"hover_preview_delay_1s": "1秒遅延",
|
||||||
"hover_preview_delay_2s": "2秒遅延",
|
"hover_preview_delay_2s": "2秒遅延",
|
||||||
"hover_preview_off": "無効"
|
"hover_preview_off": "無効",
|
||||||
|
"show_birthday_calendar": "誕生日カレンダー",
|
||||||
|
"show_birthday_calendar_desc": "連絡先の誕生日を表示する仮想カレンダーを表示します"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "月曜日",
|
"monday": "月曜日",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+29
-4
@@ -993,7 +993,7 @@
|
|||||||
"title": "Accountbeveiliging",
|
"title": "Accountbeveiliging",
|
||||||
"description": "Beheer uw wachtwoord, tweefactorauthenticatie en beveiligingsinstellingen",
|
"description": "Beheer uw wachtwoord, tweefactorauthenticatie en beveiligingsinstellingen",
|
||||||
"detecting": "Servermogelijkheden worden gedetecteerd...",
|
"detecting": "Servermogelijkheden worden gedetecteerd...",
|
||||||
"not_available": "Accountbeveiligingsbeheer is niet beschikbaar voor deze mailserver.",
|
"not_available": "Accountbeveiligingsbeheer is niet beschikbaar voor deze mailserver. Vereiste machtigingen kunnen zijn uitgeschakeld. Zie de <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">documentatie</a> voor details.",
|
||||||
"password": {
|
"password": {
|
||||||
"title": "Wachtwoord wijzigen",
|
"title": "Wachtwoord wijzigen",
|
||||||
"current": "Huidig wachtwoord",
|
"current": "Huidig wachtwoord",
|
||||||
@@ -1009,6 +1009,7 @@
|
|||||||
"display_name": {
|
"display_name": {
|
||||||
"label": "Weergavenaam",
|
"label": "Weergavenaam",
|
||||||
"description": "Uw naam zoals weergegeven op de server",
|
"description": "Uw naam zoals weergegeven op de server",
|
||||||
|
"placeholder": "Voer uw weergavenaam in",
|
||||||
"save": "Opslaan",
|
"save": "Opslaan",
|
||||||
"success": "Weergavenaam bijgewerkt",
|
"success": "Weergavenaam bijgewerkt",
|
||||||
"error": "Kan weergavenaam niet bijwerken"
|
"error": "Kan weergavenaam niet bijwerken"
|
||||||
@@ -1155,6 +1156,8 @@
|
|||||||
"error_create": "Map maken mislukt",
|
"error_create": "Map maken mislukt",
|
||||||
"error_rename": "Map hernoemen mislukt",
|
"error_rename": "Map hernoemen mislukt",
|
||||||
"error_delete": "Map verwijderen mislukt",
|
"error_delete": "Map verwijderen mislukt",
|
||||||
|
"error_delete_has_children": "Map kan niet worden verwijderd: deze bevat nog submappen. Verwijder of verplaats deze eerst.",
|
||||||
|
"error_delete_has_email": "Map kan niet worden verwijderd: deze bevat nog e-mails. Verplaats of verwijder deze eerst.",
|
||||||
"error_role": "Maprol bijwerken mislukt"
|
"error_role": "Maprol bijwerken mislukt"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
@@ -1208,6 +1211,9 @@
|
|||||||
"label": "Instellingen importeren",
|
"label": "Instellingen importeren",
|
||||||
"description": "Upload instellingen vanuit JSON-bestand",
|
"description": "Upload instellingen vanuit JSON-bestand",
|
||||||
"button": "Importeren"
|
"button": "Importeren"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Bulwark Webmail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar_apps": {
|
"sidebar_apps": {
|
||||||
@@ -1224,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": "Adresboeken",
|
||||||
|
"manage_description": "Hernoem uw adresboeken",
|
||||||
|
"no_address_books": "Geen adresboeken gevonden",
|
||||||
|
"categories_title": "Categorieën",
|
||||||
|
"categories_description": "Contactcategorieën hernoemen",
|
||||||
|
"no_categories": "Geen categorieën gevonden"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "E-mailfilters",
|
"title": "E-mailfilters",
|
||||||
@@ -1629,6 +1641,10 @@
|
|||||||
"search_placeholder": "Contacten zoeken...",
|
"search_placeholder": "Contacten zoeken...",
|
||||||
"create_new": "Nieuw contact",
|
"create_new": "Nieuw contact",
|
||||||
"no_category": "Geen categorie",
|
"no_category": "Geen categorie",
|
||||||
|
"rename_category": "Categorie hernoemen",
|
||||||
|
"category_name_label": "Categorienaam",
|
||||||
|
"category_renamed": "Categorie hernoemd",
|
||||||
|
"category_rename_failed": "Categorie hernoemen mislukt",
|
||||||
"category_added": "Contact toegevoegd aan {name}",
|
"category_added": "Contact toegevoegd aan {name}",
|
||||||
"category_added_plural": "{count} contacten toegevoegd aan {name}",
|
"category_added_plural": "{count} contacten toegevoegd aan {name}",
|
||||||
"empty_state": "Geen contacten",
|
"empty_state": "Geen contacten",
|
||||||
@@ -1655,7 +1671,13 @@
|
|||||||
"moved": "Contact verplaatst naar {name}",
|
"moved": "Contact verplaatst naar {name}",
|
||||||
"moved_plural": "{count} contacten verplaatst naar {name}",
|
"moved_plural": "{count} contacten verplaatst naar {name}",
|
||||||
"move_failed": "Verplaatsen van contact mislukt",
|
"move_failed": "Verplaatsen van contact mislukt",
|
||||||
"address_book": "Adresboek"
|
"address_book": "Adresboek",
|
||||||
|
"rename": "Adresboek hernoemen",
|
||||||
|
"name_label": "Naam van adresboek",
|
||||||
|
"renamed": "Adresboek hernoemd",
|
||||||
|
"rename_failed": "Adresboek hernoemen mislukt",
|
||||||
|
"default": "Standaard",
|
||||||
|
"manage": "Adresboeken beheren"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-mailadressen",
|
"emails": "E-mailadressen",
|
||||||
@@ -1870,6 +1892,7 @@
|
|||||||
"back_to_email": "Terug naar e-mail",
|
"back_to_email": "Terug naar e-mail",
|
||||||
"back_to_month": "Terug naar maand",
|
"back_to_month": "Terug naar maand",
|
||||||
"my_calendars": "Mijn agenda's",
|
"my_calendars": "Mijn agenda's",
|
||||||
|
"birthday_calendar": "Verjaardagen",
|
||||||
"mini_calendar_change": "Klik om van maand te wisselen",
|
"mini_calendar_change": "Klik om van maand te wisselen",
|
||||||
"views": {
|
"views": {
|
||||||
"month": "Maand",
|
"month": "Maand",
|
||||||
@@ -2007,7 +2030,9 @@
|
|||||||
"hover_preview_delay_500ms": "0,5 seconde vertraging",
|
"hover_preview_delay_500ms": "0,5 seconde vertraging",
|
||||||
"hover_preview_delay_1s": "1 seconde vertraging",
|
"hover_preview_delay_1s": "1 seconde vertraging",
|
||||||
"hover_preview_delay_2s": "2 seconden vertraging",
|
"hover_preview_delay_2s": "2 seconden vertraging",
|
||||||
"hover_preview_off": "Uitgeschakeld"
|
"hover_preview_off": "Uitgeschakeld",
|
||||||
|
"show_birthday_calendar": "Verjaardagskalender",
|
||||||
|
"show_birthday_calendar_desc": "Toon een virtuele kalender met verjaardagen van je contacten"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Maandag",
|
"monday": "Maandag",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+29
-4
@@ -993,7 +993,7 @@
|
|||||||
"title": "Segurança da conta",
|
"title": "Segurança da conta",
|
||||||
"description": "Gerencie sua senha, autenticação de dois fatores e configurações de segurança",
|
"description": "Gerencie sua senha, autenticação de dois fatores e configurações de segurança",
|
||||||
"detecting": "Detectando recursos do servidor...",
|
"detecting": "Detectando recursos do servidor...",
|
||||||
"not_available": "O gerenciamento de segurança da conta não está disponível para este servidor de e-mail.",
|
"not_available": "O gerenciamento de segurança da conta não está disponível para este servidor de e-mail. As permissões necessárias podem estar desabilitadas. Consulte a <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">documentação</a> para obter detalhes.",
|
||||||
"password": {
|
"password": {
|
||||||
"title": "Alterar senha",
|
"title": "Alterar senha",
|
||||||
"current": "Senha atual",
|
"current": "Senha atual",
|
||||||
@@ -1009,6 +1009,7 @@
|
|||||||
"display_name": {
|
"display_name": {
|
||||||
"label": "Nome de exibição",
|
"label": "Nome de exibição",
|
||||||
"description": "Seu nome como aparece no servidor",
|
"description": "Seu nome como aparece no servidor",
|
||||||
|
"placeholder": "Digite seu nome de exibição",
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
"success": "Nome de exibição atualizado",
|
"success": "Nome de exibição atualizado",
|
||||||
"error": "Não foi possível atualizar o nome de exibição"
|
"error": "Não foi possível atualizar o nome de exibição"
|
||||||
@@ -1155,6 +1156,8 @@
|
|||||||
"error_create": "Falha ao criar a pasta",
|
"error_create": "Falha ao criar a pasta",
|
||||||
"error_rename": "Falha ao renomear a pasta",
|
"error_rename": "Falha ao renomear a pasta",
|
||||||
"error_delete": "Falha ao excluir a pasta",
|
"error_delete": "Falha ao excluir a pasta",
|
||||||
|
"error_delete_has_children": "Não é possível excluir a pasta: ela ainda contém subpastas. Exclua ou mova-as primeiro.",
|
||||||
|
"error_delete_has_email": "Não é possível excluir a pasta: ela ainda contém e-mails. Mova ou exclua-os primeiro.",
|
||||||
"error_role": "Falha ao atualizar a função da pasta"
|
"error_role": "Falha ao atualizar a função da pasta"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
@@ -1208,6 +1211,9 @@
|
|||||||
"label": "Importar Configurações",
|
"label": "Importar Configurações",
|
||||||
"description": "Fazer upload de configurações de arquivo JSON",
|
"description": "Fazer upload de configurações de arquivo JSON",
|
||||||
"button": "Importar"
|
"button": "Importar"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Bulwark Webmail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar_apps": {
|
"sidebar_apps": {
|
||||||
@@ -1224,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": "Catálogos de endereços",
|
||||||
|
"manage_description": "Renomeie seus catálogos de endereços",
|
||||||
|
"no_address_books": "Nenhum catálogo de endereços encontrado",
|
||||||
|
"categories_title": "Categorias",
|
||||||
|
"categories_description": "Renomear categorias de contatos",
|
||||||
|
"no_categories": "Nenhuma categoria encontrada"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "Filtros de e-mail",
|
"title": "Filtros de e-mail",
|
||||||
@@ -1629,6 +1641,10 @@
|
|||||||
"search_placeholder": "Pesquisar contatos...",
|
"search_placeholder": "Pesquisar contatos...",
|
||||||
"create_new": "Novo contato",
|
"create_new": "Novo contato",
|
||||||
"no_category": "Sem categoria",
|
"no_category": "Sem categoria",
|
||||||
|
"rename_category": "Renomear categoria",
|
||||||
|
"category_name_label": "Nome da categoria",
|
||||||
|
"category_renamed": "Categoria renomeada",
|
||||||
|
"category_rename_failed": "Falha ao renomear a categoria",
|
||||||
"category_added": "Contato adicionado a {name}",
|
"category_added": "Contato adicionado a {name}",
|
||||||
"category_added_plural": "{count} contatos adicionados a {name}",
|
"category_added_plural": "{count} contatos adicionados a {name}",
|
||||||
"empty_state": "Nenhum contato",
|
"empty_state": "Nenhum contato",
|
||||||
@@ -1655,7 +1671,13 @@
|
|||||||
"moved": "Contato movido para {name}",
|
"moved": "Contato movido para {name}",
|
||||||
"moved_plural": "{count} contatos movidos para {name}",
|
"moved_plural": "{count} contatos movidos para {name}",
|
||||||
"move_failed": "Falha ao mover o contato",
|
"move_failed": "Falha ao mover o contato",
|
||||||
"address_book": "Catálogo de endereços"
|
"address_book": "Catálogo de endereços",
|
||||||
|
"rename": "Renomear catálogo de endereços",
|
||||||
|
"name_label": "Nome do catálogo de endereços",
|
||||||
|
"renamed": "Catálogo de endereços renomeado",
|
||||||
|
"rename_failed": "Falha ao renomear o catálogo de endereços",
|
||||||
|
"default": "Padrão",
|
||||||
|
"manage": "Gerenciar catálogos de endereços"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Endereços de e-mail",
|
"emails": "Endereços de e-mail",
|
||||||
@@ -1870,6 +1892,7 @@
|
|||||||
"back_to_email": "Voltar ao e-mail",
|
"back_to_email": "Voltar ao e-mail",
|
||||||
"back_to_month": "Voltar ao mês",
|
"back_to_month": "Voltar ao mês",
|
||||||
"my_calendars": "Meus calendários",
|
"my_calendars": "Meus calendários",
|
||||||
|
"birthday_calendar": "Aniversários",
|
||||||
"mini_calendar_change": "Clique para mudar o mês",
|
"mini_calendar_change": "Clique para mudar o mês",
|
||||||
"views": {
|
"views": {
|
||||||
"month": "Mês",
|
"month": "Mês",
|
||||||
@@ -2007,7 +2030,9 @@
|
|||||||
"hover_preview_delay_500ms": "Atraso de 0,5 segundos",
|
"hover_preview_delay_500ms": "Atraso de 0,5 segundos",
|
||||||
"hover_preview_delay_1s": "Atraso de 1 segundo",
|
"hover_preview_delay_1s": "Atraso de 1 segundo",
|
||||||
"hover_preview_delay_2s": "Atraso de 2 segundos",
|
"hover_preview_delay_2s": "Atraso de 2 segundos",
|
||||||
"hover_preview_off": "Desativado"
|
"hover_preview_off": "Desativado",
|
||||||
|
"show_birthday_calendar": "Calendário de aniversários",
|
||||||
|
"show_birthday_calendar_desc": "Mostrar um calendário virtual com os aniversários dos seus contactos"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Segunda-feira",
|
"monday": "Segunda-feira",
|
||||||
|
|||||||
+29
-4
@@ -993,7 +993,7 @@
|
|||||||
"title": "Безопасность аккаунта",
|
"title": "Безопасность аккаунта",
|
||||||
"description": "Управляйте паролем, двухфакторной аутентификацией и настройками безопасности",
|
"description": "Управляйте паролем, двухфакторной аутентификацией и настройками безопасности",
|
||||||
"detecting": "Определение возможностей сервера...",
|
"detecting": "Определение возможностей сервера...",
|
||||||
"not_available": "Управление безопасностью аккаунта недоступно для этого почтового сервера.",
|
"not_available": "Управление безопасностью аккаунта недоступно для этого почтового сервера. Требуемые разрешения могут быть отключены. Подробнее см. в <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">документации</a>.",
|
||||||
"password": {
|
"password": {
|
||||||
"title": "Изменить пароль",
|
"title": "Изменить пароль",
|
||||||
"current": "Текущий пароль",
|
"current": "Текущий пароль",
|
||||||
@@ -1009,6 +1009,7 @@
|
|||||||
"display_name": {
|
"display_name": {
|
||||||
"label": "Отображаемое имя",
|
"label": "Отображаемое имя",
|
||||||
"description": "Ваше имя, отображаемое на сервере",
|
"description": "Ваше имя, отображаемое на сервере",
|
||||||
|
"placeholder": "Введите ваше отображаемое имя",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"success": "Отображаемое имя обновлено",
|
"success": "Отображаемое имя обновлено",
|
||||||
"error": "Не удалось обновить отображаемое имя"
|
"error": "Не удалось обновить отображаемое имя"
|
||||||
@@ -1155,6 +1156,8 @@
|
|||||||
"error_create": "Не удалось создать папку",
|
"error_create": "Не удалось создать папку",
|
||||||
"error_rename": "Не удалось переименовать папку",
|
"error_rename": "Не удалось переименовать папку",
|
||||||
"error_delete": "Не удалось удалить папку",
|
"error_delete": "Не удалось удалить папку",
|
||||||
|
"error_delete_has_children": "Невозможно удалить папку: она содержит подпапки. Сначала удалите или переместите их.",
|
||||||
|
"error_delete_has_email": "Невозможно удалить папку: она содержит письма. Сначала переместите или удалите их.",
|
||||||
"error_role": "Не удалось обновить роль папки"
|
"error_role": "Не удалось обновить роль папки"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
@@ -1208,6 +1211,9 @@
|
|||||||
"label": "Импортировать настройки",
|
"label": "Импортировать настройки",
|
||||||
"description": "Загрузить настройки из файла JSON",
|
"description": "Загрузить настройки из файла JSON",
|
||||||
"button": "Импортировать"
|
"button": "Импортировать"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Bulwark Webmail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar_apps": {
|
"sidebar_apps": {
|
||||||
@@ -1224,7 +1230,13 @@
|
|||||||
"import_label": "Импорт контактов",
|
"import_label": "Импорт контактов",
|
||||||
"import_description": "Импортировать контакты из файла vCard (.vcf)",
|
"import_description": "Импортировать контакты из файла vCard (.vcf)",
|
||||||
"export_label": "Экспорт контактов",
|
"export_label": "Экспорт контактов",
|
||||||
"export_description": "Экспортировать все контакты в виде файла vCard (.vcf)"
|
"export_description": "Экспортировать все контакты в виде файла vCard (.vcf)",
|
||||||
|
"manage_title": "Адресные книги",
|
||||||
|
"manage_description": "Переименуйте ваши адресные книги",
|
||||||
|
"no_address_books": "Адресные книги не найдены",
|
||||||
|
"categories_title": "Категории",
|
||||||
|
"categories_description": "Переименование категорий контактов",
|
||||||
|
"no_categories": "Категории не найдены"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "Фильтры почты",
|
"title": "Фильтры почты",
|
||||||
@@ -1629,6 +1641,10 @@
|
|||||||
"search_placeholder": "Поиск контактов...",
|
"search_placeholder": "Поиск контактов...",
|
||||||
"create_new": "Новый контакт",
|
"create_new": "Новый контакт",
|
||||||
"no_category": "Без категории",
|
"no_category": "Без категории",
|
||||||
|
"rename_category": "Переименовать категорию",
|
||||||
|
"category_name_label": "Название категории",
|
||||||
|
"category_renamed": "Категория переименована",
|
||||||
|
"category_rename_failed": "Не удалось переименовать категорию",
|
||||||
"category_added": "Контакт добавлен в {name}",
|
"category_added": "Контакт добавлен в {name}",
|
||||||
"category_added_plural": "{count} контактов добавлено в {name}",
|
"category_added_plural": "{count} контактов добавлено в {name}",
|
||||||
"empty_state": "Контактов пока нет",
|
"empty_state": "Контактов пока нет",
|
||||||
@@ -1655,7 +1671,13 @@
|
|||||||
"moved": "Контакт перемещён в {name}",
|
"moved": "Контакт перемещён в {name}",
|
||||||
"moved_plural": "{count} контактов перемещено в {name}",
|
"moved_plural": "{count} контактов перемещено в {name}",
|
||||||
"move_failed": "Не удалось переместить контакт",
|
"move_failed": "Не удалось переместить контакт",
|
||||||
"address_book": "Адресная книга"
|
"address_book": "Адресная книга",
|
||||||
|
"rename": "Переименовать адресную книгу",
|
||||||
|
"name_label": "Название адресной книги",
|
||||||
|
"renamed": "Адресная книга переименована",
|
||||||
|
"rename_failed": "Не удалось переименовать адресную книгу",
|
||||||
|
"default": "По умолчанию",
|
||||||
|
"manage": "Управление адресными книгами"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Адреса электронной почты",
|
"emails": "Адреса электронной почты",
|
||||||
@@ -1870,6 +1892,7 @@
|
|||||||
"back_to_email": "Вернуться к почте",
|
"back_to_email": "Вернуться к почте",
|
||||||
"back_to_month": "Вернуться к месяцу",
|
"back_to_month": "Вернуться к месяцу",
|
||||||
"my_calendars": "Календари",
|
"my_calendars": "Календари",
|
||||||
|
"birthday_calendar": "Дни рождения",
|
||||||
"mini_calendar_change": "Нажмите для смены месяца",
|
"mini_calendar_change": "Нажмите для смены месяца",
|
||||||
"views": {
|
"views": {
|
||||||
"month": "Месяц",
|
"month": "Месяц",
|
||||||
@@ -2007,7 +2030,9 @@
|
|||||||
"hover_preview_delay_500ms": "Задержка 0,5 секунды",
|
"hover_preview_delay_500ms": "Задержка 0,5 секунды",
|
||||||
"hover_preview_delay_1s": "Задержка 1 секунда",
|
"hover_preview_delay_1s": "Задержка 1 секунда",
|
||||||
"hover_preview_delay_2s": "Задержка 2 секунды",
|
"hover_preview_delay_2s": "Задержка 2 секунды",
|
||||||
"hover_preview_off": "Отключено"
|
"hover_preview_off": "Отключено",
|
||||||
|
"show_birthday_calendar": "Календарь дней рождения",
|
||||||
|
"show_birthday_calendar_desc": "Показать виртуальный календарь с днями рождения из ваших контактов"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Понедельник",
|
"monday": "Понедельник",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,22 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
let gitCommitHash = "unknown";
|
||||||
|
try {
|
||||||
|
gitCommitHash = execSync("git rev-parse --short HEAD").toString().trim();
|
||||||
|
} catch {
|
||||||
|
// git not available
|
||||||
|
}
|
||||||
|
|
||||||
|
let appVersion = "0.0.0";
|
||||||
|
try {
|
||||||
|
appVersion = readFileSync(join(import.meta.dirname, "VERSION"), "utf-8").trim();
|
||||||
|
} catch {
|
||||||
|
// VERSION file not found
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
@@ -7,6 +24,10 @@ const nextConfig: NextConfig = {
|
|||||||
turbopack: {
|
turbopack: {
|
||||||
root: import.meta.dirname,
|
root: import.meta.dirname,
|
||||||
},
|
},
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
||||||
|
NEXT_PUBLIC_APP_VERSION: appVersion,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin();
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.11",
|
"version": "1.4.12",
|
||||||
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function proxy(request: NextRequest) {
|
|||||||
`default-src 'self'`,
|
`default-src 'self'`,
|
||||||
`script-src ${scriptSrc}`,
|
`script-src ${scriptSrc}`,
|
||||||
`style-src 'self' 'unsafe-inline'`,
|
`style-src 'self' 'unsafe-inline'`,
|
||||||
`img-src 'self' data: https:`,
|
`img-src 'self' data: blob: https:`,
|
||||||
`font-src 'self'`,
|
`font-src 'self'`,
|
||||||
`connect-src ${connectSrc}`,
|
`connect-src ${connectSrc}`,
|
||||||
`frame-src 'none'`,
|
`frame-src 'none'`,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "Bulwark Webmail",
|
||||||
|
"short_name": "Bulwark",
|
||||||
|
"description": "A modern webmail client built for Stalwart Mail Server",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["productivity"],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/screenshot-540x720.png",
|
||||||
|
"sizes": "540x720",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/screenshot-1280x720.png",
|
||||||
|
"sizes": "1280x720",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "wide"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
|
||||||
|
// Self-destructing service worker.
|
||||||
|
//
|
||||||
|
// The previous version of this file used a cache-first strategy with no
|
||||||
|
// dev-mode guard, which pinned stale JS/HTML chunks until a hard reload.
|
||||||
|
// This replacement unregisters itself and wipes all caches as soon as the
|
||||||
|
// browser picks it up. Browsers re-fetch sw.js on every navigation to check
|
||||||
|
// for updates, so any client running the old worker will swap to this one
|
||||||
|
// on their next page load and then lose the worker entirely.
|
||||||
|
|
||||||
|
self.addEventListener("install", () => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
await Promise.all(cacheNames.map((name) => caches.delete(name)));
|
||||||
|
await self.registration.unregister();
|
||||||
|
const clients = await self.clients.matchAll({ type: "window" });
|
||||||
|
clients.forEach((client) => client.navigate(client.url));
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -129,7 +129,20 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
|||||||
const response = await fetch('/api/account/stalwart/principal', {
|
const response = await fetch('/api/account/stalwart/principal', {
|
||||||
headers: getApiHeaders(),
|
headers: getApiHeaders(),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) {
|
||||||
|
if (response.status === 403) {
|
||||||
|
// User lacks permission to read principal (e.g. non-admin); treat as empty
|
||||||
|
set({
|
||||||
|
displayName: '',
|
||||||
|
emails: [],
|
||||||
|
quota: 0,
|
||||||
|
roles: [],
|
||||||
|
isLoadingPrincipal: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const principal = data.data;
|
const principal = data.data;
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ interface AuthState {
|
|||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
syncIdentities: () => void;
|
syncIdentities: () => void;
|
||||||
|
refreshIdentities: () => Promise<void>;
|
||||||
getClientForAccount: (accountId: string) => JMAPClient | undefined;
|
getClientForAccount: (accountId: string) => JMAPClient | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1513,6 +1514,18 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({ identities, primaryIdentity });
|
set({ identities, primaryIdentity });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshIdentities: async () => {
|
||||||
|
const { client, username } = get();
|
||||||
|
if (!client || !username) return;
|
||||||
|
try {
|
||||||
|
const rawIdentities = await client.getIdentities();
|
||||||
|
const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
|
||||||
|
set({ identities, primaryIdentity });
|
||||||
|
} catch {
|
||||||
|
// Silently fail — background sync should not surface errors to the user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getClientForAccount: (accountId: string) => {
|
getClientForAccount: (accountId: string) => {
|
||||||
return clients.get(accountId);
|
return clients.get(accountId);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { debug } from '@/lib/debug';
|
|||||||
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
|
||||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
|
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
|
||||||
|
|
||||||
@@ -722,9 +723,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
if (!calendar) throw new Error('Failed to create calendar');
|
if (!calendar) throw new Error('Failed to create calendar');
|
||||||
|
|
||||||
const subscription: ICalSubscription = {
|
const subscription: ICalSubscription = {
|
||||||
id: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
id: generateUUID(),
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
||||||
url,
|
url,
|
||||||
calendarId: calendar.id,
|
calendarId: calendar.id,
|
||||||
name,
|
name,
|
||||||
|
|||||||
+98
-5
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
|
||||||
export function getContactDisplayName(contact: ContactCard): string {
|
export function getContactDisplayName(contact: ContactCard): string {
|
||||||
if (contact.name?.components) {
|
if (contact.name?.components) {
|
||||||
@@ -81,6 +82,8 @@ interface ContactStore {
|
|||||||
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
|
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
|
||||||
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
||||||
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
||||||
|
renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise<void>;
|
||||||
|
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
|
||||||
|
|
||||||
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
||||||
}
|
}
|
||||||
@@ -155,8 +158,33 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
createContact: async (client, contact) => {
|
createContact: async (client, contact) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const accountId = contact.isShared ? contact.accountId : undefined;
|
// Determine target account from the selected address book
|
||||||
const created = await client.createContact(contact, accountId);
|
let accountId = contact.isShared ? contact.accountId : undefined;
|
||||||
|
let cleanedContact = contact;
|
||||||
|
|
||||||
|
// De-namespace addressBookIds if they reference a shared address book
|
||||||
|
if (contact.addressBookIds) {
|
||||||
|
const books = get().addressBooks;
|
||||||
|
const deNamespaced: Record<string, boolean> = {};
|
||||||
|
let sharedAccountId: string | undefined;
|
||||||
|
for (const [bookId, value] of Object.entries(contact.addressBookIds)) {
|
||||||
|
const book = books.find(b => b.id === bookId);
|
||||||
|
if (book?.isShared && book.originalId) {
|
||||||
|
deNamespaced[book.originalId] = value;
|
||||||
|
sharedAccountId = book.accountId;
|
||||||
|
} else {
|
||||||
|
deNamespaced[bookId] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sharedAccountId) {
|
||||||
|
accountId = sharedAccountId;
|
||||||
|
cleanedContact = { ...contact, addressBookIds: deNamespaced, isShared: true, accountId: sharedAccountId };
|
||||||
|
} else {
|
||||||
|
cleanedContact = { ...contact, addressBookIds: deNamespaced };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await client.createContact(cleanedContact, accountId);
|
||||||
// Preserve shared account metadata
|
// Preserve shared account metadata
|
||||||
if (contact.isShared && contact.accountId) {
|
if (contact.isShared && contact.accountId) {
|
||||||
created.accountId = contact.accountId;
|
created.accountId = contact.accountId;
|
||||||
@@ -182,7 +210,21 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
const contact = get().contacts.find(c => c.id === id);
|
const contact = get().contacts.find(c => c.id === id);
|
||||||
const originalId = contact?.originalId || id;
|
const originalId = contact?.originalId || id;
|
||||||
const accountId = contact?.isShared ? contact.accountId : undefined;
|
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||||
await client.updateContact(originalId, updates, accountId);
|
|
||||||
|
// De-namespace addressBookIds for shared contacts before sending to JMAP server
|
||||||
|
let cleanedUpdates = updates;
|
||||||
|
if (contact?.isShared && contact?.accountId && updates.addressBookIds) {
|
||||||
|
const prefix = `${contact.accountId}:`;
|
||||||
|
const deNamespaced = Object.fromEntries(
|
||||||
|
Object.entries(updates.addressBookIds).map(([k, v]) => [
|
||||||
|
k.startsWith(prefix) ? k.slice(prefix.length) : k,
|
||||||
|
v
|
||||||
|
])
|
||||||
|
);
|
||||||
|
cleanedUpdates = { ...updates, addressBookIds: deNamespaced };
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.updateContact(originalId, cleanedUpdates, accountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.map(c =>
|
contacts: state.contacts.map(c =>
|
||||||
c.id === id ? { ...c, ...updates } : c
|
c.id === id ? { ...c, ...updates } : c
|
||||||
@@ -339,7 +381,7 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
set((state) => ({ contacts: [...state.contacts, created] }));
|
set((state) => ({ contacts: [...state.contacts, created] }));
|
||||||
} else {
|
} else {
|
||||||
const localGroup: ContactCard = {
|
const localGroup: ContactCard = {
|
||||||
id: `local-${crypto.randomUUID()}`,
|
id: `local-${generateUUID()}`,
|
||||||
addressBookIds: {},
|
addressBookIds: {},
|
||||||
...groupData,
|
...groupData,
|
||||||
} as ContactCard;
|
} as ContactCard;
|
||||||
@@ -565,6 +607,57 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
renameAddressBook: async (client, addressBook, newName) => {
|
||||||
|
set({ error: null });
|
||||||
|
const trimmed = newName.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
try {
|
||||||
|
const originalId = addressBook.originalId || addressBook.id;
|
||||||
|
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||||
|
await client.updateAddressBook(originalId, { name: trimmed }, accountId);
|
||||||
|
set((state) => ({
|
||||||
|
addressBooks: state.addressBooks.map(b =>
|
||||||
|
b.id === addressBook.id ? { ...b, name: trimmed } : b
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Failed to rename address book';
|
||||||
|
set({ error: msg });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renameKeyword: async (client, oldKeyword, newKeyword) => {
|
||||||
|
set({ error: null });
|
||||||
|
const oldKw = oldKeyword.trim();
|
||||||
|
const newKw = newKeyword.trim();
|
||||||
|
if (!oldKw || !newKw || oldKw === newKw) return;
|
||||||
|
|
||||||
|
const { contacts, supportsSync } = get();
|
||||||
|
const affected = contacts.filter(c => c.keywords?.[oldKw]);
|
||||||
|
|
||||||
|
for (const contact of affected) {
|
||||||
|
const { [oldKw]: _old, ...rest } = contact.keywords || {};
|
||||||
|
const updatedKeywords: Record<string, boolean> = { ...rest, [newKw]: true };
|
||||||
|
try {
|
||||||
|
if (supportsSync && client) {
|
||||||
|
const originalId = contact.originalId || contact.id;
|
||||||
|
const accountId = contact.isShared ? contact.accountId : undefined;
|
||||||
|
await client.updateContact(originalId, { keywords: updatedKeywords }, accountId);
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
contacts: state.contacts.map(c =>
|
||||||
|
c.id === contact.id ? { ...c, keywords: updatedKeywords } : c
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Failed to rename category';
|
||||||
|
set({ error: msg });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
importContacts: async (client, contacts) => {
|
importContacts: async (client, contacts) => {
|
||||||
const { supportsSync } = get();
|
const { supportsSync } = get();
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
@@ -578,7 +671,7 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
} else {
|
} else {
|
||||||
const localContact: ContactCard = {
|
const localContact: ContactCard = {
|
||||||
...contact,
|
...contact,
|
||||||
id: `local-${crypto.randomUUID()}`,
|
id: `local-${generateUUID()}`,
|
||||||
};
|
};
|
||||||
set((state) => ({ contacts: [...state.contacts, localContact] }));
|
set((state) => ({ contacts: [...state.contacts, localContact] }));
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -1241,7 +1241,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Get emails per page from settings
|
// Get emails per page from settings
|
||||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
|
|
||||||
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
|
// Respect active search filters / query so that a push-triggered refresh
|
||||||
|
// does not silently replace a filtered list with an unfiltered one.
|
||||||
|
const { searchQuery, searchFilters } = get();
|
||||||
|
const hasFilters = !isFilterEmpty(searchFilters);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (hasFilters || searchQuery) {
|
||||||
|
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||||||
|
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
|
||||||
|
} else {
|
||||||
|
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
|
||||||
|
}
|
||||||
|
|
||||||
const currentEmails = get().emails;
|
const currentEmails = get().emails;
|
||||||
|
|
||||||
|
|||||||
+21
-3
@@ -234,7 +234,17 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
set({ resources, isLoading: false });
|
|
||||||
|
// Prune recent files whose backing node no longer exists on the server
|
||||||
|
const { recentFiles } = get();
|
||||||
|
const existingIds = new Set(allNodes.map(n => n.id));
|
||||||
|
const prunedRecent = recentFiles.filter(r => existingIds.has(r.id));
|
||||||
|
if (prunedRecent.length !== recentFiles.length) {
|
||||||
|
try { localStorage.setItem('files-recent-files', JSON.stringify(prunedRecent)); } catch { /* ignore */ }
|
||||||
|
set({ resources, recentFiles: prunedRecent, isLoading: false });
|
||||||
|
} else {
|
||||||
|
set({ resources, isLoading: false });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : 'Failed to list directory',
|
error: error instanceof Error ? error.message : 'Failed to list directory',
|
||||||
@@ -408,7 +418,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteResource: async (name: string) => {
|
deleteResource: async (name: string) => {
|
||||||
const { client, resources, refresh } = get();
|
const { client, resources, recentFiles, refresh } = get();
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
const resource = resources.find(r => r.name === name);
|
const resource = resources.find(r => r.name === name);
|
||||||
@@ -428,11 +438,15 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.destroyFileNodes(idsToDelete);
|
await client.destroyFileNodes(idsToDelete);
|
||||||
|
const deletedIdSet = new Set(idsToDelete);
|
||||||
|
const nextRecentFiles = recentFiles.filter(r => !deletedIdSet.has(r.id));
|
||||||
|
set({ recentFiles: nextRecentFiles });
|
||||||
|
try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ }
|
||||||
await refresh();
|
await refresh();
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteResources: async (names: string[]) => {
|
deleteResources: async (names: string[]) => {
|
||||||
const { client, resources, refresh } = get();
|
const { client, resources, recentFiles, refresh } = get();
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
const idsToDelete: string[] = [];
|
const idsToDelete: string[] = [];
|
||||||
@@ -457,7 +471,11 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
if (idsToDelete.length === 0) return;
|
if (idsToDelete.length === 0) return;
|
||||||
|
|
||||||
await client.destroyFileNodes(idsToDelete);
|
await client.destroyFileNodes(idsToDelete);
|
||||||
|
const deletedIdSet = new Set(idsToDelete);
|
||||||
|
const nextRecentFiles = recentFiles.filter(r => !deletedIdSet.has(r.id));
|
||||||
set({ selectedResources: new Set() });
|
set({ selectedResources: new Set() });
|
||||||
|
set({ recentFiles: nextRecentFiles });
|
||||||
|
try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ }
|
||||||
await refresh();
|
await refresh();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
status: 'installed',
|
status: 'installed',
|
||||||
managed: false,
|
managed: false,
|
||||||
forceEnabled: false,
|
forceEnabled: false,
|
||||||
|
adminApproved: false, // Requires admin approval before it can be enabled
|
||||||
settings: existing?.settings ?? {},
|
settings: existing?.settings ?? {},
|
||||||
settingsSchema: manifest.settingsSchema,
|
settingsSchema: manifest.settingsSchema,
|
||||||
};
|
};
|
||||||
@@ -144,6 +145,11 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
const plugin = plugins.find(p => p.id === id);
|
const plugin = plugins.find(p => p.id === id);
|
||||||
if (!plugin) return;
|
if (!plugin) return;
|
||||||
|
|
||||||
|
// Block enabling if plugin requires admin approval and hasn't been approved
|
||||||
|
const requireApproval = usePolicyStore.getState().isFeatureEnabled('requirePluginApproval');
|
||||||
|
const isApproved = plugin.adminApproved || plugin.managed || usePolicyStore.getState().isPluginApproved(id);
|
||||||
|
if (requireApproval && !isApproved) return;
|
||||||
|
|
||||||
// Ensure bridges are wired before loading (may not have run initializePlugins yet)
|
// Ensure bridges are wired before loading (may not have run initializePlugins yet)
|
||||||
setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
|
setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
|
||||||
setSlotRegistrationBridge(get().registerSlot);
|
setSlotRegistrationBridge(get().registerSlot);
|
||||||
@@ -393,6 +399,7 @@ async function syncServerPlugins(
|
|||||||
status: sp.forceEnabled ? 'enabled' : 'installed',
|
status: sp.forceEnabled ? 'enabled' : 'installed',
|
||||||
managed: true,
|
managed: true,
|
||||||
forceEnabled: sp.forceEnabled,
|
forceEnabled: sp.forceEnabled,
|
||||||
|
adminApproved: true, // Server-managed plugins are always approved
|
||||||
settings: {},
|
settings: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface PolicyState {
|
|||||||
getForcedThemeId: (availableThemeIds?: string[]) => string | null;
|
getForcedThemeId: (availableThemeIds?: string[]) => string | null;
|
||||||
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
|
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
|
||||||
isPluginForceEnabled: (pluginId: string) => boolean;
|
isPluginForceEnabled: (pluginId: string) => boolean;
|
||||||
|
isPluginApproved: (pluginId: string) => boolean;
|
||||||
isThemeForceEnabled: (themeId: string) => boolean;
|
isThemeForceEnabled: (themeId: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +85,10 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
|||||||
return (get().policy.forceEnabledPlugins || []).includes(pluginId);
|
return (get().policy.forceEnabledPlugins || []).includes(pluginId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isPluginApproved: (pluginId) => {
|
||||||
|
return (get().policy.approvedPlugins || []).includes(pluginId);
|
||||||
|
},
|
||||||
|
|
||||||
isThemeForceEnabled: (themeId) => {
|
isThemeForceEnabled: (themeId) => {
|
||||||
return (get().policy.forceEnabledThemes || []).includes(themeId);
|
return (get().policy.forceEnabledThemes || []).includes(themeId);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ interface SettingsState {
|
|||||||
enableCalendarTasks: boolean;
|
enableCalendarTasks: boolean;
|
||||||
showTasksOnCalendar: boolean;
|
showTasksOnCalendar: boolean;
|
||||||
|
|
||||||
|
// Contact Birthday Calendar
|
||||||
|
showBirthdayCalendar: boolean;
|
||||||
|
birthdayCalendarColor: string;
|
||||||
|
|
||||||
// Email Notifications
|
// Email Notifications
|
||||||
emailNotificationsEnabled: boolean;
|
emailNotificationsEnabled: boolean;
|
||||||
emailNotificationSound: boolean;
|
emailNotificationSound: boolean;
|
||||||
@@ -278,6 +282,10 @@ const DEFAULT_SETTINGS = {
|
|||||||
enableCalendarTasks: false,
|
enableCalendarTasks: false,
|
||||||
showTasksOnCalendar: true,
|
showTasksOnCalendar: true,
|
||||||
|
|
||||||
|
// Contact Birthday Calendar
|
||||||
|
showBirthdayCalendar: false,
|
||||||
|
birthdayCalendarColor: '#eab308',
|
||||||
|
|
||||||
// Email Notifications
|
// Email Notifications
|
||||||
emailNotificationsEnabled: true,
|
emailNotificationsEnabled: true,
|
||||||
emailNotificationSound: true,
|
emailNotificationSound: true,
|
||||||
@@ -392,6 +400,8 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
|
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
|
||||||
enableCalendarTasks: state.enableCalendarTasks,
|
enableCalendarTasks: state.enableCalendarTasks,
|
||||||
showTasksOnCalendar: state.showTasksOnCalendar,
|
showTasksOnCalendar: state.showTasksOnCalendar,
|
||||||
|
showBirthdayCalendar: state.showBirthdayCalendar,
|
||||||
|
birthdayCalendarColor: state.birthdayCalendarColor,
|
||||||
expandedFilterView: state.expandedFilterView,
|
expandedFilterView: state.expandedFilterView,
|
||||||
showTimeInMonthView: state.showTimeInMonthView,
|
showTimeInMonthView: state.showTimeInMonthView,
|
||||||
showWeekNumbers: state.showWeekNumbers,
|
showWeekNumbers: state.showWeekNumbers,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { SmimeKeyRecord, SmimePublicCert } from '@/lib/smime/types';
|
import type { SmimeKeyRecord, SmimePublicCert } from '@/lib/smime/types';
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
saveKeyRecord,
|
saveKeyRecord,
|
||||||
listKeyRecords,
|
listKeyRecords,
|
||||||
@@ -287,7 +288,7 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
const email = info.emailAddresses[0] ?? '';
|
const email = info.emailAddresses[0] ?? '';
|
||||||
|
|
||||||
const publicCert: SmimePublicCert = {
|
const publicCert: SmimePublicCert = {
|
||||||
id: crypto.randomUUID(),
|
id: generateUUID(),
|
||||||
accountId: get().currentAccountId ?? undefined,
|
accountId: get().currentAccountId ?? undefined,
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
certificate: der,
|
certificate: der,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
importTemplates as importUtil,
|
importTemplates as importUtil,
|
||||||
filterTemplates,
|
filterTemplates,
|
||||||
} from '@/lib/template-utils';
|
} from '@/lib/template-utils';
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
|
||||||
const MAX_RECENT = 5;
|
const MAX_RECENT = 5;
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export const useTemplateStore = create<TemplateStore>()(
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const template: EmailTemplate = {
|
const template: EmailTemplate = {
|
||||||
...data,
|
...data,
|
||||||
id: crypto.randomUUID(),
|
id: generateUUID(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -73,8 +74,9 @@ export const useTemplateStore = create<TemplateStore>()(
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const duplicate: EmailTemplate = {
|
const duplicate: EmailTemplate = {
|
||||||
...original,
|
...original,
|
||||||
id: crypto.randomUUID(),
|
id: generateUUID(),
|
||||||
name: `${original.name} ${nameSuffix || '(copy)'}`,
|
name: `${original.name} ${nameSuffix || '(copy)'}`,
|
||||||
|
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user