Compare commits

..
55 Commits
Author SHA1 Message Date
Linus Rath 9c5daa3918 chore: update version to 1.4.12 2026-04-09 20:08:03 +02:00
Linus Rath 23870801fc chore: update version to 1.4.12 2026-04-09 20:04:06 +02:00
Davi MacielandLinus Rath ae793551ba fix(email-list): show recipient in Sent and Drafts folders
When viewing the Sent or Drafts mailbox, the list items always
displayed the sender (email.from[0]) — which is always the logged-in
user — instead of the recipient. This makes it impossible to
identify messages by who they were sent to.

This change detects the current mailbox role from the store and
swaps the displayed person to email.to[0] when the role is 'sent'
or 'drafts'. For multi-email threads, participant names are
computed from the collected recipients of the thread's emails.

Affected components:
- EmailListItem (flat list, threading disabled)
- SingleEmailItem (single-email thread)
- ThreadListItem (multi-email thread header + avatar)

This matches the behaviour of Gmail, Outlook, Apple Mail, and
every other mainstream mail client.
2026-04-09 19:52:09 +02:00
Linus Rath f19aaf6207 fix: embed dropped images as data URLs and prevent duplicate attachment #163 2026-04-09 19:50:42 +02:00
Linus Rath d0cc439fd1 feat: add automatic identity synchronization (#167) 2026-04-09 19:39:04 +02:00
Linus Rath f42f57b8d0 fix: preserve search filters on push-triggered mailbox refresh #164 2026-04-09 19:10:34 +02:00
Linus Rath 0d4b588fd0 feat: add Latvian (lv) locale support 2026-04-08 22:42:56 +02:00
Linus Rath 6a725dde58 feat: add Latvian (lv) locale support 2026-04-08 22:42:15 +02:00
Linus Rath 6499da6281 feat: sync mail view to browser history for back/forward navigation 2026-04-08 22:20:36 +02:00
Linus Rath b2379fb03f fix: align shared account folders with primary folders #151 2026-04-08 13:45:31 +02:00
Linus Rath b29e71124d fix: prune recent files against server nodes on refresh #146 2026-04-08 13:22:51 +02:00
Linus Rath 63593e2146 fix: use fetch param type instead of RequestInit 2026-04-08 13:18:07 +02:00
Linus Rath 10cbe7a637 fix: stream WebDAV PUT uploads to avoid buffering in memory #162 2026-04-08 13:16:28 +02:00
Linus Rath 79418f013a fix: allow blob images in CSP for inline drag-and-drop #163 2026-04-08 13:10:04 +02:00
Linus Rath 4024732696 fix: handle 403 on principal fetch without console error 2026-04-08 13:08:46 +02:00
Linus Rath ffad3ea78b feat: Ability to rename address book #152 2026-04-08 13:05:42 +02:00
Linus Rath a9b9aeb44d fix: clamp Feb 29 birthdays in non-leap years #163 2026-04-08 12:47:24 +02:00
Linus Rath 3d76fe6d75 fix: focused mode expanding to the right of the screen #156 2026-04-08 12:43:48 +02:00
Linus RathandGitHub 58cafe28ad Update CONTRIBUTING.md 2026-04-07 10:17:30 +02:00
Linus RathandGitHub 60f9a3dc4d Update CONTRIBUTING.md 2026-04-07 02:28:06 +02:00
Linus RathandGitHub 11d9db5580 Update CONTRIBUTING.md 2026-04-07 02:27:19 +02:00
lingxhandLinus Rath 05d3f0b469 add zh_cn 2026-04-07 01:11:04 +02:00
Sienna Meridian SatterwhiteandLinus Rath c3f60448ad feat: configurable OAuth scopes and cookie security via env vars
Add three environment variables for deployments with external identity
providers (Keycloak, Authentik, Ory Hydra, etc.):

- OAUTH_EXTRA_SCOPES: append additional scopes to the default
  "openid email profile" (e.g. "offline_access" for refresh tokens)
- OAUTH_SCOPES: full override of the requested OAuth scopes
- COOKIE_SECURE: override the Secure flag on auth cookies (useful
  for reverse proxy setups where the internal hop is HTTP)

Without these, deploying Bulwark with an external OIDC provider that
requires `offline_access` for refresh tokens is impossible — sessions
die on every page refresh because no refresh token is issued.

All three are backwards-compatible: unset = identical to current behavior.
2026-04-06 23:22:06 +02:00
p.kwiatkowskiandLinus Rath 523711cca3 feat: add Polish language support 2026-04-06 23:20:25 +02:00
Linus Rath bcb487810a feat: add Korean language support 2026-04-05 16:46:17 +02:00
rldn12134andLinus Rath 7e1d644f7d feat: Add Korean translation file 2026-04-05 13:51:22 +02:00
Linus Rath 4aac65d7d2 feat: add birthday calendar feature with settings and localization 2026-04-03 22:45:07 +02:00
Linus Rath 14ecae61dc feat: enhance error handling in Stalwart API responses 2026-04-03 15:10:11 +02:00
Linus Rath c53ff5a30a fix: file deletion logic to update recent files and handle errors #146 2026-04-03 14:20:06 +02:00
Linus Rath 5aad97d64e feat: add PWA support with service worker and install prompt 2026-04-03 04:39:19 +02:00
Linus Rath 081c865018 fix: guard against undefined trigger in calendar event alert popover #143 2026-04-03 03:15:55 +02:00
Linus Rath 050f38b1fa fix: use dynamic batch size for calendar event retrieval 2026-04-02 23:29:37 +02:00
Linus Rath d657bdfa75 fix: improve calendar event retrieval by batching requests to avoid server limits #141 2026-04-02 22:55:07 +02:00
Linus Rath bbf724d9e1 fix: correct logic for marking email as read in EmailViewer component 2026-04-02 22:50:04 +02:00
Linus Rath 9d8c6044e3 fix: optimize htmlToPlainText function using DOMParser 2026-04-02 17:32:53 +02:00
Linus Rath 2d17ca71e3 fix: replace random ID generation with generateUUID() 2026-04-02 17:29:00 +02:00
Linus Rath d77dd1e3e1 fix: replace non-UUID fallback 2026-04-02 17:25:10 +02:00
Linus Rath 9d97b74684 fix: adjust max width of calendar invitation banner for better layout 2026-04-02 14:56:04 +02:00
Linus Rath c4673acb65 fix: replace unguarded crypto.randomUUID() with safe generateUUID() utility 2026-04-02 14:51:21 +02:00
Linus Rath 1bdc51dcc7 feat: implement version badge and easter egg in settings 2026-04-02 14:31:25 +02:00
Linus Rath 6ee0f6a40a feat: disable plugins by default, require admin approval 2026-04-02 13:50:02 +02:00
Linus Rath 9ee25c930e fix: compute per-occurrence utcStart/utcEnd in recurrence expansion #116 2026-04-02 13:35:26 +02:00
Linus Rath 1b2c70a90b fix: de-namespace addressBookIds for shared contacts in create and update operations #133 2026-04-02 11:34:26 +02:00
Linus Rath cdea876992 fix: fetch mailboxes on mount in FolderSettings when store is empty 2026-04-02 11:26:21 +02:00
Linus Rath 16557830ae fix: mailbox deletion error handling 2026-04-02 11:24:11 +02:00
Linus Rath 8836f9cdca fix: wrap handleArchive call to prevent MouseEvent leaking as argument 2026-04-02 11:19:36 +02:00
Linus Rath 26bfe9cb6c fix: extend file drop zone to fill remaining viewport height 2026-04-02 11:14:38 +02:00
Linus Rath 05eaaad61f fix: fallback to application/octet-stream for long MIME types in createFileNode 2026-04-02 11:04:59 +02:00
Linus Rath 2e0852b272 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-02 10:57:03 +02:00
Linus Rath 6173a9ad13 fix: resolve settings sync Identity mismatch for OAuth/SSO sessions #127 2026-04-02 10:54:35 +02:00
Linus RathandGitHub e3560d9cb4 Add funding configuration for Bulwark Webmail 2026-04-02 00:37:58 +02:00
Niklas VossandLinus Rath 52326326e2 fix: check plugin http.post url against origin and add regression tests 2026-04-01 11:26:12 +02:00
Niklas VossandLinus Rath 2734fa08b7 feat: instead of exposing auth headers, offer a http proxy api for plugins 2026-04-01 11:26:12 +02:00
Niklas VossandLinus Rath 67a0d622bc feat: add auth:read permission and ability to retrieve auth headers 2026-04-01 11:26:12 +02:00
chrilepandGitHub 58968a1cbf updated to show latest version in badge 2026-04-01 09:49:23 +02:00
101 changed files with 12911 additions and 343 deletions
+4
View File
@@ -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]
+54
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.4.10-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.4.12-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
+1 -1
View File
@@ -1 +1 @@
1.4.11
1.4.12
+42 -11
View File
@@ -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)}
+49 -2
View File
@@ -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">
+1 -1
View File
@@ -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">
+40 -8
View File
@@ -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
View File
@@ -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}
+2 -1
View File
@@ -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 />}
+21
View File
@@ -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">
+17 -2
View File
@@ -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 }
);
}
+17 -2
View File
@@ -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 }
);
}
+17 -2
View File
@@ -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 }
);
}
+21 -5
View File
@@ -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
View File
@@ -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') {
+21
View File
@@ -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 */}
+1 -1
View File
@@ -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$/);
+2 -3
View File
@@ -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,
+2 -5
View File
@@ -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];
}
}
+111 -28
View File
@@ -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}
+57 -56
View File
@@ -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">
+14 -14
View File
@@ -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;
+1 -1
View File
@@ -94,7 +94,7 @@ export function EmailHoverActions({
onToggleStar?.();
break;
case "markRead":
onMarkAsRead?.(!isUnread);
onMarkAsRead?.(isUnread);
break;
case "archive":
onArchive?.();
+5 -2
View File
@@ -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}` : '';
+24 -5
View File
@@ -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');
}
}}
+12
View File
@@ -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);
+1
View File
@@ -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) {
+19 -7
View File
@@ -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(
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -16,7 +16,7 @@ import type {
FilterActionType,
} from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from "@/lib/utils";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
interface FilterRuleModalProps {
rule?: FilterRule;
@@ -109,7 +109,7 @@ export function FilterRuleModal({
}
onSave({
id: rule?.id || crypto.randomUUID(),
id: rule?.id || generateUUID(),
name: trimmedName,
enabled: rule?.enabled ?? true,
matchType,
@@ -14,6 +14,10 @@ function useSyncIdentities() {
const syncIdentities = useAuthStore((state) => state.syncIdentities);
return syncIdentities;
}
function useRefreshIdentities() {
return useAuthStore((state) => state.refreshIdentities);
}
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
const syncIdentities = useSyncIdentities();
const refreshIdentitiesFromServer = useRefreshIdentities();
// Refresh identities from server whenever the modal is opened
useEffect(() => {
if (isOpen) {
refreshIdentitiesFromServer();
}
}, [isOpen, refreshIdentitiesFromServer]);
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
+2 -2
View File
@@ -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]);
-5
View File
@@ -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;
}
+8
View File
@@ -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 {
+88
View File
@@ -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>
</>
);
}
+58
View File
@@ -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>
</>
);
}
+11
View File
@@ -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
+19 -3
View File
@@ -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);
}
+28 -5
View File
@@ -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]);
+387
View File
@@ -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>
);
}
+5 -1
View File
@@ -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 (
+157
View File
@@ -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]);
}
+31
View File
@@ -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
View File
@@ -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
View File
@@ -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'
});
+43
View File
@@ -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' });
});
});
+106
View File
@@ -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
View File
@@ -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: [],
};
+207
View File
@@ -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;
}
+2 -3
View File
@@ -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',
+5
View File
@@ -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];
+1
View File
@@ -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
View File
@@ -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: [],
};
}
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 -8
View File
@@ -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) => {
+3
View File
@@ -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',
+37
View File
@@ -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,
+4 -2
View File
@@ -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));
}
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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'`,
+52
View File
@@ -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"
}
]
}
+26
View File
@@ -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));
})(),
);
});
+14 -1
View File
@@ -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({
+13
View File
@@ -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);
},
+2 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
},
+7
View File
@@ -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: {},
};
+5
View File
@@ -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);
},
+10
View File
@@ -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,
+2 -1
View File
@@ -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,
+4 -2
View File
@@ -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