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
|
||||
|
||||
## 1.4.12 (2026-04-09)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
**One-time**
|
||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||
|
||||
**Monthly**
|
||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||
|
||||
### Features
|
||||
|
||||
- **PWA**: Add PWA support with service worker and install prompt
|
||||
- **Calendar**: Add birthday calendar feature with settings and localization
|
||||
- **Calendar**: Clamp February 29 birthdays in non-leap years
|
||||
- **Identity**: Add automatic identity synchronization (#167)
|
||||
- **Plugins**: Disable plugins by default and require admin approval
|
||||
- **Plugins**: Replace auth header exposure with a secure HTTP proxy API for plugins
|
||||
- **Auth**: Add configurable OAuth scopes and cookie security via environment variables
|
||||
- **Email**: Sync mail view to browser history for back/forward navigation
|
||||
- **Contacts**: Add ability to rename address books (#152)
|
||||
- **UI**: Add version badge in settings
|
||||
- **i18n**: Add Latvian (lv) locale support
|
||||
- **i18n**: Add Polish language support
|
||||
- **i18n**: Add Korean language support
|
||||
- **i18n**: Add Simplified Chinese (zh_CN) locale support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Email**: Show recipient instead of sender in Sent and Drafts folder lists
|
||||
- **Email**: Embed dropped images as data URLs and prevent duplicate attachments (#163)
|
||||
- **Email**: Fix logic for marking email as read in EmailViewer
|
||||
- **Email**: Fix archive action passing MouseEvent as argument
|
||||
- **Mailbox**: Preserve search filters on push-triggered mailbox refresh (#164)
|
||||
- **Mailbox**: Align shared account folders with primary folders (#151)
|
||||
- **Mailbox**: Fetch mailboxes on mount in FolderSettings when store is empty
|
||||
- **Mailbox**: Improve mailbox deletion error handling
|
||||
- **Calendar**: Improve calendar event retrieval by batching requests to avoid server limits (#141)
|
||||
- **Calendar**: Compute per-occurrence UTC start/end in recurrence expansion (#116)
|
||||
- **Calendar**: Guard against undefined trigger in calendar event alert popover (#143)
|
||||
- **Files**: Stream WebDAV PUT uploads to avoid buffering in memory (#162)
|
||||
- **Files**: Prune recent files against server nodes on refresh (#146)
|
||||
- **Files**: Fix file deletion logic to update recent files and handle errors (#146)
|
||||
- **Files**: Extend file drop zone to fill remaining viewport height
|
||||
- **Files**: Fallback to application/octet-stream for long MIME types
|
||||
- **Security**: Replace unguarded crypto.randomUUID() with safe generateUUID() utility
|
||||
- **Security**: Validate plugin HTTP post URL against origin with regression tests
|
||||
- **Security**: Allow blob images in CSP for inline drag-and-drop (#163)
|
||||
- **Auth**: Resolve settings sync identity mismatch for OAuth/SSO sessions (#127)
|
||||
- **Contacts**: Fix address book ID namespacing for shared contacts in create and update operations (#133)
|
||||
- **UI**: Fix focused mode expanding beyond screen bounds (#156)
|
||||
- **API**: Handle 403 on principal fetch without console error
|
||||
- **API**: Enhance error handling in Stalwart API responses
|
||||
|
||||
## 1.4.11 (2026-03-31)
|
||||
|
||||
### Features
|
||||
|
||||
+14
-3
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" />
|
||||
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
</picture>
|
||||
|
||||
</div>
|
||||
@@ -12,6 +12,17 @@
|
||||
|
||||
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
|
||||
|
||||
## Join our Community
|
||||
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
|
||||
|
||||
* **Get Support:** Get real-time help with development hurdles.
|
||||
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
|
||||
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
|
||||
|
||||
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development Setup
|
||||
|
||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -41,9 +41,11 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { debug } from "@/lib/debug";
|
||||
|
||||
type PendingScopeAction =
|
||||
@@ -69,10 +71,11 @@ export default function CalendarPage() {
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
const taskStore = useTaskStore();
|
||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||
const { identities } = useIdentityStore();
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||
|
||||
const currentUserEmails = useMemo(() =>
|
||||
@@ -148,6 +151,13 @@ export default function CalendarPage() {
|
||||
return () => clearInterval(interval);
|
||||
}, [client, refreshAllSubscriptions]);
|
||||
|
||||
// Auto-add birthday calendar to selected IDs when enabled
|
||||
useEffect(() => {
|
||||
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
|
||||
}
|
||||
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const dateRange = useMemo(() => {
|
||||
const d = selectedDate;
|
||||
switch (normalizedViewMode) {
|
||||
@@ -743,14 +753,31 @@ export default function CalendarPage() {
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
|
||||
|
||||
const visibleEvents = useMemo(() =>
|
||||
events.filter((e) => {
|
||||
const birthdayEvents = useMemo(() => {
|
||||
if (!showBirthdayCalendar || !dateRange) return [];
|
||||
return generateBirthdayEvents(contacts, dateRange.start, dateRange.end);
|
||||
}, [showBirthdayCalendar, contacts, dateRange]);
|
||||
|
||||
const birthdayCalendarName = (() => {
|
||||
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
||||
})();
|
||||
|
||||
const allCalendars = useMemo(() => {
|
||||
if (!showBirthdayCalendar) return calendars;
|
||||
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const filtered = events.filter((e) => {
|
||||
if (!e.start || !e.calendarIds) return false;
|
||||
const calIds = Object.keys(e.calendarIds);
|
||||
return calIds.some((id) => selectedCalendarIds.includes(id));
|
||||
}),
|
||||
[events, selectedCalendarIds]
|
||||
);
|
||||
});
|
||||
if (showBirthdayCalendar && selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||
return [...filtered, ...birthdayEvents];
|
||||
}
|
||||
return filtered;
|
||||
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
const hiddenEvents = events.filter((event) => {
|
||||
@@ -799,7 +826,7 @@ export default function CalendarPage() {
|
||||
<CalendarMonthView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
@@ -815,7 +842,7 @@ export default function CalendarPage() {
|
||||
<CalendarWeekView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
@@ -834,7 +861,7 @@ export default function CalendarPage() {
|
||||
<CalendarDayView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
@@ -851,7 +878,7 @@ export default function CalendarPage() {
|
||||
<CalendarAgendaView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
@@ -942,10 +969,14 @@ export default function CalendarPage() {
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
/>
|
||||
<CalendarSidebarPanel
|
||||
calendars={calendars}
|
||||
calendars={allCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
onColorChange={client ? (calendarId, color) => {
|
||||
if (calendarId === BIRTHDAY_CALENDAR_ID) {
|
||||
updateSetting('birthdayCalendarColor', color);
|
||||
return;
|
||||
}
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : undefined}
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
|
||||
@@ -13,12 +13,13 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, generateUUID } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
@@ -72,12 +73,16 @@ export default function ContactsPage() {
|
||||
bulkDeleteContacts,
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
renameAddressBook,
|
||||
renameKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
|
||||
const [view, setView] = useState<View>("list");
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
@@ -274,7 +279,7 @@ export default function ContactsPage() {
|
||||
toast.success(t("toast.created"));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
id: `local-${crypto.randomUUID()}`,
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: {},
|
||||
...data,
|
||||
};
|
||||
@@ -631,6 +636,8 @@ export default function ContactsPage() {
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
@@ -725,6 +732,46 @@ export default function ContactsPage() {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
{renamingKeyword !== null && (
|
||||
<RenameDialog
|
||||
currentName={renamingKeyword}
|
||||
title={t("rename_category")}
|
||||
label={t("category_name_label")}
|
||||
onCancel={() => setRenamingKeyword(null)}
|
||||
onConfirm={async (newName) => {
|
||||
try {
|
||||
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
|
||||
toast.success(t("category_renamed"));
|
||||
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
|
||||
setActiveCategory({ keyword: newName.trim() });
|
||||
}
|
||||
setRenamingKeyword(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename category:", err);
|
||||
toast.error(t("category_rename_failed"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{renamingAddressBook && (
|
||||
<RenameDialog
|
||||
currentName={renamingAddressBook.name}
|
||||
title={t("address_books.rename")}
|
||||
label={t("address_books.name_label")}
|
||||
onCancel={() => setRenamingAddressBook(null)}
|
||||
onConfirm={async (newName) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await renameAddressBook(client, renamingAddressBook, newName);
|
||||
toast.success(t("address_books.renamed"));
|
||||
setRenamingAddressBook(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename address book:", err);
|
||||
toast.error(t("address_books.rename_failed"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showImportDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
|
||||
@@ -395,7 +395,7 @@ export default function FilesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{!filesEnabled ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="max-w-lg text-center space-y-3 px-4">
|
||||
|
||||
@@ -11,12 +11,13 @@ import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||
|
||||
const APP_VERSION = "1.4.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 = [
|
||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||
@@ -24,6 +25,41 @@ const THEME_OPTIONS = [
|
||||
{ value: "system" as const, icon: Monitor, label: "System" },
|
||||
];
|
||||
|
||||
function VersionBadge() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(versionInfo).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex justify-center">
|
||||
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
|
||||
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label="Copy version info"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
@@ -579,9 +615,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center text-xs text-muted-foreground/40">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
<VersionBadge />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1075,9 +1109,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center text-xs text-muted-foreground/40">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
<VersionBadge />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+112
-5
@@ -19,6 +19,7 @@ import { useUIStore } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -38,6 +39,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIdentitySync } from "@/hooks/use-identity-sync";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
@@ -76,6 +78,7 @@ export default function Home() {
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||
const { identities } = useIdentityStore();
|
||||
useIdentitySync();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRateLimited || !rateLimitUntil) {
|
||||
@@ -143,6 +146,102 @@ export default function Home() {
|
||||
fetchEmailContent,
|
||||
} = useEmailStore();
|
||||
|
||||
// Browser back / forward integration. The restore handler reads the
|
||||
// latest values from a ref so we don't have to recreate the callback on
|
||||
// every render (and so the popstate listener is never stale).
|
||||
const navRestoreStateRef = useRef({
|
||||
client,
|
||||
emails,
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
selectedEmailId: selectedEmail?.id ?? null,
|
||||
conversationThreadId: null as string | null,
|
||||
});
|
||||
navRestoreStateRef.current.client = client;
|
||||
navRestoreStateRef.current.emails = emails;
|
||||
navRestoreStateRef.current.mailboxes = mailboxes;
|
||||
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
|
||||
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
|
||||
navRestoreStateRef.current.conversationThreadId = conversationThread?.threadId ?? null;
|
||||
|
||||
const handleNavRestore = useCallback(async (state: NavSnapshot) => {
|
||||
const ctx = navRestoreStateRef.current;
|
||||
|
||||
// Restore sidebar overlay state.
|
||||
setSidebarOpen(state.sidebarOpen);
|
||||
|
||||
// Restore composer visibility.
|
||||
if (!state.composerOpen) {
|
||||
setShowComposer(false);
|
||||
}
|
||||
|
||||
// Derive the mobile view from the saved snapshot. The view is a
|
||||
// function of which content the user is looking at: an email, a
|
||||
// thread, the composer, or the bare list.
|
||||
const derivedView: "list" | "viewer" =
|
||||
state.emailId || state.threadId || state.composerOpen ? "viewer" : "list";
|
||||
setActiveView(derivedView);
|
||||
|
||||
// Restore mailbox selection. selectMailbox clears the current email,
|
||||
// which is fine because we re-apply the saved email below.
|
||||
if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
|
||||
selectMailbox(state.mailboxId);
|
||||
if (ctx.client) {
|
||||
try {
|
||||
await fetchEmails(ctx.client, state.mailboxId);
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch emails on history restore:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore conversation thread (mobile only). We can clear it directly,
|
||||
// but reopening requires the thread group; if the user pressed forward
|
||||
// to return to a thread, we silently skip — back navigation always works.
|
||||
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
||||
if (state.threadId === null) {
|
||||
setConversationThread(null);
|
||||
setConversationEmails([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore email selection.
|
||||
if (state.emailId !== ctx.selectedEmailId) {
|
||||
if (state.emailId === null) {
|
||||
selectEmail(null);
|
||||
} else {
|
||||
// Try the in-memory list first; the existing useEffect will fetch
|
||||
// body content if it's missing.
|
||||
const found = ctx.emails.find(e => e.id === state.emailId);
|
||||
if (found) {
|
||||
selectEmail(found);
|
||||
} else if (ctx.client) {
|
||||
// Email isn't in the current list (e.g. mailbox just changed).
|
||||
// Fetch it directly.
|
||||
try {
|
||||
const mailbox = ctx.mailboxes.find(mb => mb.id === state.mailboxId);
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
const fullEmail = await ctx.client.getEmail(state.emailId, accountId);
|
||||
if (fullEmail) selectEmail(fullEmail);
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch email on history restore:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useBrowserNavigation({
|
||||
mailboxId: selectedMailbox,
|
||||
emailId: selectedEmail?.id ?? null,
|
||||
threadId: conversationThread?.threadId ?? null,
|
||||
composerOpen: showComposer,
|
||||
sidebarOpen,
|
||||
onRestore: handleNavRestore,
|
||||
enabled: isAuthenticated && mailboxes.length > 0,
|
||||
});
|
||||
|
||||
// Keyboard shortcuts handlers
|
||||
const keyboardHandlers = useMemo(() => ({
|
||||
onNextEmail: () => {
|
||||
@@ -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 = () => {
|
||||
// If in conversation view, clear it
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
if (conversationThread) {
|
||||
setConversationThread(null);
|
||||
setConversationEmails([]);
|
||||
@@ -1209,11 +1315,12 @@ export default function Home() {
|
||||
"max-md:flex-1 max-md:border-r-0",
|
||||
isMobile && activeView !== "list" && "max-md:hidden",
|
||||
// Tablet/Desktop: fixed width with collapse animation
|
||||
"md:flex-shrink-0 md:shadow-sm",
|
||||
shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0",
|
||||
"md:shadow-sm",
|
||||
!isResizing && "transition-all duration-200 ease-out",
|
||||
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 */}
|
||||
<MobileHeader
|
||||
@@ -1653,7 +1760,7 @@ export default function Home() {
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onArchive={() => handleArchive()}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onMarkAsSpam={handleMarkAsSpam}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
||||
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
|
||||
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
@@ -211,7 +212,7 @@ export default function SettingsPage() {
|
||||
{activeTab === 'encryption' && <SmimeSettings />}
|
||||
{activeTab === 'vacation' && <VacationSettings />}
|
||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{activeTab === 'contacts' && <ContactsSettings />}
|
||||
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{activeTab === 'filters' && <FilterSettings />}
|
||||
{activeTab === 'templates' && <TemplateSettings />}
|
||||
{activeTab === 'folders' && <FolderSettings />}
|
||||
|
||||
@@ -60,6 +60,15 @@ export default function AdminPluginsPage() {
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleRequirePluginApproval() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSavePolicy() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
@@ -248,6 +257,7 @@ export default function AdminPluginsPage() {
|
||||
|
||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -320,6 +330,17 @@ export default function AdminPluginsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Require Admin Approval</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
|
||||
</div>
|
||||
<button onClick={toggleRequirePluginApproval}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Force enable / disable all */}
|
||||
{plugins.length > 0 && (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
|
||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/auth
|
||||
* Proxy to Stalwart GET /api/account/auth
|
||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart auth info failed', { status: response.status });
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart auth info failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch auth info', details: text },
|
||||
{ error: detail || 'Failed to fetch auth info' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/crypto
|
||||
* Proxy to Stalwart GET /api/account/crypto
|
||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart crypto info failed', { status: response.status });
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch crypto info', details: text },
|
||||
{ error: detail || 'Failed to fetch crypto info' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/principal
|
||||
* Proxy to Stalwart GET /api/principal/{username}
|
||||
@@ -20,9 +34,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart principal fetch failed', { status: response.status });
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch principal', details: text },
|
||||
{ error: detail || 'Failed to fetch principal' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
@@ -50,21 +51,36 @@ function isEnabled(): boolean {
|
||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
||||
}
|
||||
|
||||
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify identity against session cookies across all account slots.
|
||||
* With multi-account, the requesting account may be on any slot (0-4).
|
||||
* Returns true 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> {
|
||||
const cookieStore = await cookies();
|
||||
const normalizedServerUrl = normalizeUrl(serverUrl);
|
||||
|
||||
for (let slot = 0; slot <= 4; slot++) {
|
||||
// Check basic-auth session cookie
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (!token) continue;
|
||||
if (token) {
|
||||
const session = decryptSession(token);
|
||||
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const session = decryptSession(token);
|
||||
if (session && session.username === username && session.serverUrl === serverUrl) {
|
||||
return true; // Found a matching slot
|
||||
// Check stalwart auth context cookie (set for all auth modes)
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -79,10 +79,14 @@ export async function POST(request: NextRequest) {
|
||||
const overwrite = request.headers.get('Overwrite');
|
||||
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
|
||||
|
||||
// Forward request body for methods that need it
|
||||
let body: ArrayBuffer | null = null;
|
||||
if (method === 'PROPFIND' || method === 'PUT') {
|
||||
// Forward request body for methods that need it.
|
||||
// PUT streams directly to upstream to avoid buffering large uploads in memory.
|
||||
// PROPFIND bodies are small XML and are read fully.
|
||||
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
|
||||
if (method === 'PROPFIND') {
|
||||
body = await request.arrayBuffer();
|
||||
} else if (method === 'PUT') {
|
||||
body = request.body;
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
@@ -90,7 +94,9 @@ export async function POST(request: NextRequest) {
|
||||
headers: upstreamHeaders,
|
||||
body,
|
||||
redirect: 'follow',
|
||||
});
|
||||
// `duplex: 'half'` is required by undici when sending a streaming request body.
|
||||
...(method === 'PUT' ? { duplex: 'half' } : {}),
|
||||
} as Parameters<typeof fetch>[1] & { duplex?: 'half' });
|
||||
|
||||
// For file downloads (GET), stream the response back
|
||||
if (method === 'GET') {
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -20,6 +22,15 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||
};
|
||||
}
|
||||
@@ -36,6 +47,14 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-title"
|
||||
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
{parentOrigin && (
|
||||
<meta name="parent-origin" content={parentOrigin} />
|
||||
)}
|
||||
@@ -63,7 +82,9 @@ export default async function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
<PWAInstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
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 type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 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 {
|
||||
if (!event.alerts) return null;
|
||||
const first = Object.values(event.alerts)[0];
|
||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||
if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||
const offset = first.trigger.offset;
|
||||
if (offset === "PT0S") return t("alerts.at_time");
|
||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/lib/calendar-participants";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -402,9 +403,7 @@ export function EventModal({
|
||||
if (!event || !onDuplicate) return;
|
||||
const start = getEventStartDate(event);
|
||||
const newStart = addDays(start, 1);
|
||||
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
const newUid = generateUUID();
|
||||
const data: Partial<CalendarEvent> = {
|
||||
uid: newUid,
|
||||
title: event.title,
|
||||
|
||||
@@ -304,11 +304,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
if (contact?.addressBookIds) {
|
||||
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
|
||||
if (ids.length > 0) {
|
||||
// For shared contacts, the addressBookIds uses the original (non-namespaced) id
|
||||
// but we need the namespaced id to match addressBooks entries
|
||||
if (contact.isShared && contact.accountId) {
|
||||
return `${contact.accountId}:${ids[0]}`;
|
||||
}
|
||||
// addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
|
||||
// so we can use them directly to match addressBook entries
|
||||
return ids[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
|
||||
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
|
||||
onDeleteGroup?: (groupId: string) => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||
onRenameKeyword?: (keyword: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -58,10 +61,15 @@ export function ContactsSidebar({
|
||||
onDeleteGroup,
|
||||
onDropContacts,
|
||||
onDropContactsToCategory,
|
||||
onRenameAddressBook,
|
||||
onRenameKeyword,
|
||||
className,
|
||||
}: ContactsSidebarProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const router = useRouter();
|
||||
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
|
||||
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
|
||||
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
@@ -246,19 +254,32 @@ export function ContactsSidebar({
|
||||
{/* My Address Books */}
|
||||
{personalBooks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleSection("addressBooks")}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
>
|
||||
{collapsed.addressBooks ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t("address_books.title")}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection("addressBooks")}
|
||||
className="flex items-center gap-1 flex-1 text-left"
|
||||
>
|
||||
{collapsed.addressBooks ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t("address_books.title")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||
router.push('/settings');
|
||||
}}
|
||||
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||
title={t("address_books.manage")}
|
||||
>
|
||||
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||
<AddressBookItem
|
||||
key={book.id}
|
||||
@@ -267,6 +288,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -362,6 +384,7 @@ export function ContactsSidebar({
|
||||
isActive={isActive}
|
||||
onSelect={() => onSelectCategory({ keyword })}
|
||||
onDropContacts={onDropContactsToCategory}
|
||||
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -372,20 +395,33 @@ export function ContactsSidebar({
|
||||
{/* Shared accounts with address books */}
|
||||
{sharedBookGroups.map((group) => (
|
||||
<div key={group.accountId} className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
>
|
||||
{collapsed[`shared-${group.accountId}`] ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<Share2 className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
||||
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
||||
>
|
||||
{collapsed[`shared-${group.accountId}`] ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<Share2 className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
||||
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||
router.push('/settings');
|
||||
}}
|
||||
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||
title={t("address_books.manage")}
|
||||
>
|
||||
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
||||
<AddressBookItem
|
||||
key={book.id}
|
||||
@@ -394,12 +430,53 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Address book context menu */}
|
||||
{bookContextMenu.data && onRenameAddressBook && (
|
||||
<ContextMenu
|
||||
ref={bookMenuRef}
|
||||
isOpen={bookContextMenu.isOpen}
|
||||
position={bookContextMenu.position}
|
||||
onClose={closeBookContextMenu}
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("address_books.rename")}
|
||||
onClick={() => {
|
||||
const book = bookContextMenu.data!;
|
||||
closeBookContextMenu();
|
||||
onRenameAddressBook(book);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{/* Keyword (category) context menu */}
|
||||
{keywordContextMenu.data && onRenameKeyword && (
|
||||
<ContextMenu
|
||||
ref={keywordMenuRef}
|
||||
isOpen={keywordContextMenu.isOpen}
|
||||
position={keywordContextMenu.position}
|
||||
onClose={closeKeywordContextMenu}
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("rename_category")}
|
||||
onClick={() => {
|
||||
const kw = keywordContextMenu.data!;
|
||||
closeKeywordContextMenu();
|
||||
onRenameKeyword(kw);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{/* Group context menu */}
|
||||
{groupContextMenu.data && (
|
||||
<ContextMenu
|
||||
@@ -438,12 +515,14 @@ function CategoryItem({
|
||||
isActive,
|
||||
onSelect,
|
||||
onDropContacts,
|
||||
onContextMenu,
|
||||
}: {
|
||||
keyword: string;
|
||||
count: number;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onDropContacts?: (contactIds: string[], keyword: string) => void;
|
||||
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -476,6 +555,7 @@ function CategoryItem({
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -503,12 +583,14 @@ function AddressBookItem({
|
||||
contactCount,
|
||||
onSelect,
|
||||
onDropContacts,
|
||||
onContextMenu,
|
||||
}: {
|
||||
book: AddressBook;
|
||||
isActive: boolean;
|
||||
contactCount: number;
|
||||
onSelect: () => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -541,6 +623,7 @@ function AddressBookItem({
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -754,49 +754,67 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
{/* Event title */}
|
||||
{summary?.title && (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className={cn(
|
||||
"text-base font-semibold leading-snug",
|
||||
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
|
||||
)}>
|
||||
{summary.title}
|
||||
</h3>
|
||||
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
|
||||
{t('event_updated', { sequence: parsedEvent.sequence })}
|
||||
</span>
|
||||
<div className="lg:flex lg:gap-6">
|
||||
{/* Left: Event info */}
|
||||
<div className="lg:flex-1 space-y-2.5 min-w-0">
|
||||
{/* Event title */}
|
||||
{summary?.title && (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className={cn(
|
||||
"text-base font-semibold leading-snug",
|
||||
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
|
||||
)}>
|
||||
{summary.title}
|
||||
</h3>
|
||||
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
|
||||
{t('event_updated', { sequence: parsedEvent.sequence })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event details */}
|
||||
<div className="space-y-1">
|
||||
{summary?.start && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
{formatDateTime(summary.start)}
|
||||
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
||||
</span>
|
||||
{/* Event details */}
|
||||
<div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
|
||||
{summary?.start && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
{formatDateTime(summary.start)}
|
||||
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{summary?.location && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{summary.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{summary?.organizer && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Users className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{t('organizer', { name: summary.organizer })}
|
||||
</span>
|
||||
)}
|
||||
{summary && summary.attendeeCount > 0 && (
|
||||
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{summary?.location && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{summary.location}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
||||
{summary?.organizer && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{t('organizer', { name: summary.organizer })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Info & actor messages on large screens */}
|
||||
<div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
|
||||
{bannerInfo && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
|
||||
)}
|
||||
{summary && summary.attendeeCount > 0 && (
|
||||
<span>{t('attendees', { count: summary.attendeeCount })}</span>
|
||||
{actorMessage && (
|
||||
<p className="text-xs text-muted-foreground">{actorMessage}</p>
|
||||
)}
|
||||
{actorSummary?.participationComment && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t('actor_note', { comment: actorSummary.participationComment })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -840,23 +858,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info text */}
|
||||
{bannerInfo && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
|
||||
)}
|
||||
|
||||
{/* Actor message */}
|
||||
{actorMessage && (
|
||||
<p className="text-xs text-muted-foreground">{actorMessage}</p>
|
||||
)}
|
||||
|
||||
{/* Actor comment */}
|
||||
{actorSummary?.participationComment && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t('actor_note', { comment: actorSummary.participationComment })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Proposed changes */}
|
||||
{proposedChanges.length > 0 && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
|
||||
|
||||
@@ -34,9 +34,8 @@ import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
|
||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||
function htmlToPlainText(html: string): string {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
return doc.body.textContent || '';
|
||||
}
|
||||
|
||||
export interface ComposerDraftData {
|
||||
@@ -533,17 +532,18 @@ export function EmailComposer({
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
|
||||
if (!client) return null;
|
||||
try {
|
||||
const { blobId } = await client.uploadBlob(file);
|
||||
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
|
||||
} catch (error) {
|
||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
return null;
|
||||
}
|
||||
}, [client, t]);
|
||||
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||
reader.onerror = () => {
|
||||
debug.error(`Failed to read inline image ${file.name}`);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
resolve(null);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return;
|
||||
|
||||
@@ -94,7 +94,7 @@ export function EmailHoverActions({
|
||||
onToggleStar?.();
|
||||
break;
|
||||
case "markRead":
|
||||
onMarkAsRead?.(!isUnread);
|
||||
onMarkAsRead?.(isUnread);
|
||||
break;
|
||||
case "archive":
|
||||
onArchive?.();
|
||||
|
||||
@@ -32,7 +32,7 @@ interface EmailListItemProps {
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -44,7 +44,10 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const sender = email.from?.[0];
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/ema
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime } from "@/lib/utils";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||
import {
|
||||
Reply,
|
||||
@@ -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(() => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -1167,17 +1185,18 @@ export function EmailViewer({
|
||||
|
||||
// Instant mark
|
||||
if (markAsReadDelay === 0) {
|
||||
autoMarkedEmailRef.current = email.id;
|
||||
onMarkAsRead(email.id, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delayed mark
|
||||
const timeout = setTimeout(() => {
|
||||
autoMarkedEmailRef.current = email.id;
|
||||
onMarkAsRead(email.id, true);
|
||||
}, markAsReadDelay);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger
|
||||
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
|
||||
|
||||
// Reset external content permission and quick reply when email changes
|
||||
@@ -4507,7 +4526,7 @@ export function EmailViewer({
|
||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
hasCalendarInvitation) && (
|
||||
<div className="border-b border-border bg-muted/30 isolate">
|
||||
<div className="max-w-4xl mx-auto px-6 py-1.5">
|
||||
<div className="max-w-6xl mx-auto px-6 py-1.5">
|
||||
<div className="flex flex-col gap-3 isolate">
|
||||
{/* External Content Controls */}
|
||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||
@@ -4874,7 +4893,7 @@ export function EmailViewer({
|
||||
if (client && supportsSync) {
|
||||
createContact(client, contactData).then(() => toast.success('Contact added'));
|
||||
} else {
|
||||
addLocalContact({ id: `local-${crypto.randomUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
||||
addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
||||
toast.success('Contact added');
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -93,6 +93,18 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on scroll so the popover does not float while content moves.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = () => handleClose();
|
||||
document.addEventListener("scroll", handler, true);
|
||||
window.addEventListener("resize", handler);
|
||||
return () => {
|
||||
document.removeEventListener("scroll", handler, true);
|
||||
window.removeEventListener("resize", handler);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleViewContact = () => {
|
||||
if (onViewContact) {
|
||||
onViewContact(contact ?? null, email);
|
||||
|
||||
@@ -118,6 +118,7 @@ export function RichTextEditor({
|
||||
);
|
||||
if (imageFiles.length === 0) return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
for (const file of imageFiles) {
|
||||
upload(file).then((url) => {
|
||||
if (url) {
|
||||
|
||||
@@ -55,8 +55,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -339,7 +342,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const displayNames = showRecipient
|
||||
? Array.from(new Set(
|
||||
thread.emails.flatMap(e => (e.to ?? []).map(r => r.name || r.email.split('@')[0]))
|
||||
)).slice(0, 4)
|
||||
: participantNames;
|
||||
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
|
||||
|
||||
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
||||
email: latestEmail,
|
||||
@@ -522,8 +534,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={latestEmail.from?.[0]?.name}
|
||||
email={latestEmail.from?.[0]?.email}
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
@@ -537,7 +549,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{participantNames.join(', ')}
|
||||
{displayNames.join(', ')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -591,7 +603,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{participantNames.join(", ")}
|
||||
{displayNames.join(", ")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -837,7 +837,7 @@ export function FileBrowser({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col h-full"
|
||||
className="flex flex-col flex-1 min-h-0"
|
||||
onClick={handleContainerClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
FilterActionType,
|
||||
} from "@/lib/jmap/sieve-types";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from "@/lib/utils";
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||
|
||||
interface FilterRuleModalProps {
|
||||
rule?: FilterRule;
|
||||
@@ -109,7 +109,7 @@ export function FilterRuleModal({
|
||||
}
|
||||
|
||||
onSave({
|
||||
id: rule?.id || crypto.randomUUID(),
|
||||
id: rule?.id || generateUUID(),
|
||||
name: trimmedName,
|
||||
enabled: rule?.enabled ?? true,
|
||||
matchType,
|
||||
|
||||
@@ -14,6 +14,10 @@ function useSyncIdentities() {
|
||||
const syncIdentities = useAuthStore((state) => state.syncIdentities);
|
||||
return syncIdentities;
|
||||
}
|
||||
|
||||
function useRefreshIdentities() {
|
||||
return useAuthStore((state) => state.refreshIdentities);
|
||||
}
|
||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
@@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
|
||||
const syncIdentities = useSyncIdentities();
|
||||
|
||||
const refreshIdentitiesFromServer = useRefreshIdentities();
|
||||
|
||||
// Refresh identities from server whenever the modal is opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
refreshIdentitiesFromServer();
|
||||
}
|
||||
}, [isOpen, refreshIdentitiesFromServer]);
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
|
||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { IconPicker } from './icon-picker';
|
||||
@@ -207,7 +207,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
|
||||
});
|
||||
|
||||
const handleCreate = useCallback((data: SidebarAppFormData) => {
|
||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const id = `app-${generateUUID()}`;
|
||||
addSidebarApp({ id, ...data });
|
||||
setIsCreating(false);
|
||||
}, [addSidebarApp]);
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Users,
|
||||
User,
|
||||
Palmtree,
|
||||
Settings,
|
||||
@@ -59,10 +58,6 @@ interface SidebarProps {
|
||||
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
|
||||
const lowerName = name?.toLowerCase() || "";
|
||||
|
||||
if (id === 'shared-folders-root') {
|
||||
return isExpanded ? FolderOpen : Users;
|
||||
}
|
||||
|
||||
if (id?.startsWith('shared-account-')) {
|
||||
return isExpanded ? FolderOpen : User;
|
||||
}
|
||||
|
||||
@@ -6,24 +6,32 @@ import { useLocaleStore } from '@/stores/locale-store';
|
||||
import enMessages from '@/locales/en/common.json';
|
||||
import frMessages from '@/locales/fr/common.json';
|
||||
import jaMessages from '@/locales/ja/common.json';
|
||||
import koMessages from '@/locales/ko/common.json';
|
||||
import esMessages from '@/locales/es/common.json';
|
||||
import itMessages from '@/locales/it/common.json';
|
||||
import deMessages from '@/locales/de/common.json';
|
||||
import lvMessages from '@/locales/lv/common.json';
|
||||
import nlMessages from '@/locales/nl/common.json';
|
||||
import plMessages from '@/locales/pl/common.json';
|
||||
import ptMessages from '@/locales/pt/common.json';
|
||||
import ruMessages from '@/locales/ru/common.json';
|
||||
import zhMessages from '@/locales/zh/common.json';
|
||||
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
const ALL_MESSAGES = {
|
||||
en: enMessages,
|
||||
fr: frMessages,
|
||||
ja: jaMessages,
|
||||
ko: koMessages,
|
||||
es: esMessages,
|
||||
it: itMessages,
|
||||
de: deMessages,
|
||||
lv: lvMessages,
|
||||
nl: nlMessages,
|
||||
pl: plMessages,
|
||||
pt: ptMessages,
|
||||
ru: ruMessages,
|
||||
zh: zhMessages,
|
||||
};
|
||||
|
||||
interface IntlProviderProps {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download } from "lucide-react";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
export function PWAInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] =
|
||||
useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
setShowPrompt(true);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === "accepted") {
|
||||
setDeferredPrompt(null);
|
||||
setShowPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
if (!showPrompt || !deferredPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
|
||||
Install Bulwark
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
Install our app for quick access and offline support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
||||
aria-label="Dismiss install prompt"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// In development, unregister any previously installed service worker
|
||||
// and clear its caches so hot reload always serves fresh assets.
|
||||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
registrations.forEach((registration) => registration.unregister());
|
||||
});
|
||||
if ("caches" in window) {
|
||||
caches.keys().then((keys) => {
|
||||
keys.forEach((key) => caches.delete(key));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
console.log("Service Worker registered successfully:", registration);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Service Worker registration failed:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -157,6 +157,7 @@ function DisplayNameSection() {
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={displayName || t('display_name.placeholder')}
|
||||
className="w-48"
|
||||
/>
|
||||
<Button
|
||||
@@ -523,7 +524,7 @@ export function AccountSecuritySettings() {
|
||||
if (isStalwart === false) {
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<p className="text-sm text-muted-foreground py-4">{t('not_available')}</p>
|
||||
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: t('not_available') }} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Book, Pencil, Share2, Tag } from "lucide-react";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AddressBook } from "@/lib/jmap/types";
|
||||
|
||||
function AddressBookEditRow({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: {
|
||||
initial: string;
|
||||
onSave: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const t = useTranslations("contacts.address_books");
|
||||
const tCal = useTranslations("calendar.management");
|
||||
const [name, setName] = useState(initial);
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("name_label")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && isValid) onSave(name.trim());
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => isValid && onSave(name.trim())}
|
||||
disabled={isLoading || !isValid}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{tCal("save")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
|
||||
>
|
||||
{tCal("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddressBookManagementSettings() {
|
||||
const t = useTranslations("contacts.address_books");
|
||||
const tContacts = useTranslations("contacts");
|
||||
const tSettings = useTranslations("settings.contacts");
|
||||
const { client } = useAuthStore();
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && addressBooks.length === 0) {
|
||||
fetchAddressBooks(client);
|
||||
}
|
||||
}, [client, addressBooks.length, fetchAddressBooks]);
|
||||
|
||||
const handleUpdate = async (book: AddressBook, newName: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await renameAddressBook(client, book, newName);
|
||||
setEditingId(null);
|
||||
toast.success(t("renamed"));
|
||||
} catch {
|
||||
toast.error(t("rename_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Group: personal first, then by shared account
|
||||
const personal = addressBooks.filter((b) => !b.isShared);
|
||||
const sharedGroups = new Map<string, { accountName: string; books: AddressBook[] }>();
|
||||
for (const book of addressBooks) {
|
||||
if (!book.isShared || !book.accountId) continue;
|
||||
const key = book.accountId;
|
||||
const existing = sharedGroups.get(key);
|
||||
if (existing) existing.books.push(book);
|
||||
else sharedGroups.set(key, { accountName: book.accountName || book.accountId, books: [book] });
|
||||
}
|
||||
|
||||
const renderBook = (book: AddressBook) => {
|
||||
if (editingId === book.id) {
|
||||
return (
|
||||
<AddressBookEditRow
|
||||
key={book.id}
|
||||
initial={book.name}
|
||||
onSave={(name) => handleUpdate(book, name)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canRename = !book.isShared || book.myRights?.mayWrite !== false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={book.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
|
||||
)}
|
||||
>
|
||||
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{book.name}</span>
|
||||
</div>
|
||||
{book.isDefault && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
{t("default")}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{canRename && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(book.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("rename")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Collect keywords with counts
|
||||
const keywordCounts: Record<string, number> = {};
|
||||
for (const c of contacts) {
|
||||
if (c.kind === "group" || !c.keywords) continue;
|
||||
for (const [kw, active] of Object.entries(c.keywords)) {
|
||||
if (active) keywordCounts[kw] = (keywordCounts[kw] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const sortedKeywords = Object.entries(keywordCounts).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const handleRenameKeyword = async (oldKw: string, newKw: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await renameKeyword(supportsSync && client ? client : null, oldKw, newKw);
|
||||
setEditingKeyword(null);
|
||||
toast.success(tContacts("category_renamed"));
|
||||
} catch {
|
||||
toast.error(tContacts("category_rename_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
|
||||
<div className="space-y-2">
|
||||
{personal.map(renderBook)}
|
||||
|
||||
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
|
||||
<div key={accountId} className="mt-4 space-y-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{t("shared_prefix", { name: group.accountName })}
|
||||
</h4>
|
||||
{group.books.map(renderBook)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{addressBooks.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mt-8">
|
||||
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
|
||||
<div className="space-y-2">
|
||||
{sortedKeywords.map(([keyword, count]) => {
|
||||
if (editingKeyword === keyword) {
|
||||
return (
|
||||
<AddressBookEditRow
|
||||
key={keyword}
|
||||
initial={keyword}
|
||||
onSave={(name) => handleRenameKeyword(keyword, name)}
|
||||
onCancel={() => setEditingKeyword(null)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={keyword}
|
||||
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
|
||||
>
|
||||
<Tag className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{keyword}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{count}</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingKeyword(keyword)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={tContacts("rename_category")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sortedKeywords.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">{tSettings("no_categories")}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,11 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
|
||||
export function AdvancedSettings() {
|
||||
const t = useTranslations('settings.advanced');
|
||||
@@ -18,6 +23,20 @@ export function AdvancedSettings() {
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
const [showGame, setShowGame] = useState(false);
|
||||
const logoClickCount = useRef(0);
|
||||
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleLogoClick = () => {
|
||||
logoClickCount.current++;
|
||||
if (logoClickTimer.current) clearTimeout(logoClickTimer.current);
|
||||
if (logoClickCount.current >= 3) {
|
||||
logoClickCount.current = 0;
|
||||
setShowGame(true);
|
||||
} else {
|
||||
logoClickTimer.current = setTimeout(() => { logoClickCount.current = 0; }, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const settingsJson = exportSettings();
|
||||
@@ -65,6 +84,44 @@ export function AdvancedSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
|
||||
{/* About */}
|
||||
<div className="rounded-lg border border-border bg-card p-5 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_Color.svg"
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_White.svg"
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('about.title')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
|
||||
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/bulwarkmail/webmail"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
GitHub <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Debug Mode */}
|
||||
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
|
||||
@@ -147,5 +204,6 @@ export function AdvancedSettings() {
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function CalendarSettings() {
|
||||
showWeekNumbers,
|
||||
enableCalendarTasks,
|
||||
showTasksOnCalendar,
|
||||
showBirthdayCalendar,
|
||||
calendarHoverPreview,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
@@ -98,6 +99,16 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('show_birthday_calendar')}
|
||||
description={t('show_birthday_calendar_desc')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showBirthdayCalendar}
|
||||
onChange={(checked) => updateSetting('showBirthdayCalendar', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{isFeatureEnabled('calendarTasksEnabled') && (
|
||||
<>
|
||||
<SettingItem
|
||||
|
||||
@@ -99,11 +99,17 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
|
||||
export function FolderSettings() {
|
||||
const t = useTranslations('settings.folders');
|
||||
const { client } = useAuthStore();
|
||||
const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
|
||||
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
|
||||
const { folderIcons, setFolderIcon } = useSettingsStore();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
|
||||
|
||||
useEffect(() => {
|
||||
if (client && mailboxes.length === 0) {
|
||||
fetchMailboxes(client);
|
||||
}
|
||||
}, [client, mailboxes.length, fetchMailboxes]);
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [creatingParentId, setCreatingParentId] = useState<string | null>(null);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
@@ -200,8 +206,18 @@ export function FolderSettings() {
|
||||
await deleteMailbox(client, mailboxId);
|
||||
setDeletingId(null);
|
||||
toast.success(t('folder_deleted'));
|
||||
} catch {
|
||||
toast.error(t('error_delete'));
|
||||
} catch (err: unknown) {
|
||||
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
|
||||
switch (jmapType) {
|
||||
case 'mailboxHasChild':
|
||||
toast.error(t('error_delete_has_children'));
|
||||
break;
|
||||
case 'mailboxHasEmail':
|
||||
toast.error(t('error_delete_has_email'));
|
||||
break;
|
||||
default:
|
||||
toast.error(t('error_delete'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record<PluginStatus, string> = {
|
||||
|
||||
export function PluginsSettings() {
|
||||
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
|
||||
const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore();
|
||||
const { isFeatureEnabled, isPluginForceEnabled, isPluginApproved, fetchPolicy, loaded } = usePolicyStore();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -75,6 +75,13 @@ export function PluginsSettings() {
|
||||
return;
|
||||
}
|
||||
|
||||
const requireApproval = isFeatureEnabled('requirePluginApproval');
|
||||
const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
|
||||
if (!plugin.enabled && requireApproval && !isApproved) {
|
||||
toast.info(`Plugin "${plugin.name}" requires admin approval before it can be enabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plugin.enabled) {
|
||||
disablePlugin(plugin.id);
|
||||
toast.info(`Plugin "${plugin.name}" disabled`);
|
||||
@@ -108,20 +115,26 @@ export function PluginsSettings() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{plugins.map(plugin => (
|
||||
{plugins.map(plugin => {
|
||||
const requireApproval = isFeatureEnabled('requirePluginApproval');
|
||||
const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
|
||||
const needsApproval = requireApproval && !isApproved;
|
||||
return (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
isExpanded={expandedPlugin === plugin.id}
|
||||
isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)}
|
||||
isManaged={Boolean(plugin.managed)}
|
||||
needsApproval={needsApproval}
|
||||
controlsDisabled={!initialized}
|
||||
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
||||
onToggle={() => handleToggle(plugin)}
|
||||
onUninstall={() => handleUninstall(plugin)}
|
||||
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -166,6 +179,7 @@ interface PluginCardProps {
|
||||
isExpanded: boolean;
|
||||
isForceEnabled: boolean;
|
||||
isManaged: boolean;
|
||||
needsApproval: boolean;
|
||||
controlsDisabled: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onToggle: () => void;
|
||||
@@ -173,7 +187,7 @@ interface PluginCardProps {
|
||||
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
|
||||
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApproval, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border transition-colors',
|
||||
@@ -197,6 +211,11 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
|
||||
<Server className="w-2.5 h-2.5" /> Managed
|
||||
</span>
|
||||
)}
|
||||
{needsApproval && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
|
||||
Awaiting approval
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{plugin.author}</span>
|
||||
@@ -206,7 +225,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled} />
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled || needsApproval} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -217,6 +236,10 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p>
|
||||
)}
|
||||
|
||||
{needsApproval && (
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400">This plugin is awaiting admin approval and cannot be enabled until an administrator approves it.</p>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-muted-foreground">{plugin.description}</p>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { IconPicker } from "@/components/layout/icon-picker";
|
||||
import { useSettingsStore, type SidebarApp } from "@/stores/settings-store";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, generateUUID } from "@/lib/utils";
|
||||
|
||||
interface SidebarAppFormData {
|
||||
name: string;
|
||||
@@ -188,7 +188,7 @@ export function SidebarAppsSettings() {
|
||||
const draggedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const handleAdd = useCallback((data: SidebarAppFormData) => {
|
||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const id = `app-${generateUUID()}`;
|
||||
addSidebarApp({ id, ...data });
|
||||
setShowAddForm(false);
|
||||
}, [addSidebarApp]);
|
||||
|
||||
@@ -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: 'fr', label: 'Français' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'lv', label: 'Latviešu' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ru', label: 'Русский' }
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '简体中文' }
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -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':
|
||||
messages = (await import('../locales/ja/common.json')).default;
|
||||
break;
|
||||
case 'ko':
|
||||
messages = (await import('../locales/ko/common.json')).default;
|
||||
break;
|
||||
case 'lv':
|
||||
messages = (await import('../locales/lv/common.json')).default;
|
||||
break;
|
||||
case 'nl':
|
||||
messages = (await import('../locales/nl/common.json')).default;
|
||||
break;
|
||||
case 'pl':
|
||||
messages = (await import('../locales/pl/common.json')).default;
|
||||
break;
|
||||
case 'pt':
|
||||
messages = (await import('../locales/pt/common.json')).default;
|
||||
break;
|
||||
case 'ru':
|
||||
messages = (await import('../locales/ru/common.json')).default;
|
||||
break;
|
||||
case 'zh':
|
||||
messages = (await import('../locales/zh/common.json')).default;
|
||||
break;
|
||||
default:
|
||||
messages = (await import('../locales/en/common.json')).default;
|
||||
}
|
||||
@@ -45,4 +57,4 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
timeZone: 'Europe/Paris',
|
||||
now: new Date()
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'nl', 'pt', 'ru'],
|
||||
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'zh'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix: 'never'
|
||||
});
|
||||
|
||||
@@ -148,3 +148,46 @@ describe('toast bridge', () => {
|
||||
expect(api.toast.warning).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
|
||||
describe('http.post path validation', () => {
|
||||
function makeApi(permissions: string[] = ['http:post']) {
|
||||
return createPluginAPI(makePlugin({ permissions }));
|
||||
}
|
||||
|
||||
it('rejects protocol-relative URLs like //evil.example', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('//evil.example/collect', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects absolute URLs to other origins', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('https://evil.example/steal', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects paths not under /api/', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('/other/path', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects paths that use backslash to bypass the check', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('/api/\\@evil.example', {})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws without http:post permission', async () => {
|
||||
const api = makeApi([]);
|
||||
await expect(api.http.post('/api/jitsi', {})).rejects.toThrow('lacks permission');
|
||||
});
|
||||
|
||||
it('accepts a valid /api/ path', async () => {
|
||||
const api = makeApi();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ url: 'https://meet.example.com/room' }),
|
||||
});
|
||||
const result = await api.http.post('/api/jitsi', { eventTitle: 'test' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data).toEqual({ url: 'https://meet.example.com/room' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -375,5 +375,111 @@ describe('expandRecurringEvents', () => {
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
|
||||
expect(result[0].originalId).toBe('evt1');
|
||||
});
|
||||
|
||||
it('skips server-returned override events that belong to a recurring master', () => {
|
||||
// Server returns the master event plus override instances with recurrenceId set
|
||||
const master = makeEvent({
|
||||
id: 'master1',
|
||||
uid: 'shared-uid',
|
||||
start: '2025-01-06T09:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
});
|
||||
const override1 = makeEvent({
|
||||
id: 'override1',
|
||||
uid: 'shared-uid',
|
||||
start: '2025-01-13T09:00:00',
|
||||
recurrenceId: '2025-01-13T09:00:00',
|
||||
recurrenceRules: null,
|
||||
});
|
||||
const override2 = makeEvent({
|
||||
id: 'override2',
|
||||
uid: 'shared-uid',
|
||||
start: '2025-01-20T09:00:00',
|
||||
recurrenceId: '2025-01-20T09:00:00',
|
||||
recurrenceRules: null,
|
||||
});
|
||||
|
||||
const result = expandRecurringEvents(
|
||||
[master, override1, override2],
|
||||
'2025-01-06T00:00:00',
|
||||
'2025-01-27T00:00:00',
|
||||
);
|
||||
|
||||
// Should have 3 weekly occurrences from master expansion only (Jan 6, 13, 20)
|
||||
// Override events should be skipped since they share the master's UID
|
||||
expect(result).toHaveLength(3);
|
||||
expect(starts(result)).toEqual([
|
||||
'2025-01-06T09:00:00',
|
||||
'2025-01-13T09:00:00',
|
||||
'2025-01-20T09:00:00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps override events whose UID has no matching master', () => {
|
||||
// Standalone override event with no master in the batch
|
||||
const orphanOverride = makeEvent({
|
||||
id: 'orphan1',
|
||||
uid: 'orphan-uid',
|
||||
start: '2025-01-13T09:00:00',
|
||||
recurrenceId: '2025-01-13T09:00:00',
|
||||
recurrenceRules: null,
|
||||
});
|
||||
|
||||
const result = expandRecurringEvents(
|
||||
[orphanOverride],
|
||||
'2025-01-06T00:00:00',
|
||||
'2025-01-27T00:00:00',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('orphan1');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// utcStart computation for occurrences
|
||||
// -----------------------------------------------------------------------
|
||||
describe('utcStart per occurrence', () => {
|
||||
it('computes distinct utcStart for each weekly occurrence', () => {
|
||||
const event = makeEvent({
|
||||
start: '2026-09-01T12:00:00',
|
||||
utcStart: '2026-09-01T10:00:00Z',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
});
|
||||
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
|
||||
const utcStarts = result.map(e => (e as any).utcStart);
|
||||
// Each occurrence should have a unique utcStart
|
||||
expect(new Set(utcStarts).size).toBe(result.length);
|
||||
// First occurrence keeps master's UTC offset relationship
|
||||
expect(utcStarts[0]).toContain('2026-09-01');
|
||||
expect(utcStarts[1]).toContain('2026-09-08');
|
||||
expect(utcStarts[2]).toContain('2026-09-15');
|
||||
});
|
||||
|
||||
it('computes distinct utcEnd for each weekly occurrence', () => {
|
||||
const event = makeEvent({
|
||||
start: '2026-09-01T12:00:00',
|
||||
duration: 'PT1H',
|
||||
utcStart: '2026-09-01T10:00:00Z',
|
||||
utcEnd: '2026-09-01T11:00:00Z',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
});
|
||||
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
|
||||
const utcEnds = result.map(e => (e as any).utcEnd);
|
||||
// Each occurrence should have a unique utcEnd
|
||||
expect(new Set(utcEnds).size).toBe(result.length);
|
||||
expect(utcEnds[0]).toContain('2026-09-01');
|
||||
expect(utcEnds[1]).toContain('2026-09-08');
|
||||
expect(utcEnds[2]).toContain('2026-09-15');
|
||||
});
|
||||
|
||||
it('does not set utcStart when master has none', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-06T09:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
|
||||
expect(result[0].utcStart).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+6
-1
@@ -25,6 +25,7 @@ export interface SettingRestriction {
|
||||
export interface FeatureGates {
|
||||
pluginsEnabled: boolean;
|
||||
pluginsUploadEnabled: boolean;
|
||||
requirePluginApproval: boolean;
|
||||
themesEnabled: boolean;
|
||||
sidebarAppsEnabled: boolean;
|
||||
userThemesEnabled: boolean;
|
||||
@@ -41,8 +42,9 @@ export interface FeatureGates {
|
||||
}
|
||||
|
||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
pluginsEnabled: true,
|
||||
pluginsEnabled: false,
|
||||
pluginsUploadEnabled: true,
|
||||
requirePluginApproval: true,
|
||||
themesEnabled: true,
|
||||
sidebarAppsEnabled: true,
|
||||
userThemesEnabled: true,
|
||||
@@ -80,6 +82,8 @@ export interface SettingsPolicy {
|
||||
themePolicy: ThemePolicy;
|
||||
/** Plugin IDs that are force-enabled (users cannot disable) */
|
||||
forceEnabledPlugins: string[];
|
||||
/** Plugin IDs that have been approved by admin (users can enable) */
|
||||
approvedPlugins: string[];
|
||||
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
||||
forceEnabledThemes: string[];
|
||||
}
|
||||
@@ -90,6 +94,7 @@ export const DEFAULT_POLICY: SettingsPolicy = {
|
||||
defaults: {},
|
||||
themePolicy: { ...DEFAULT_THEME_POLICY },
|
||||
forceEnabledPlugins: [],
|
||||
approvedPlugins: [],
|
||||
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 { generateUUID } from '@/lib/utils';
|
||||
|
||||
export interface ParticipantInfo {
|
||||
id: string;
|
||||
@@ -104,9 +105,7 @@ export function buildParticipantMap(
|
||||
): Record<string, Partial<CalendarParticipant>> {
|
||||
const participants: Record<string, Partial<CalendarParticipant>> = {};
|
||||
|
||||
const generateId = () => typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `p-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const generateId = () => generateUUID();
|
||||
|
||||
participants[generateId()] = {
|
||||
'@type': 'Participant',
|
||||
|
||||
@@ -481,6 +481,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
async getAddressBooks(): 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[]> {
|
||||
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||
return [...this.data.contacts];
|
||||
|
||||
@@ -178,6 +178,7 @@ export interface IJMAPClient {
|
||||
getContactsAccountId(): string;
|
||||
getAddressBooks(): Promise<AddressBook[]>;
|
||||
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
||||
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||
getAllContacts(): Promise<ContactCard[]>;
|
||||
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];
|
||||
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(
|
||||
accountId: string,
|
||||
filter?: Record<string, unknown>,
|
||||
@@ -3220,33 +3252,48 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
|
||||
|
||||
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
||||
if (calendarIds && calendarIds.length > 0) {
|
||||
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/get", {
|
||||
accountId,
|
||||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
|
||||
}, "1"]
|
||||
], this.calendarUsing());
|
||||
|
||||
// Check for JMAP method-level errors
|
||||
if (response.methodResponses?.[0]?.[0] === "error") {
|
||||
const error = response.methodResponses[0][1];
|
||||
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
|
||||
const error = queryResponse.methodResponses[0][1];
|
||||
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
|
||||
}
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
||||
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
|
||||
.filter((event) => !isTaskObject(event))
|
||||
.map((event) => normalizeCalendarEventLike(event));
|
||||
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 [];
|
||||
|
||||
return allEvents
|
||||
.filter((event) => !isTaskObject(event))
|
||||
.map((event) => normalizeCalendarEventLike(event));
|
||||
}
|
||||
|
||||
async queryAllCalendarEvents(
|
||||
@@ -3311,21 +3358,42 @@ export class JMAPClient implements IJMAPClient {
|
||||
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/get", {
|
||||
accountId,
|
||||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
|
||||
}, "1"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
||||
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
|
||||
.filter((event) => !isTaskObject(event))
|
||||
.map((event) => normalizeCalendarEventLike(event));
|
||||
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
|
||||
const error = queryResponse.methodResponses[0][1];
|
||||
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
|
||||
}
|
||||
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) {
|
||||
console.error('Failed to query calendar events:', error);
|
||||
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> {
|
||||
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) {
|
||||
fileProps.parentId = parentId;
|
||||
}
|
||||
@@ -4112,6 +4182,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
[["FileNode/set", {
|
||||
accountId,
|
||||
destroy: ids,
|
||||
onDestroyRemoveChildren: true,
|
||||
}, "fns0"]],
|
||||
this.fileUsing(),
|
||||
);
|
||||
@@ -4120,9 +4191,18 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (!result || result[0] === "error") {
|
||||
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 {
|
||||
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_SECURE = process.env.COOKIE_SECURE !== undefined
|
||||
? process.env.COOKIE_SECURE === 'true'
|
||||
: (COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production');
|
||||
|
||||
export function getCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production',
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: COOKIE_SAME_SITE,
|
||||
path: '/',
|
||||
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';
|
||||
|
||||
/** 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,
|
||||
} from './plugin-hooks';
|
||||
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
|
||||
function getPluginExternals(): any {
|
||||
@@ -60,7 +61,7 @@ function guardedHook<T extends (...args: never[]) => unknown>(
|
||||
return bus.register(plugin.id, handler, order);
|
||||
}
|
||||
|
||||
// ─── Plugin-scoped storage ───────────────────────────────────
|
||||
// --- Plugin-scoped storage -----------------------------------
|
||||
|
||||
function createPluginStorage(pluginId: string) {
|
||||
const prefix = `plugin:${pluginId}:`;
|
||||
@@ -92,7 +93,7 @@ function createPluginStorage(pluginId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Plugin-scoped logger ────────────────────────────────────
|
||||
// --- Plugin-scoped logger ------------------------------------
|
||||
|
||||
function createPluginLogger(pluginId: string) {
|
||||
const tag = `[plugin:${pluginId}]`;
|
||||
@@ -104,7 +105,7 @@ function createPluginLogger(pluginId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── PluginAPI interface ─────────────────────────────────────
|
||||
// --- PluginAPI interface -------------------------------------
|
||||
|
||||
export interface PluginAPI {
|
||||
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
||||
@@ -128,6 +129,9 @@ export interface PluginAPI {
|
||||
info: (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>;
|
||||
log: ReturnType<typeof createPluginLogger>;
|
||||
admin: {
|
||||
@@ -312,7 +316,7 @@ export interface PluginHooksAPI {
|
||||
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
}
|
||||
|
||||
// ─── Permission mapping for hooks ────────────────────────────
|
||||
// --- Permission mapping for hooks ----------------------------
|
||||
|
||||
const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
// Email
|
||||
@@ -461,7 +465,7 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
|
||||
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
||||
};
|
||||
|
||||
// ─── Slot registration bridge ────────────────────────────────
|
||||
// --- Slot registration bridge --------------------------------
|
||||
// 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;
|
||||
@@ -483,7 +487,7 @@ function registerSlot(
|
||||
return registerSlotFn(slotName, { pluginId, component, order });
|
||||
}
|
||||
|
||||
// ─── Factory ─────────────────────────────────────────────────
|
||||
// --- Factory -------------------------------------------------
|
||||
|
||||
export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
||||
// 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),
|
||||
},
|
||||
|
||||
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),
|
||||
log: createPluginLogger(plugin.id),
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import * as ReactJSX from 'react/jsx-runtime';
|
||||
|
||||
// ─── Shared React (window.__PLUGIN_EXTERNALS__) ─────────────
|
||||
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
|
||||
|
||||
export function exposePluginExternals(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -20,7 +20,7 @@ export function exposePluginExternals(): void {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Active plugin tracking ──────────────────────────────────
|
||||
// --- Active plugin tracking ----------------------------------
|
||||
|
||||
interface ActivePlugin {
|
||||
id: string;
|
||||
@@ -31,7 +31,7 @@ interface ActivePlugin {
|
||||
|
||||
const activePlugins = new Map<string, ActivePlugin>();
|
||||
|
||||
// ─── Load a single plugin ────────────────────────────────────
|
||||
// --- Load a single plugin ------------------------------------
|
||||
|
||||
type PluginStoreAccessor = {
|
||||
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 {
|
||||
const active = activePlugins.get(pluginId);
|
||||
@@ -127,7 +127,7 @@ export function deactivatePlugin(pluginId: string): void {
|
||||
console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
|
||||
}
|
||||
|
||||
// ─── Activate all enabled plugins ────────────────────────────
|
||||
// --- Activate all enabled plugins ----------------------------
|
||||
|
||||
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
|
||||
// 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 {
|
||||
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 {
|
||||
return activePlugins.has(pluginId);
|
||||
}
|
||||
|
||||
// ─── Setup auto-disable callback ─────────────────────────────
|
||||
// --- Setup auto-disable callback -----------------------------
|
||||
|
||||
export function setupAutoDisable(): void {
|
||||
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface InstalledPlugin {
|
||||
managed?: boolean;
|
||||
// True when plugin is admin-enforced and cannot be disabled locally.
|
||||
forceEnabled?: boolean;
|
||||
// True when plugin has been approved by an admin. Unapproved plugins cannot be enabled.
|
||||
adminApproved?: boolean;
|
||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
@@ -402,6 +404,7 @@ export const ALL_PERMISSIONS = [
|
||||
'settings:read', 'settings:write',
|
||||
'security:read',
|
||||
'auth:observe',
|
||||
'http:post',
|
||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
||||
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||
|
||||
@@ -35,7 +35,23 @@ export function expandRecurringEvents(
|
||||
const end = parseISO(rangeEnd);
|
||||
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) {
|
||||
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) {
|
||||
result.push(event);
|
||||
continue;
|
||||
@@ -112,6 +128,25 @@ function createOccurrence(
|
||||
? format(date, "yyyy-MM-dd'T'00:00:00")
|
||||
: 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 {
|
||||
...master,
|
||||
...(override || {}),
|
||||
@@ -120,6 +155,8 @@ function createOccurrence(
|
||||
uid: master.uid,
|
||||
calendarIds: master.calendarIds,
|
||||
start: (override?.start) || startStr,
|
||||
...(utcStart && !override?.utcStart ? { utcStart } : {}),
|
||||
...(utcEnd && !override?.utcEnd ? { utcEnd } : {}),
|
||||
recurrenceId,
|
||||
recurrenceRules: master.recurrenceRules,
|
||||
recurrenceOverrides: master.recurrenceOverrides,
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* All line endings are CRLF per RFC 5322.
|
||||
*/
|
||||
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
const CRLF = '\r\n';
|
||||
|
||||
export interface MimeAttachment {
|
||||
@@ -43,7 +45,7 @@ export function buildMimeMessage(input: MimeMessageInput): Uint8Array {
|
||||
// BCC is intentionally omitted from the MIME headers per RFC 5322
|
||||
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
|
||||
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) {
|
||||
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('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) {
|
||||
lines.push(formatHeader('In-Reply-To', input.inReplyTo));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import {
|
||||
extractCertificateInfo,
|
||||
classifyCapabilities,
|
||||
@@ -154,7 +155,7 @@ export async function importPkcs12(
|
||||
const email = certInfo.emailAddresses[0] ?? '';
|
||||
|
||||
const keyRecord: SmimeKeyRecord = {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
email: email.toLowerCase(),
|
||||
certificate: leafCertDer,
|
||||
certificateChain: chainCertsDer,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { EmailTemplate } from './template-types';
|
||||
import { BUILT_IN_PLACEHOLDERS } from './template-types';
|
||||
import { generateUUID } from './utils';
|
||||
|
||||
const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g;
|
||||
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;
|
||||
|
||||
templates.push({
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
name: sanitizeText(t.name),
|
||||
subject: sanitizeText(t.subject),
|
||||
body: sanitizeText(t.body),
|
||||
|
||||
+27
-40
@@ -7,6 +7,19 @@ export function cn(...inputs: ClassValue[]) {
|
||||
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 {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
@@ -244,7 +257,10 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
`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) {
|
||||
// Group shared mailboxes by account
|
||||
const accountGroups = new Map<string, Mailbox[]>();
|
||||
@@ -256,20 +272,16 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
accountGroups.get(accountId)!.push(mb);
|
||||
});
|
||||
|
||||
// Create virtual nodes for each shared account
|
||||
const sharedAccountNodes: MailboxNode[] = [];
|
||||
|
||||
accountGroups.forEach((accountMailboxes, accountId) => {
|
||||
// Create account nodes
|
||||
// Create nodes for this account's mailboxes
|
||||
const accountMailboxMap = new Map<string, MailboxNode>();
|
||||
const accountRootNodes: MailboxNode[] = [];
|
||||
|
||||
// Create nodes for this account's mailboxes
|
||||
accountMailboxes.forEach(mailbox => {
|
||||
accountMailboxMap.set(mailbox.id, {
|
||||
...mailbox,
|
||||
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
|
||||
recalculateDepths(accountRootNodes, 2);
|
||||
// Render the shared account's mailboxes flush with primary-account
|
||||
// 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 accountNode: MailboxNode = {
|
||||
id: `shared-account-${accountId}`,
|
||||
@@ -314,39 +329,11 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
accountName: accountName,
|
||||
isShared: true,
|
||||
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
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
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
|
||||
function anniversaryDateToVcardString(date: AnniversaryDate): string {
|
||||
@@ -153,7 +154,7 @@ export function parseVCard(vcfString: string): ContactCard[] {
|
||||
}
|
||||
|
||||
function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
const id = `import-${crypto.randomUUID()}`;
|
||||
const id = `import-${generateUUID()}`;
|
||||
const card: ContactCard = { id, addressBookIds: {} };
|
||||
|
||||
for (const [fullKey, values] of Object.entries(raw)) {
|
||||
|
||||
+29
-4
@@ -993,7 +993,7 @@
|
||||
"title": "Kontosicherheit",
|
||||
"description": "Verwalten Sie Ihr Passwort, Zwei-Faktor-Authentifizierung und Sicherheitseinstellungen",
|
||||
"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": {
|
||||
"title": "Passwort ändern",
|
||||
"current": "Aktuelles Passwort",
|
||||
@@ -1009,6 +1009,7 @@
|
||||
"display_name": {
|
||||
"label": "Anzeigename",
|
||||
"description": "Ihr Name, wie er auf dem Server angezeigt wird",
|
||||
"placeholder": "Geben Sie Ihren Anzeigenamen ein",
|
||||
"save": "Speichern",
|
||||
"success": "Anzeigename aktualisiert",
|
||||
"error": "Anzeigename konnte nicht aktualisiert werden"
|
||||
@@ -1155,6 +1156,8 @@
|
||||
"error_create": "Ordner konnte nicht erstellt werden",
|
||||
"error_rename": "Ordner konnte nicht umbenannt 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"
|
||||
},
|
||||
"advanced": {
|
||||
@@ -1208,6 +1211,9 @@
|
||||
"label": "Einstellungen importieren",
|
||||
"description": "Einstellungen aus JSON-Datei hochladen",
|
||||
"button": "Importieren"
|
||||
},
|
||||
"about": {
|
||||
"title": "Bulwark Webmail"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
@@ -1224,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"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": {
|
||||
"title": "E-Mail-Filter",
|
||||
@@ -1629,6 +1641,10 @@
|
||||
"search_placeholder": "Kontakte suchen...",
|
||||
"create_new": "Neuer Kontakt",
|
||||
"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_plural": "{count} Kontakte zu {name} hinzugefügt",
|
||||
"empty_state": "Keine Kontakte",
|
||||
@@ -1655,7 +1671,13 @@
|
||||
"moved": "Kontakt verschoben nach {name}",
|
||||
"moved_plural": "{count} Kontakte verschoben nach {name}",
|
||||
"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": {
|
||||
"emails": "E-Mail-Adressen",
|
||||
@@ -1870,6 +1892,7 @@
|
||||
"back_to_email": "Zurück zu E-Mails",
|
||||
"back_to_month": "Zurück zur Monatsansicht",
|
||||
"my_calendars": "Meine Kalender",
|
||||
"birthday_calendar": "Geburtstage",
|
||||
"mini_calendar_change": "Klicken, um den Monat zu wechseln",
|
||||
"views": {
|
||||
"month": "Monat",
|
||||
@@ -2007,7 +2030,9 @@
|
||||
"hover_preview_delay_500ms": "0,5 Sekunden Verzögerung",
|
||||
"hover_preview_delay_1s": "1 Sekunde 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": {
|
||||
"monday": "Montag",
|
||||
|
||||
+29
-4
@@ -993,7 +993,7 @@
|
||||
"title": "Account Security",
|
||||
"description": "Manage your password, two-factor authentication, and security settings",
|
||||
"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": {
|
||||
"title": "Change Password",
|
||||
"current": "Current Password",
|
||||
@@ -1009,6 +1009,7 @@
|
||||
"display_name": {
|
||||
"label": "Display Name",
|
||||
"description": "Your name as it appears on the server",
|
||||
"placeholder": "Enter your display name",
|
||||
"save": "Save",
|
||||
"success": "Display name updated",
|
||||
"error": "Failed to update display name"
|
||||
@@ -1155,6 +1156,8 @@
|
||||
"error_create": "Failed to create folder",
|
||||
"error_rename": "Failed to rename 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"
|
||||
},
|
||||
"advanced": {
|
||||
@@ -1208,6 +1211,9 @@
|
||||
"label": "Import Settings",
|
||||
"description": "Upload settings from JSON file",
|
||||
"button": "Import"
|
||||
},
|
||||
"about": {
|
||||
"title": "Bulwark Webmail"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
@@ -1224,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"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": {
|
||||
"title": "Email Filters",
|
||||
@@ -1629,6 +1641,10 @@
|
||||
"search_placeholder": "Search contacts...",
|
||||
"create_new": "New Contact",
|
||||
"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_plural": "{count} contacts added to {name}",
|
||||
"empty_state": "No contacts yet",
|
||||
@@ -1655,7 +1671,13 @@
|
||||
"moved": "Contact moved to {name}",
|
||||
"moved_plural": "{count} contacts moved to {name}",
|
||||
"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": {
|
||||
"emails": "Email Addresses",
|
||||
@@ -1870,6 +1892,7 @@
|
||||
"back_to_email": "Back to email",
|
||||
"back_to_month": "Back to month",
|
||||
"my_calendars": "Calendars",
|
||||
"birthday_calendar": "Birthdays",
|
||||
"mini_calendar_change": "Click to change month",
|
||||
"views": {
|
||||
"month": "Month",
|
||||
@@ -2007,7 +2030,9 @@
|
||||
"hover_preview_delay_500ms": "0.5-second delay",
|
||||
"hover_preview_delay_1s": "1-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": {
|
||||
"monday": "Monday",
|
||||
|
||||
+29
-4
@@ -993,7 +993,7 @@
|
||||
"title": "Seguridad de la cuenta",
|
||||
"description": "Administre su contraseña, autenticación de dos factores y configuración de seguridad",
|
||||
"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": {
|
||||
"title": "Cambiar contraseña",
|
||||
"current": "Contraseña actual",
|
||||
@@ -1009,6 +1009,7 @@
|
||||
"display_name": {
|
||||
"label": "Nombre para mostrar",
|
||||
"description": "Su nombre tal como aparece en el servidor",
|
||||
"placeholder": "Ingrese su nombre para mostrar",
|
||||
"save": "Guardar",
|
||||
"success": "Nombre para mostrar actualizado",
|
||||
"error": "No se pudo actualizar el nombre para mostrar"
|
||||
@@ -1155,6 +1156,8 @@
|
||||
"error_create": "Error al crear la carpeta",
|
||||
"error_rename": "Error al renombrar 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"
|
||||
},
|
||||
"advanced": {
|
||||
@@ -1208,6 +1211,9 @@
|
||||
"label": "Importar Configuración",
|
||||
"description": "Cargar configuración desde archivo JSON",
|
||||
"button": "Importar"
|
||||
},
|
||||
"about": {
|
||||
"title": "Bulwark Webmail"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
@@ -1224,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"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": {
|
||||
"title": "Filtros de correo",
|
||||
@@ -1629,6 +1641,10 @@
|
||||
"search_placeholder": "Buscar contactos...",
|
||||
"create_new": "Nuevo contacto",
|
||||
"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_plural": "{count} contactos añadidos a {name}",
|
||||
"empty_state": "No hay contactos",
|
||||
@@ -1655,7 +1671,13 @@
|
||||
"moved": "Contacto movido a {name}",
|
||||
"moved_plural": "{count} contactos movidos a {name}",
|
||||
"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": {
|
||||
"emails": "Direcciones de correo",
|
||||
@@ -1870,6 +1892,7 @@
|
||||
"back_to_email": "Volver al correo",
|
||||
"back_to_month": "Volver al mes",
|
||||
"my_calendars": "Mis calendarios",
|
||||
"birthday_calendar": "Cumpleaños",
|
||||
"mini_calendar_change": "Clic para cambiar de mes",
|
||||
"views": {
|
||||
"month": "Mes",
|
||||
@@ -2007,7 +2030,9 @@
|
||||
"hover_preview_delay_500ms": "Retraso de 0,5 segundos",
|
||||
"hover_preview_delay_1s": "Retraso de 1 segundo",
|
||||
"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": {
|
||||
"monday": "Lunes",
|
||||
|
||||
+29
-4
@@ -993,7 +993,7 @@
|
||||
"title": "Sécurité du compte",
|
||||
"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...",
|
||||
"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": {
|
||||
"title": "Changer le mot de passe",
|
||||
"current": "Mot de passe actuel",
|
||||
@@ -1009,6 +1009,7 @@
|
||||
"display_name": {
|
||||
"label": "Nom d'affichage",
|
||||
"description": "Votre nom tel qu'il apparaît sur le serveur",
|
||||
"placeholder": "Entrez votre nom d'affichage",
|
||||
"save": "Enregistrer",
|
||||
"success": "Nom d'affichage mis à jour",
|
||||
"error": "Impossible de mettre à jour le nom d'affichage"
|
||||
@@ -1155,6 +1156,8 @@
|
||||
"error_create": "Échec de la création du dossier",
|
||||
"error_rename": "Échec du renommage 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"
|
||||
},
|
||||
"advanced": {
|
||||
@@ -1208,6 +1211,9 @@
|
||||
"label": "Importer les paramètres",
|
||||
"description": "Charger les paramètres depuis un fichier JSON",
|
||||
"button": "Importer"
|
||||
},
|
||||
"about": {
|
||||
"title": "Bulwark Webmail"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
@@ -1224,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"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": {
|
||||
"title": "Filtres de courrier",
|
||||
@@ -1629,6 +1641,10 @@
|
||||
"search_placeholder": "Rechercher des contacts...",
|
||||
"create_new": "Nouveau contact",
|
||||
"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_plural": "{count} contacts ajoutés à {name}",
|
||||
"empty_state": "Aucun contact",
|
||||
@@ -1655,7 +1671,13 @@
|
||||
"moved": "Contact déplacé vers {name}",
|
||||
"moved_plural": "{count} contacts déplacés vers {name}",
|
||||
"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": {
|
||||
"emails": "Adresses e-mail",
|
||||
@@ -1870,6 +1892,7 @@
|
||||
"back_to_email": "Retour aux e-mails",
|
||||
"back_to_month": "Retour au mois",
|
||||
"my_calendars": "Mes calendriers",
|
||||
"birthday_calendar": "Anniversaires",
|
||||
"mini_calendar_change": "Cliquer pour changer de mois",
|
||||
"views": {
|
||||
"month": "Mois",
|
||||
@@ -2007,7 +2030,9 @@
|
||||
"hover_preview_delay_500ms": "Délai de 0,5 seconde",
|
||||
"hover_preview_delay_1s": "Délai de 1 seconde",
|
||||
"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": {
|
||||
"monday": "Lundi",
|
||||
|
||||
+29
-4
@@ -993,7 +993,7 @@
|
||||
"title": "Sicurezza dell'account",
|
||||
"description": "Gestisci la tua password, l'autenticazione a due fattori e le impostazioni di sicurezza",
|
||||
"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": {
|
||||
"title": "Cambia password",
|
||||
"current": "Password attuale",
|
||||
@@ -1009,6 +1009,7 @@
|
||||
"display_name": {
|
||||
"label": "Nome visualizzato",
|
||||
"description": "Il tuo nome come appare sul server",
|
||||
"placeholder": "Inserisci il tuo nome visualizzato",
|
||||
"save": "Salva",
|
||||
"success": "Nome visualizzato aggiornato",
|
||||
"error": "Impossibile aggiornare il nome visualizzato"
|
||||
@@ -1155,6 +1156,8 @@
|
||||
"error_create": "Impossibile creare la cartella",
|
||||
"error_rename": "Impossibile rinominare 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"
|
||||
},
|
||||
"advanced": {
|
||||
@@ -1208,6 +1211,9 @@
|
||||
"label": "Importa impostazioni",
|
||||
"description": "Carica impostazioni da file JSON",
|
||||
"button": "Importa"
|
||||
},
|
||||
"about": {
|
||||
"title": "Bulwark Webmail"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
@@ -1224,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"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": {
|
||||
"title": "Filtri email",
|
||||
@@ -1629,6 +1641,10 @@
|
||||
"search_placeholder": "Cerca contatti...",
|
||||
"create_new": "Nuovo contatto",
|
||||
"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_plural": "{count} contatti aggiunti a {name}",
|
||||
"empty_state": "Nessun contatto",
|
||||
@@ -1655,7 +1671,13 @@
|
||||
"moved": "Contatto spostato in {name}",
|
||||
"moved_plural": "{count} contatti spostati in {name}",
|
||||
"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": {
|
||||
"emails": "Indirizzi email",
|
||||
@@ -1870,6 +1892,7 @@
|
||||
"back_to_email": "Torna alla posta",
|
||||
"back_to_month": "Torna al mese",
|
||||
"my_calendars": "I miei calendari",
|
||||
"birthday_calendar": "Compleanni",
|
||||
"mini_calendar_change": "Clicca per cambiare mese",
|
||||
"views": {
|
||||
"month": "Mese",
|
||||
@@ -2007,7 +2030,9 @@
|
||||
"hover_preview_delay_500ms": "Ritardo di 0,5 secondi",
|
||||
"hover_preview_delay_1s": "Ritardo di 1 secondo",
|
||||
"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": {
|
||||
"monday": "Lunedì",
|
||||
|
||||
+29
-4
@@ -993,7 +993,7 @@
|
||||
"title": "アカウントセキュリティ",
|
||||
"description": "パスワード、二要素認証、セキュリティ設定を管理します",
|
||||
"detecting": "サーバー機能を検出中...",
|
||||
"not_available": "このメールサーバーではアカウントセキュリティ管理は利用できません。",
|
||||
"not_available": "このメールサーバーではアカウントセキュリティ管理は利用できません。必要な権限が無効になっている可能性があります。詳細は<a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">ドキュメント</a>を参照してください。",
|
||||
"password": {
|
||||
"title": "パスワードの変更",
|
||||
"current": "現在のパスワード",
|
||||
@@ -1009,6 +1009,7 @@
|
||||
"display_name": {
|
||||
"label": "表示名",
|
||||
"description": "サーバー上に表示される名前",
|
||||
"placeholder": "表示名を入力",
|
||||
"save": "保存",
|
||||
"success": "表示名が更新されました",
|
||||
"error": "表示名を更新できませんでした"
|
||||
@@ -1155,6 +1156,8 @@
|
||||
"error_create": "フォルダーの作成に失敗しました",
|
||||
"error_rename": "フォルダー名の変更に失敗しました",
|
||||
"error_delete": "フォルダーの削除に失敗しました",
|
||||
"error_delete_has_children": "フォルダーを削除できません:サブフォルダーが含まれています。先にサブフォルダーを削除または移動してください。",
|
||||
"error_delete_has_email": "フォルダーを削除できません:メールが含まれています。先にメールを移動または削除してください。",
|
||||
"error_role": "フォルダーの役割の更新に失敗しました"
|
||||
},
|
||||
"advanced": {
|
||||
@@ -1208,6 +1211,9 @@
|
||||
"label": "設定をインポート",
|
||||
"description": "JSONファイルから設定をアップロード",
|
||||
"button": "インポート"
|
||||
},
|
||||
"about": {
|
||||
"title": "Bulwark Webmail"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
@@ -1224,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"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": {
|
||||
"title": "メールフィルター",
|
||||
@@ -1629,6 +1641,10 @@
|
||||
"search_placeholder": "連絡先を検索...",
|
||||
"create_new": "新しい連絡先",
|
||||
"no_category": "カテゴリなし",
|
||||
"rename_category": "カテゴリの名前を変更",
|
||||
"category_name_label": "カテゴリ名",
|
||||
"category_renamed": "カテゴリの名前を変更しました",
|
||||
"category_rename_failed": "カテゴリの名前変更に失敗しました",
|
||||
"category_added": "{name} に連絡先を追加しました",
|
||||
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
|
||||
"empty_state": "連絡先がありません",
|
||||
@@ -1655,7 +1671,13 @@
|
||||
"moved": "連絡先を {name} に移動しました",
|
||||
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
|
||||
"move_failed": "連絡先の移動に失敗しました",
|
||||
"address_book": "アドレス帳"
|
||||
"address_book": "アドレス帳",
|
||||
"rename": "アドレス帳の名前を変更",
|
||||
"name_label": "アドレス帳名",
|
||||
"renamed": "アドレス帳の名前を変更しました",
|
||||
"rename_failed": "アドレス帳の名前変更に失敗しました",
|
||||
"default": "デフォルト",
|
||||
"manage": "アドレス帳を管理"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "メールアドレス",
|
||||
@@ -1870,6 +1892,7 @@
|
||||
"back_to_email": "メールに戻る",
|
||||
"back_to_month": "月表示に戻る",
|
||||
"my_calendars": "マイカレンダー",
|
||||
"birthday_calendar": "誕生日",
|
||||
"mini_calendar_change": "クリックで月を変更",
|
||||
"views": {
|
||||
"month": "月",
|
||||
@@ -2007,7 +2030,9 @@
|
||||
"hover_preview_delay_500ms": "0.5秒遅延",
|
||||
"hover_preview_delay_1s": "1秒遅延",
|
||||
"hover_preview_delay_2s": "2秒遅延",
|
||||
"hover_preview_off": "無効"
|
||||
"hover_preview_off": "無効",
|
||||
"show_birthday_calendar": "誕生日カレンダー",
|
||||
"show_birthday_calendar_desc": "連絡先の誕生日を表示する仮想カレンダーを表示します"
|
||||
},
|
||||
"days": {
|
||||
"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",
|
||||
"description": "Beheer uw wachtwoord, tweefactorauthenticatie en beveiligingsinstellingen",
|
||||
"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": {
|
||||
"title": "Wachtwoord wijzigen",
|
||||
"current": "Huidig wachtwoord",
|
||||
@@ -1009,6 +1009,7 @@
|
||||
"display_name": {
|
||||
"label": "Weergavenaam",
|
||||
"description": "Uw naam zoals weergegeven op de server",
|
||||
"placeholder": "Voer uw weergavenaam in",
|
||||
"save": "Opslaan",
|
||||
"success": "Weergavenaam bijgewerkt",
|
||||
"error": "Kan weergavenaam niet bijwerken"
|
||||
@@ -1155,6 +1156,8 @@
|
||||
"error_create": "Map maken mislukt",
|
||||
"error_rename": "Map hernoemen 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"
|
||||
},
|
||||
"advanced": {
|
||||
@@ -1208,6 +1211,9 @@
|
||||
"label": "Instellingen importeren",
|
||||
"description": "Upload instellingen vanuit JSON-bestand",
|
||||
"button": "Importeren"
|
||||
},
|
||||
"about": {
|
||||
"title": "Bulwark Webmail"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
@@ -1224,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"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": {
|
||||
"title": "E-mailfilters",
|
||||
@@ -1629,6 +1641,10 @@
|
||||
"search_placeholder": "Contacten zoeken...",
|
||||
"create_new": "Nieuw contact",
|
||||
"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_plural": "{count} contacten toegevoegd aan {name}",
|
||||
"empty_state": "Geen contacten",
|
||||
@@ -1655,7 +1671,13 @@
|
||||
"moved": "Contact verplaatst naar {name}",
|
||||
"moved_plural": "{count} contacten verplaatst naar {name}",
|
||||
"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": {
|
||||
"emails": "E-mailadressen",
|
||||
@@ -1870,6 +1892,7 @@
|
||||
"back_to_email": "Terug naar e-mail",
|
||||
"back_to_month": "Terug naar maand",
|
||||
"my_calendars": "Mijn agenda's",
|
||||
"birthday_calendar": "Verjaardagen",
|
||||
"mini_calendar_change": "Klik om van maand te wisselen",
|
||||
"views": {
|
||||
"month": "Maand",
|
||||
@@ -2007,7 +2030,9 @@
|
||||
"hover_preview_delay_500ms": "0,5 seconde vertraging",
|
||||
"hover_preview_delay_1s": "1 seconde 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": {
|
||||
"monday": "Maandag",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+29
-4
@@ -993,7 +993,7 @@
|
||||
"title": "Segurança da conta",
|
||||
"description": "Gerencie sua senha, autenticação de dois fatores e configurações de segurança",
|
||||
"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": {
|
||||
"title": "Alterar senha",
|
||||
"current": "Senha atual",
|
||||
@@ -1009,6 +1009,7 @@
|
||||
"display_name": {
|
||||
"label": "Nome de exibição",
|
||||
"description": "Seu nome como aparece no servidor",
|
||||
"placeholder": "Digite seu nome de exibição",
|
||||
"save": "Salvar",
|
||||
"success": "Nome de exibição atualizado",
|
||||
"error": "Não foi possível atualizar o nome de exibição"
|
||||
@@ -1155,6 +1156,8 @@
|
||||
"error_create": "Falha ao criar a pasta",
|
||||
"error_rename": "Falha ao renomear 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"
|
||||
},
|
||||
"advanced": {
|
||||
@@ -1208,6 +1211,9 @@
|
||||
"label": "Importar Configurações",
|
||||
"description": "Fazer upload de configurações de arquivo JSON",
|
||||
"button": "Importar"
|
||||
},
|
||||
"about": {
|
||||
"title": "Bulwark Webmail"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
@@ -1224,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"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": {
|
||||
"title": "Filtros de e-mail",
|
||||
@@ -1629,6 +1641,10 @@
|
||||
"search_placeholder": "Pesquisar contatos...",
|
||||
"create_new": "Novo contato",
|
||||
"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_plural": "{count} contatos adicionados a {name}",
|
||||
"empty_state": "Nenhum contato",
|
||||
@@ -1655,7 +1671,13 @@
|
||||
"moved": "Contato movido para {name}",
|
||||
"moved_plural": "{count} contatos movidos para {name}",
|
||||
"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": {
|
||||
"emails": "Endereços de e-mail",
|
||||
@@ -1870,6 +1892,7 @@
|
||||
"back_to_email": "Voltar ao e-mail",
|
||||
"back_to_month": "Voltar ao mês",
|
||||
"my_calendars": "Meus calendários",
|
||||
"birthday_calendar": "Aniversários",
|
||||
"mini_calendar_change": "Clique para mudar o mês",
|
||||
"views": {
|
||||
"month": "Mês",
|
||||
@@ -2007,7 +2030,9 @@
|
||||
"hover_preview_delay_500ms": "Atraso de 0,5 segundos",
|
||||
"hover_preview_delay_1s": "Atraso de 1 segundo",
|
||||
"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": {
|
||||
"monday": "Segunda-feira",
|
||||
|
||||
+29
-4
@@ -993,7 +993,7 @@
|
||||
"title": "Безопасность аккаунта",
|
||||
"description": "Управляйте паролем, двухфакторной аутентификацией и настройками безопасности",
|
||||
"detecting": "Определение возможностей сервера...",
|
||||
"not_available": "Управление безопасностью аккаунта недоступно для этого почтового сервера.",
|
||||
"not_available": "Управление безопасностью аккаунта недоступно для этого почтового сервера. Требуемые разрешения могут быть отключены. Подробнее см. в <a href=\"/docs/guides/account-security\" class=\"underline hover:opacity-80\" target=\"_blank\">документации</a>.",
|
||||
"password": {
|
||||
"title": "Изменить пароль",
|
||||
"current": "Текущий пароль",
|
||||
@@ -1009,6 +1009,7 @@
|
||||
"display_name": {
|
||||
"label": "Отображаемое имя",
|
||||
"description": "Ваше имя, отображаемое на сервере",
|
||||
"placeholder": "Введите ваше отображаемое имя",
|
||||
"save": "Сохранить",
|
||||
"success": "Отображаемое имя обновлено",
|
||||
"error": "Не удалось обновить отображаемое имя"
|
||||
@@ -1155,6 +1156,8 @@
|
||||
"error_create": "Не удалось создать папку",
|
||||
"error_rename": "Не удалось переименовать папку",
|
||||
"error_delete": "Не удалось удалить папку",
|
||||
"error_delete_has_children": "Невозможно удалить папку: она содержит подпапки. Сначала удалите или переместите их.",
|
||||
"error_delete_has_email": "Невозможно удалить папку: она содержит письма. Сначала переместите или удалите их.",
|
||||
"error_role": "Не удалось обновить роль папки"
|
||||
},
|
||||
"advanced": {
|
||||
@@ -1208,6 +1211,9 @@
|
||||
"label": "Импортировать настройки",
|
||||
"description": "Загрузить настройки из файла JSON",
|
||||
"button": "Импортировать"
|
||||
},
|
||||
"about": {
|
||||
"title": "Bulwark Webmail"
|
||||
}
|
||||
},
|
||||
"sidebar_apps": {
|
||||
@@ -1224,7 +1230,13 @@
|
||||
"import_label": "Импорт контактов",
|
||||
"import_description": "Импортировать контакты из файла vCard (.vcf)",
|
||||
"export_label": "Экспорт контактов",
|
||||
"export_description": "Экспортировать все контакты в виде файла vCard (.vcf)"
|
||||
"export_description": "Экспортировать все контакты в виде файла vCard (.vcf)",
|
||||
"manage_title": "Адресные книги",
|
||||
"manage_description": "Переименуйте ваши адресные книги",
|
||||
"no_address_books": "Адресные книги не найдены",
|
||||
"categories_title": "Категории",
|
||||
"categories_description": "Переименование категорий контактов",
|
||||
"no_categories": "Категории не найдены"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фильтры почты",
|
||||
@@ -1629,6 +1641,10 @@
|
||||
"search_placeholder": "Поиск контактов...",
|
||||
"create_new": "Новый контакт",
|
||||
"no_category": "Без категории",
|
||||
"rename_category": "Переименовать категорию",
|
||||
"category_name_label": "Название категории",
|
||||
"category_renamed": "Категория переименована",
|
||||
"category_rename_failed": "Не удалось переименовать категорию",
|
||||
"category_added": "Контакт добавлен в {name}",
|
||||
"category_added_plural": "{count} контактов добавлено в {name}",
|
||||
"empty_state": "Контактов пока нет",
|
||||
@@ -1655,7 +1671,13 @@
|
||||
"moved": "Контакт перемещён в {name}",
|
||||
"moved_plural": "{count} контактов перемещено в {name}",
|
||||
"move_failed": "Не удалось переместить контакт",
|
||||
"address_book": "Адресная книга"
|
||||
"address_book": "Адресная книга",
|
||||
"rename": "Переименовать адресную книгу",
|
||||
"name_label": "Название адресной книги",
|
||||
"renamed": "Адресная книга переименована",
|
||||
"rename_failed": "Не удалось переименовать адресную книгу",
|
||||
"default": "По умолчанию",
|
||||
"manage": "Управление адресными книгами"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреса электронной почты",
|
||||
@@ -1870,6 +1892,7 @@
|
||||
"back_to_email": "Вернуться к почте",
|
||||
"back_to_month": "Вернуться к месяцу",
|
||||
"my_calendars": "Календари",
|
||||
"birthday_calendar": "Дни рождения",
|
||||
"mini_calendar_change": "Нажмите для смены месяца",
|
||||
"views": {
|
||||
"month": "Месяц",
|
||||
@@ -2007,7 +2030,9 @@
|
||||
"hover_preview_delay_500ms": "Задержка 0,5 секунды",
|
||||
"hover_preview_delay_1s": "Задержка 1 секунда",
|
||||
"hover_preview_delay_2s": "Задержка 2 секунды",
|
||||
"hover_preview_off": "Отключено"
|
||||
"hover_preview_off": "Отключено",
|
||||
"show_birthday_calendar": "Календарь дней рождения",
|
||||
"show_birthday_calendar_desc": "Показать виртуальный календарь с днями рождения из ваших контактов"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Понедельник",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,22 @@
|
||||
import type { NextConfig } from "next";
|
||||
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 = {
|
||||
output: "standalone",
|
||||
@@ -7,6 +24,10 @@ const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: import.meta.dirname,
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
||||
NEXT_PUBLIC_APP_VERSION: appVersion,
|
||||
},
|
||||
};
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.4.11",
|
||||
"version": "1.4.12",
|
||||
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
@@ -20,7 +20,7 @@ export function proxy(request: NextRequest) {
|
||||
`default-src 'self'`,
|
||||
`script-src ${scriptSrc}`,
|
||||
`style-src 'self' 'unsafe-inline'`,
|
||||
`img-src 'self' data: https:`,
|
||||
`img-src 'self' data: blob: https:`,
|
||||
`font-src 'self'`,
|
||||
`connect-src ${connectSrc}`,
|
||||
`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', {
|
||||
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 principal = data.data;
|
||||
set({
|
||||
|
||||
@@ -47,6 +47,7 @@ interface AuthState {
|
||||
checkAuth: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
syncIdentities: () => void;
|
||||
refreshIdentities: () => Promise<void>;
|
||||
getClientForAccount: (accountId: string) => JMAPClient | undefined;
|
||||
}
|
||||
|
||||
@@ -1513,6 +1514,18 @@ export const useAuthStore = create<AuthState>()(
|
||||
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) => {
|
||||
return clients.get(accountId);
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { debug } from '@/lib/debug';
|
||||
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
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');
|
||||
|
||||
const subscription: ICalSubscription = {
|
||||
id: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
||||
id: generateUUID(),
|
||||
url,
|
||||
calendarId: calendar.id,
|
||||
name,
|
||||
|
||||
+98
-5
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
export function getContactDisplayName(contact: ContactCard): string {
|
||||
if (contact.name?.components) {
|
||||
@@ -81,6 +82,8 @@ interface ContactStore {
|
||||
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
|
||||
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => 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>;
|
||||
}
|
||||
@@ -155,8 +158,33 @@ export const useContactStore = create<ContactStore>()(
|
||||
createContact: async (client, contact) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const accountId = contact.isShared ? contact.accountId : undefined;
|
||||
const created = await client.createContact(contact, accountId);
|
||||
// Determine target account from the selected address book
|
||||
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
|
||||
if (contact.isShared && 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 originalId = contact?.originalId || id;
|
||||
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) => ({
|
||||
contacts: state.contacts.map(c =>
|
||||
c.id === id ? { ...c, ...updates } : c
|
||||
@@ -339,7 +381,7 @@ export const useContactStore = create<ContactStore>()(
|
||||
set((state) => ({ contacts: [...state.contacts, created] }));
|
||||
} else {
|
||||
const localGroup: ContactCard = {
|
||||
id: `local-${crypto.randomUUID()}`,
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: {},
|
||||
...groupData,
|
||||
} 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) => {
|
||||
const { supportsSync } = get();
|
||||
let imported = 0;
|
||||
@@ -578,7 +671,7 @@ export const useContactStore = create<ContactStore>()(
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
...contact,
|
||||
id: `local-${crypto.randomUUID()}`,
|
||||
id: `local-${generateUUID()}`,
|
||||
};
|
||||
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
|
||||
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;
|
||||
|
||||
|
||||
+21
-3
@@ -234,7 +234,17 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
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) {
|
||||
set({
|
||||
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) => {
|
||||
const { client, resources, refresh } = get();
|
||||
const { client, resources, recentFiles, refresh } = get();
|
||||
if (!client) return;
|
||||
|
||||
const resource = resources.find(r => r.name === name);
|
||||
@@ -428,11 +438,15 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
deleteResources: async (names: string[]) => {
|
||||
const { client, resources, refresh } = get();
|
||||
const { client, resources, recentFiles, refresh } = get();
|
||||
if (!client) return;
|
||||
|
||||
const idsToDelete: string[] = [];
|
||||
@@ -457,7 +471,11 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
if (idsToDelete.length === 0) return;
|
||||
|
||||
await client.destroyFileNodes(idsToDelete);
|
||||
const deletedIdSet = new Set(idsToDelete);
|
||||
const nextRecentFiles = recentFiles.filter(r => !deletedIdSet.has(r.id));
|
||||
set({ selectedResources: new Set() });
|
||||
set({ recentFiles: nextRecentFiles });
|
||||
try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ }
|
||||
await refresh();
|
||||
},
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
status: 'installed',
|
||||
managed: false,
|
||||
forceEnabled: false,
|
||||
adminApproved: false, // Requires admin approval before it can be enabled
|
||||
settings: existing?.settings ?? {},
|
||||
settingsSchema: manifest.settingsSchema,
|
||||
};
|
||||
@@ -144,6 +145,11 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
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)
|
||||
setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
|
||||
setSlotRegistrationBridge(get().registerSlot);
|
||||
@@ -393,6 +399,7 @@ async function syncServerPlugins(
|
||||
status: sp.forceEnabled ? 'enabled' : 'installed',
|
||||
managed: true,
|
||||
forceEnabled: sp.forceEnabled,
|
||||
adminApproved: true, // Server-managed plugins are always approved
|
||||
settings: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ interface PolicyState {
|
||||
getForcedThemeId: (availableThemeIds?: string[]) => string | null;
|
||||
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
|
||||
isPluginForceEnabled: (pluginId: string) => boolean;
|
||||
isPluginApproved: (pluginId: string) => boolean;
|
||||
isThemeForceEnabled: (themeId: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -84,6 +85,10 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
||||
return (get().policy.forceEnabledPlugins || []).includes(pluginId);
|
||||
},
|
||||
|
||||
isPluginApproved: (pluginId) => {
|
||||
return (get().policy.approvedPlugins || []).includes(pluginId);
|
||||
},
|
||||
|
||||
isThemeForceEnabled: (themeId) => {
|
||||
return (get().policy.forceEnabledThemes || []).includes(themeId);
|
||||
},
|
||||
|
||||
@@ -153,6 +153,10 @@ interface SettingsState {
|
||||
enableCalendarTasks: boolean;
|
||||
showTasksOnCalendar: boolean;
|
||||
|
||||
// Contact Birthday Calendar
|
||||
showBirthdayCalendar: boolean;
|
||||
birthdayCalendarColor: string;
|
||||
|
||||
// Email Notifications
|
||||
emailNotificationsEnabled: boolean;
|
||||
emailNotificationSound: boolean;
|
||||
@@ -278,6 +282,10 @@ const DEFAULT_SETTINGS = {
|
||||
enableCalendarTasks: false,
|
||||
showTasksOnCalendar: true,
|
||||
|
||||
// Contact Birthday Calendar
|
||||
showBirthdayCalendar: false,
|
||||
birthdayCalendarColor: '#eab308',
|
||||
|
||||
// Email Notifications
|
||||
emailNotificationsEnabled: true,
|
||||
emailNotificationSound: true,
|
||||
@@ -392,6 +400,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
|
||||
enableCalendarTasks: state.enableCalendarTasks,
|
||||
showTasksOnCalendar: state.showTasksOnCalendar,
|
||||
showBirthdayCalendar: state.showBirthdayCalendar,
|
||||
birthdayCalendarColor: state.birthdayCalendarColor,
|
||||
expandedFilterView: state.expandedFilterView,
|
||||
showTimeInMonthView: state.showTimeInMonthView,
|
||||
showWeekNumbers: state.showWeekNumbers,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from '@/lib/smime/types';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import {
|
||||
saveKeyRecord,
|
||||
listKeyRecords,
|
||||
@@ -287,7 +288,7 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
const email = info.emailAddresses[0] ?? '';
|
||||
|
||||
const publicCert: SmimePublicCert = {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
accountId: get().currentAccountId ?? undefined,
|
||||
email: email.toLowerCase(),
|
||||
certificate: der,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
importTemplates as importUtil,
|
||||
filterTemplates,
|
||||
} from '@/lib/template-utils';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
const MAX_RECENT = 5;
|
||||
|
||||
@@ -39,7 +40,7 @@ export const useTemplateStore = create<TemplateStore>()(
|
||||
const now = new Date().toISOString();
|
||||
const template: EmailTemplate = {
|
||||
...data,
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -73,8 +74,9 @@ export const useTemplateStore = create<TemplateStore>()(
|
||||
const now = new Date().toISOString();
|
||||
const duplicate: EmailTemplate = {
|
||||
...original,
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
name: `${original.name} ${nameSuffix || '(copy)'}`,
|
||||
|
||||
isFavorite: false,
|
||||
createdAt: 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