diff --git a/app/api/admin/oauth/setup/route.ts b/app/api/admin/oauth/setup/route.ts
index b4cde06a..b1c63851 100644
--- a/app/api/admin/oauth/setup/route.ts
+++ b/app/api/admin/oauth/setup/route.ts
@@ -73,10 +73,15 @@ function buildRedirectUris(origin: string, localeList: readonly string[]): Recor
interface SetupRequestBody {
origin?: string;
+ issuerUrl?: string;
locales?: string[];
oauthOnly?: boolean;
}
+function isValidOriginUrl(value: string): boolean {
+ return /^https?:\/\/[^/]+$/.test(value);
+}
+
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth();
@@ -93,9 +98,16 @@ export async function POST(request: NextRequest) {
const body = await request.json() as SetupRequestBody;
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
- if (!/^https?:\/\/[^/]+$/.test(origin)) {
+ if (!isValidOriginUrl(origin)) {
return NextResponse.json(
- { error: 'Origin must be a URL like "https://mail.example.com" with no path.' },
+ { error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
+ { status: 400 },
+ );
+ }
+ const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
+ if (!isValidOriginUrl(issuerUrl)) {
+ return NextResponse.json(
+ { error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
{ status: 400 },
);
}
@@ -201,7 +213,7 @@ export async function POST(request: NextRequest) {
oauthEnabled: true,
oauthClientId: CLIENT_ID,
oauthClientSecret: secret,
- oauthIssuerUrl: origin,
+ oauthIssuerUrl: issuerUrl,
};
if (oauthOnly) updates.oauthOnly = true;
await configManager.setAdminConfig(updates);
@@ -209,7 +221,8 @@ export async function POST(request: NextRequest) {
await auditLog('admin.oauth_setup', {
action,
clientId: CLIENT_ID,
- issuer: origin,
+ origin,
+ issuer: issuerUrl,
redirectUriCount: localeList.length,
oauthOnly,
}, ip);
@@ -217,7 +230,8 @@ export async function POST(request: NextRequest) {
logger.info('Admin OAuth setup', {
action,
clientId: CLIENT_ID,
- issuer: origin,
+ origin,
+ issuer: issuerUrl,
locales: localeList.length,
});
@@ -225,7 +239,8 @@ export async function POST(request: NextRequest) {
ok: true,
action,
clientId: CLIENT_ID,
- issuerUrl: origin,
+ origin,
+ issuerUrl,
redirectUriCount: localeList.length,
});
} catch (error) {
From b80678b00f750a57400a801bdf30d68dda8e9f57 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 25 Apr 2026 03:11:16 +0200
Subject: [PATCH 12/27] feat: add 'Today' button to desktop calendar toolbar
---
components/calendar/calendar-toolbar.tsx | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx
index 7e458bfe..89710a93 100644
--- a/components/calendar/calendar-toolbar.tsx
+++ b/components/calendar/calendar-toolbar.tsx
@@ -260,6 +260,9 @@ export function CalendarToolbar({
{/* ── DESKTOP TOOLBAR ── */}
{!isMobile && (
+
+ {t("views.today")}
+
@@ -279,14 +282,14 @@ export function CalendarToolbar({
{!isMobile && (
-
+
{views.map((v) => (
onViewModeChange(v)}
title={t(`views.${v}_hint`)}
className={cn(
- "px-3 py-1.5 text-xs font-medium transition-colors",
+ "inline-flex items-center px-3 text-xs font-medium transition-colors",
v === viewMode
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground"
@@ -300,7 +303,7 @@ export function CalendarToolbar({
{(onImport || onSubscribe) && !isMobile && (
-
setShowImportDropdown((v) => !v)}>
+ setShowImportDropdown((v) => !v)}>
{t("import.title")}
@@ -331,7 +334,7 @@ export function CalendarToolbar({
)}
{!isMobile && (
-
+
{t("events.create")}
From 4788e8a91a8424b7a3ff3f415650cd5f116b8846 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 25 Apr 2026 16:26:05 +0200
Subject: [PATCH 13/27] feat: add right-click context menu to mail folders
sidebar
---
app/[locale]/page.tsx | 190 +++++++++++++++++++++
components/layout/mailbox-context-menu.tsx | 171 +++++++++++++++++++
components/layout/sidebar.tsx | 65 ++++++-
lib/demo/demo-client.ts | 27 +++
lib/jmap/client-interface.ts | 2 +
lib/jmap/client.ts | 93 ++++++++++
locales/de/common.json | 34 ++++
locales/en/common.json | 34 ++++
locales/es/common.json | 34 ++++
locales/fr/common.json | 34 ++++
locales/it/common.json | 34 ++++
locales/ja/common.json | 34 ++++
locales/ko/common.json | 34 ++++
locales/lv/common.json | 34 ++++
locales/nl/common.json | 34 ++++
locales/pl/common.json | 34 ++++
locales/pt/common.json | 34 ++++
locales/ru/common.json | 34 ++++
locales/uk/common.json | 34 ++++
locales/zh/common.json | 34 ++++
stores/email-store.ts | 34 ++++
21 files changed, 1057 insertions(+), 1 deletion(-)
create mode 100644 components/layout/mailbox-context-menu.tsx
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 060e4603..90ddd3f2 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -14,6 +14,7 @@ import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store";
+import { toast } from "@/stores/toast-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
@@ -162,6 +163,11 @@ export default function Home() {
fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts,
exitUnifiedView,
+ emptyMailbox,
+ markMailboxAsRead,
+ createMailbox,
+ renameMailbox,
+ deleteMailbox,
} = useEmailStore();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
@@ -1097,6 +1103,181 @@ export default function Home() {
}
};
+ const tCtxMenu = t;
+
+ const handleMarkFolderRead = async (mailboxId: string) => {
+ if (!client) return;
+ try {
+ const count = await markMailboxAsRead(client, mailboxId);
+ await fetchMailboxes(client);
+ if (selectedMailbox === mailboxId) await fetchEmails(client, mailboxId);
+ if (count > 0) {
+ toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count }));
+ } else {
+ toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
+ }
+ } catch {
+ toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
+ }
+ };
+
+ const handleMarkFolderTreeRead = async (mailboxId: string) => {
+ if (!client) return;
+ const collectIds = (rootId: string): string[] => {
+ const ids: string[] = [rootId];
+ const stack = [rootId];
+ while (stack.length > 0) {
+ const current = stack.pop()!;
+ for (const mb of mailboxes) {
+ if (mb.parentId === current) {
+ ids.push(mb.id);
+ stack.push(mb.id);
+ }
+ }
+ }
+ return ids;
+ };
+
+ try {
+ const ids = collectIds(mailboxId);
+ let total = 0;
+ for (const id of ids) {
+ total += await markMailboxAsRead(client, id);
+ }
+ await fetchMailboxes(client);
+ if (selectedMailbox && ids.includes(selectedMailbox)) await fetchEmails(client, selectedMailbox);
+ if (total > 0) {
+ toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
+ } else {
+ toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
+ }
+ } catch {
+ toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
+ }
+ };
+
+ const handleMarkAllFoldersRead = async () => {
+ if (!client) return;
+
+ const confirmed = await confirmDialog({
+ title: tCtxMenu('mailbox_context_menu.mark_all_confirm_title'),
+ message: tCtxMenu('mailbox_context_menu.mark_all_confirm_message'),
+ confirmText: tCtxMenu('mailbox_context_menu.mark_all_folders_read'),
+ variant: "default",
+ });
+ if (!confirmed) return;
+
+ try {
+ const total = await client.markAllAsRead();
+ await fetchMailboxes(client);
+ if (selectedMailbox) await fetchEmails(client, selectedMailbox);
+ if (total > 0) {
+ toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
+ } else {
+ toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
+ }
+ } catch {
+ toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
+ }
+ };
+
+ const handleEmptyFolderFromContextMenu = async (mailboxId: string) => {
+ if (!client) return;
+ const mailbox = mailboxes.find(mb => mb.id === mailboxId);
+ if (!mailbox) return;
+
+ const confirmed = await confirmDialog({
+ title: tCtxMenu('email_list.empty_folder.confirm_title'),
+ message: tCtxMenu('email_list.empty_folder.confirm_message'),
+ confirmText: tCtxMenu('email_list.empty_folder.confirm_button'),
+ variant: "destructive",
+ });
+ if (!confirmed) return;
+
+ try {
+ await emptyMailbox(client, mailboxId);
+ toast.success(tCtxMenu('mailbox_context_menu.toast_emptied'));
+ } catch {
+ toast.error(tCtxMenu('mailbox_context_menu.toast_error_empty'));
+ }
+ };
+
+ const handleCreateSubfolderFromContextMenu = async (parentId: string) => {
+ if (!client) return;
+ const name = window.prompt(tCtxMenu('mailbox_context_menu.prompt_new_subfolder'));
+ if (!name || !name.trim()) return;
+ try {
+ await createMailbox(client, name.trim(), parentId);
+ toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
+ } catch {
+ toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
+ }
+ };
+
+ const handleCreateFolderFromContextMenu = async () => {
+ if (!client) return;
+ const name = window.prompt(tCtxMenu('mailbox_context_menu.prompt_new_folder'));
+ if (!name || !name.trim()) return;
+ try {
+ await createMailbox(client, name.trim());
+ toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
+ } catch {
+ toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
+ }
+ };
+
+ const handleRenameFolderFromContextMenu = async (mailboxId: string) => {
+ if (!client) return;
+ const mailbox = mailboxes.find(mb => mb.id === mailboxId);
+ if (!mailbox) return;
+ const name = window.prompt(tCtxMenu('mailbox_context_menu.prompt_rename'), mailbox.name);
+ if (!name || !name.trim() || name.trim() === mailbox.name) return;
+ try {
+ await renameMailbox(client, mailboxId, name.trim());
+ toast.success(tCtxMenu('mailbox_context_menu.toast_folder_renamed'));
+ } catch {
+ toast.error(tCtxMenu('mailbox_context_menu.toast_error_rename'));
+ }
+ };
+
+ const handleDeleteFolderFromContextMenu = async (mailboxId: string) => {
+ if (!client) return;
+ const mailbox = mailboxes.find(mb => mb.id === mailboxId);
+ if (!mailbox) return;
+
+ const confirmed = await confirmDialog({
+ title: tCtxMenu('mailbox_context_menu.delete_confirm_title'),
+ message: tCtxMenu('mailbox_context_menu.delete_confirm_message', { name: mailbox.name }),
+ confirmText: tCtxMenu('mailbox_context_menu.delete_folder'),
+ variant: "destructive",
+ });
+ if (!confirmed) return;
+
+ try {
+ await deleteMailbox(client, mailboxId);
+ toast.success(tCtxMenu('mailbox_context_menu.toast_folder_deleted'));
+ } catch (err: unknown) {
+ const jmapType = (err as Error & { jmapType?: string })?.jmapType;
+ if (jmapType === 'mailboxHasChild') {
+ toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_children'));
+ } else if (jmapType === 'mailboxHasEmail') {
+ toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_email'));
+ } else {
+ toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete'));
+ }
+ }
+ };
+
+ const handleRefreshMailboxes = async () => {
+ if (!client) return;
+ try {
+ await fetchMailboxes(client);
+ if (selectedMailbox) await fetchEmails(client, selectedMailbox);
+ } catch {
+ // silent
+ }
+ };
+
const handleLogout = logout;
const handleSearch = async (query: string) => {
@@ -1454,6 +1635,15 @@ export default function Home() {
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick}
+ onMarkFolderRead={handleMarkFolderRead}
+ onMarkFolderTreeRead={handleMarkFolderTreeRead}
+ onMarkAllFoldersRead={handleMarkAllFoldersRead}
+ onEmptyFolder={handleEmptyFolderFromContextMenu}
+ onCreateSubfolder={handleCreateSubfolderFromContextMenu}
+ onCreateFolder={handleCreateFolderFromContextMenu}
+ onRenameFolder={handleRenameFolderFromContextMenu}
+ onDeleteFolder={handleDeleteFolderFromContextMenu}
+ onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx
new file mode 100644
index 00000000..b6f48768
--- /dev/null
+++ b/components/layout/mailbox-context-menu.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Mailbox } from "@/lib/jmap/types";
+import {
+ ContextMenu,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuHeader,
+} from "@/components/ui/context-menu";
+import {
+ CheckCheck,
+ MailOpen,
+ Mails,
+ Trash2,
+ FolderPlus,
+ Pencil,
+ FolderX,
+ RefreshCw,
+} from "lucide-react";
+
+interface Position {
+ x: number;
+ y: number;
+}
+
+export type MailboxContextTarget =
+ | { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
+ | { kind: "folders-section" };
+
+interface MailboxContextMenuProps {
+ target: MailboxContextTarget | null;
+ position: Position;
+ isOpen: boolean;
+ onClose: () => void;
+ menuRef: React.RefObject;
+ onMarkFolderRead?: (mailboxId: string) => void;
+ onMarkFolderTreeRead?: (mailboxId: string) => void;
+ onMarkAllFoldersRead?: () => void;
+ onEmptyFolder?: (mailboxId: string) => void;
+ onCreateSubfolder?: (parentId: string) => void;
+ onCreateFolder?: () => void;
+ onRenameFolder?: (mailboxId: string) => void;
+ onDeleteFolder?: (mailboxId: string) => void;
+ onRefresh?: () => void;
+}
+
+export function MailboxContextMenu({
+ target,
+ position,
+ isOpen,
+ onClose,
+ menuRef,
+ onMarkFolderRead,
+ onMarkFolderTreeRead,
+ onMarkAllFoldersRead,
+ onEmptyFolder,
+ onCreateSubfolder,
+ onCreateFolder,
+ onRenameFolder,
+ onDeleteFolder,
+ onRefresh,
+}: MailboxContextMenuProps) {
+ const t = useTranslations("mailbox_context_menu");
+
+ const handleAction = (action: () => void) => {
+ action();
+ onClose();
+ };
+
+ if (!target) return null;
+
+ if (target.kind === "folders-section") {
+ return (
+
+ handleAction(onMarkAllFoldersRead!)}
+ disabled={!onMarkAllFoldersRead}
+ />
+
+ handleAction(onCreateFolder!)}
+ disabled={!onCreateFolder}
+ />
+ handleAction(onRefresh!)}
+ disabled={!onRefresh}
+ />
+
+ );
+ }
+
+ const mailbox = target.mailbox;
+ const isTrashOrJunk = mailbox.role === "trash" || mailbox.role === "junk";
+ const isSystem =
+ !!mailbox.role &&
+ ["inbox", "sent", "drafts", "trash", "junk", "archive"].includes(mailbox.role);
+ const canRename = mailbox.myRights?.mayRename !== false && !isSystem;
+ const canDelete = mailbox.myRights?.mayDelete !== false && !isSystem;
+ const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
+ const canSetSeen = mailbox.myRights?.maySetSeen !== false;
+ const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
+
+ return (
+
+ {mailbox.name}
+
+ handleAction(() => onMarkFolderRead?.(mailbox.id))}
+ disabled={!onMarkFolderRead || !canSetSeen}
+ />
+ {target.hasChildren && (
+ handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
+ disabled={!onMarkFolderTreeRead || !canSetSeen}
+ />
+ )}
+
+
+
+ handleAction(() => onCreateSubfolder?.(mailbox.id))}
+ disabled={!onCreateSubfolder || !canCreateChild}
+ />
+ handleAction(() => onRenameFolder?.(mailbox.id))}
+ disabled={!onRenameFolder || !canRename}
+ />
+
+
+
+ handleAction(() => onEmptyFolder?.(mailbox.id))}
+ disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
+ destructive
+ />
+ handleAction(() => onDeleteFolder?.(mailbox.id))}
+ disabled={!onDeleteFolder || !canDelete}
+ destructive
+ />
+
+
+
+ handleAction(onRefresh!)}
+ disabled={!onRefresh}
+ />
+
+ );
+}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 73d34b20..a8fd95b6 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -31,6 +31,8 @@ import {
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
+import { useContextMenu } from "@/hooks/use-context-menu";
+import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store';
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
@@ -56,6 +58,15 @@ interface SidebarProps {
onCompose?: () => void;
onSidebarClose?: () => void;
onUnreadFilterClick?: (mailboxId: string) => void;
+ onMarkFolderRead?: (mailboxId: string) => void;
+ onMarkFolderTreeRead?: (mailboxId: string) => void;
+ onMarkAllFoldersRead?: () => void;
+ onEmptyFolder?: (mailboxId: string) => void;
+ onCreateSubfolder?: (parentId: string) => void;
+ onCreateFolder?: () => void;
+ onRenameFolder?: (mailboxId: string) => void;
+ onDeleteFolder?: (mailboxId: string) => void;
+ onRefreshMailboxes?: () => void;
className?: string;
}
@@ -187,6 +198,7 @@ interface SidebarRowProps {
dropHandlers?: Record;
isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean;
+ onContextMenu?: (e: React.MouseEvent) => void;
}
function SidebarRow({
@@ -206,6 +218,7 @@ function SidebarRow({
dropHandlers,
isValidDropTarget,
isInvalidDropTarget,
+ onContextMenu,
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -213,6 +226,7 @@ function SidebarRow({
return (
void;
colorful: boolean;
+ onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
}) {
const tNotifications = useTranslations('notifications');
const hasChildren = node.children.length > 0;
@@ -423,6 +439,7 @@ function MailboxTreeItem({
dropHandlers={globalDragging ? (dropHandlers as Record
) : undefined}
isValidDropTarget={isValidDropTarget}
isInvalidDropTarget={isInvalidDropTarget}
+ onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
/>
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
@@ -436,6 +453,7 @@ function MailboxTreeItem({
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorful}
+ onContextMenu={onContextMenu}
/>
))}
>
@@ -610,6 +628,15 @@ export function Sidebar({
onCompose: _onCompose,
onSidebarClose,
onUnreadFilterClick,
+ onMarkFolderRead,
+ onMarkFolderTreeRead,
+ onMarkAllFoldersRead,
+ onEmptyFolder,
+ onCreateSubfolder,
+ onCreateFolder,
+ onRenameFolder,
+ onDeleteFolder,
+ onRefreshMailboxes,
className,
}: SidebarProps) {
const router = useRouter();
@@ -790,6 +817,23 @@ export function Sidebar({
router.push('/settings');
};
+ const {
+ contextMenu: mailboxContextMenu,
+ openContextMenu: openMailboxContextMenu,
+ closeContextMenu: closeMailboxContextMenu,
+ menuRef: mailboxMenuRef,
+ } = useContextMenu();
+
+ const handleMailboxContextMenu = (e: React.MouseEvent, node: MailboxNode) => {
+ const mailbox = mailboxes.find(mb => mb.id === node.id);
+ if (!mailbox) return;
+ openMailboxContextMenu(e, { kind: "mailbox", mailbox, hasChildren: node.children.length > 0 });
+ };
+
+ const handleFoldersHeaderContextMenu = (e: React.MouseEvent) => {
+ openMailboxContextMenu(e, { kind: "folders-section" });
+ };
+
return (
)}
-
+
))
)}
@@ -934,6 +979,7 @@ export function Sidebar({
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
+ onContextMenu={handleMailboxContextMenu}
/>
))}
@@ -975,6 +1021,23 @@ export function Sidebar({
{!isCollapsed &&
}
+
+
);
}
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index b312952a..83f36370 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -312,6 +312,33 @@ export class DemoJMAPClient implements IJMAPClient {
return removed;
}
+ async markMailboxAsRead(mailboxId: string): Promise {
+ let count = 0;
+ for (const email of this.data.emails) {
+ if (email.mailboxIds[mailboxId] && email.keywords.$seen !== true) {
+ email.keywords.$seen = true;
+ count++;
+ }
+ }
+ this.recalcMailboxCounts();
+ return count;
+ }
+
+ async markAllAsRead(excludeMailboxIds: string[] = []): Promise {
+ const excluded = new Set(excludeMailboxIds);
+ let count = 0;
+ for (const email of this.data.emails) {
+ if (email.keywords.$seen === true) continue;
+ const mbIds = Object.keys(email.mailboxIds);
+ const onlyInExcluded = mbIds.length > 0 && mbIds.every(id => excluded.has(id));
+ if (onlyInExcluded) continue;
+ email.keywords.$seen = true;
+ count++;
+ }
+ this.recalcMailboxCounts();
+ return count;
+ }
+
async markAsSpam(emailId: string): Promise {
const email = this.data.emails.find(e => e.id === emailId);
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index d09767bc..a05b19b7 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -94,6 +94,8 @@ export interface IJMAPClient {
): Promise;
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise;
emptyMailbox(mailboxId: string): Promise;
+ markMailboxAsRead(mailboxId: string, accountId?: string): Promise;
+ markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise;
markAsSpam(emailId: string, accountId?: string): Promise;
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise;
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 5dfd713f..9ad44504 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -1361,6 +1361,99 @@ export class JMAPClient implements IJMAPClient {
return totalDestroyed;
}
+ async markMailboxAsRead(mailboxId: string, accountId?: string): Promise {
+ const targetAccountId = accountId || this.accountId;
+ let totalMarked = 0;
+ let hasMore = true;
+
+ while (hasMore) {
+ const queryResponse = await this.request([
+ ["Email/query", {
+ accountId: targetAccountId,
+ filter: {
+ operator: "AND",
+ conditions: [
+ { inMailbox: mailboxId },
+ { notKeyword: "$seen" },
+ ],
+ },
+ limit: 500,
+ }, "0"],
+ ]);
+
+ const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
+ if (ids.length === 0) break;
+
+ const updates = Object.fromEntries(
+ ids.map((id) => [id, { "keywords/$seen": true }])
+ );
+
+ await this.request([
+ ["Email/set", { accountId: targetAccountId, update: updates }, "0"],
+ ]);
+
+ totalMarked += ids.length;
+ hasMore = ids.length === 500;
+ }
+
+ return totalMarked;
+ }
+
+ async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise {
+ const targetAccountId = accountId || this.accountId;
+ const excludeSet = new Set(excludeMailboxIds);
+ let totalMarked = 0;
+ let hasMore = true;
+ let position = 0;
+
+ while (hasMore) {
+ const response = await this.request([
+ ["Email/query", {
+ accountId: targetAccountId,
+ filter: { notKeyword: "$seen" },
+ limit: 500,
+ position,
+ }, "0"],
+ ["Email/get", {
+ accountId: targetAccountId,
+ "#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
+ properties: ["id", "mailboxIds"],
+ }, "1"],
+ ]);
+
+ const queryResult = response.methodResponses?.[0]?.[1];
+ const getResult = response.methodResponses?.[1]?.[1];
+ const ids: string[] = queryResult?.ids || [];
+ const emails: Array<{ id: string; mailboxIds?: Record }> = getResult?.list || [];
+
+ if (ids.length === 0) break;
+
+ const targetIds = excludeSet.size === 0
+ ? ids
+ : emails
+ .filter(e => {
+ const mbIds = e.mailboxIds ? Object.keys(e.mailboxIds) : [];
+ return mbIds.some(id => !excludeSet.has(id));
+ })
+ .map(e => e.id);
+
+ if (targetIds.length > 0) {
+ const updates = Object.fromEntries(
+ targetIds.map((id) => [id, { "keywords/$seen": true }])
+ );
+ await this.request([
+ ["Email/set", { accountId: targetAccountId, update: updates }, "0"],
+ ]);
+ totalMarked += targetIds.length;
+ }
+
+ hasMore = ids.length === 500;
+ position += ids.length;
+ }
+
+ return totalMarked;
+ }
+
async markAsSpam(emailId: string, accountId?: string): Promise {
const targetAccountId = accountId || this.accountId;
diff --git a/locales/de/common.json b/locales/de/common.json
index 9c97c162..129016cb 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Ordner als gelesen markieren",
+ "mark_folder_tree_read": "Ordner & Unterordner als gelesen markieren",
+ "mark_all_folders_read": "Alle Ordner als gelesen markieren",
+ "new_subfolder": "Neuer Unterordner...",
+ "new_folder": "Neuer Ordner...",
+ "rename": "Umbenennen...",
+ "empty_folder": "Ordner leeren",
+ "empty_folder_generic": "Ordner leeren",
+ "delete_folder": "Ordner löschen",
+ "refresh": "Aktualisieren",
+ "mark_all_confirm_title": "Alle Ordner als gelesen markieren",
+ "mark_all_confirm_message": "Jede ungelesene Nachricht in deinem persönlichen Konto als gelesen markieren?",
+ "delete_confirm_title": "Ordner löschen",
+ "delete_confirm_message": "Den Ordner \"{name}\" dauerhaft löschen? Dies kann nicht rückgängig gemacht werden.",
+ "prompt_new_subfolder": "Name des neuen Unterordners:",
+ "prompt_new_folder": "Name des neuen Ordners:",
+ "prompt_rename": "Ordner umbenennen in:",
+ "toast_marked_read": "Ordner als gelesen markiert",
+ "toast_marked_read_count": "{count, plural, one {1 Nachricht} other {# Nachrichten}} als gelesen markiert",
+ "toast_already_read": "Keine ungelesenen Nachrichten",
+ "toast_marked_all_read": "Alle Ordner als gelesen markiert",
+ "toast_emptied": "Ordner geleert",
+ "toast_folder_created": "Ordner erstellt",
+ "toast_folder_renamed": "Ordner umbenannt",
+ "toast_folder_deleted": "Ordner gelöscht",
+ "toast_error_mark_read": "Konnte nicht als gelesen markiert werden",
+ "toast_error_empty": "Ordner konnte nicht geleert werden",
+ "toast_error_create": "Ordner konnte nicht erstellt werden",
+ "toast_error_rename": "Ordner konnte nicht umbenannt werden",
+ "toast_error_delete": "Ordner konnte nicht gelöscht werden",
+ "toast_error_delete_has_children": "Ordner enthält Unterordner. Entferne diese zuerst.",
+ "toast_error_delete_has_email": "Ordner ist nicht leer. Leere ihn zuerst."
+ },
"shortcuts": {
"title": "Tastaturkürzel",
"tip": "Drücken Sie ? jederzeit, um diese Hilfe anzuzeigen",
diff --git a/locales/en/common.json b/locales/en/common.json
index 8df8f072..47777a38 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -1594,6 +1594,40 @@
"items_selected": "{count} emails selected",
"edit_draft": "Edit Draft"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "Keyboard Shortcuts",
"tip": "Press ? anytime to show this help",
diff --git a/locales/es/common.json b/locales/es/common.json
index 6d7d02b1..b9f6d301 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "Atajos de Teclado",
"tip": "Presione ? en cualquier momento para mostrar esta ayuda",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 0fe2adf0..83c8c8cc 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "{count} emails sélectionnés",
"edit_draft": "Modifier le brouillon"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "Raccourcis clavier",
"tip": "Appuyez sur ? à tout moment pour afficher cette aide",
diff --git a/locales/it/common.json b/locales/it/common.json
index 802c95b2..89f84974 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "Scorciatoie da tastiera",
"tip": "Premi ? in qualsiasi momento per mostrare questo aiuto",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index db6a9bb7..bb24ed51 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "{count}件のメールを選択",
"edit_draft": "下書きを編集"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "キーボードショートカット",
"tip": "? キーを押すといつでもこのヘルプを表示できます",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 813a50e5..5f14da62 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "단축키",
"tip": "언제든 ? 키를 누르면 이 도움말을 볼 수 있어요",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index c3c7fd68..cad72013 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "Īsinājumtaustiņi",
"tip": "Nospiediet ? jebkurā laikā, lai skatītu palīdzību",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index b8a90183..223d59a7 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "{count} e-mails geselecteerd",
"edit_draft": "Concept bewerken"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "Sneltoetsen",
"tip": "Druk op ? om deze hulp te tonen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 71604296..e1a644ac 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "{count} zaznaczonych wiadomości",
"edit_draft": "Edytuj szkic"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "Skróty klawiszowe",
"tip": "Naciśnij ? w dowolnym momencie, aby wyświetlić tę pomoc",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 24d0a444..2a784b84 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "{count} e-mails selecionados",
"edit_draft": "Editar rascunho"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "Atalhos de Teclado",
"tip": "Pressione ? a qualquer momento para mostrar esta ajuda",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 23dd13c2..036917a8 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "Сочетания клавиш",
"tip": "Нажмите ? в любое время для отображения справки",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 893bdfc2..3c25d6b5 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "Вибрано електронних листів: {count}",
"edit_draft": "Редагувати чернетку"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "Комбінації клавіш",
"tip": "Натисніть ? у будь-який час, щоб показати цю допомогу",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 02ca92c9..474e8ef4 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -1590,6 +1590,40 @@
"items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿"
},
+ "mailbox_context_menu": {
+ "mark_folder_read": "Mark folder as read",
+ "mark_folder_tree_read": "Mark folder & subfolders as read",
+ "mark_all_folders_read": "Mark all folders as read",
+ "new_subfolder": "New subfolder...",
+ "new_folder": "New folder...",
+ "rename": "Rename...",
+ "empty_folder": "Empty folder",
+ "empty_folder_generic": "Empty folder",
+ "delete_folder": "Delete folder",
+ "refresh": "Refresh",
+ "mark_all_confirm_title": "Mark all folders as read",
+ "mark_all_confirm_message": "Mark every unread message in your personal account as read?",
+ "delete_confirm_title": "Delete folder",
+ "delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
+ "prompt_new_subfolder": "New subfolder name:",
+ "prompt_new_folder": "New folder name:",
+ "prompt_rename": "Rename folder to:",
+ "toast_marked_read": "Folder marked as read",
+ "toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
+ "toast_already_read": "No unread messages",
+ "toast_marked_all_read": "All folders marked as read",
+ "toast_emptied": "Folder emptied",
+ "toast_folder_created": "Folder created",
+ "toast_folder_renamed": "Folder renamed",
+ "toast_folder_deleted": "Folder deleted",
+ "toast_error_mark_read": "Failed to mark as read",
+ "toast_error_empty": "Failed to empty folder",
+ "toast_error_create": "Failed to create folder",
+ "toast_error_rename": "Failed to rename folder",
+ "toast_error_delete": "Failed to delete folder",
+ "toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ },
"shortcuts": {
"title": "键盘快捷键",
"tip": "按?随时显示此帮助",
diff --git a/stores/email-store.ts b/stores/email-store.ts
index c4aed6a6..197273c7 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -118,6 +118,7 @@ interface EmailStore {
deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise;
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise;
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise;
+ markMailboxAsRead: (client: IJMAPClient, mailboxId: string) => Promise;
// Unified mailbox operations
fetchUnifiedEmails: (accounts: UnifiedAccountClient[], role: UnifiedMailboxRole) => Promise;
@@ -1750,6 +1751,39 @@ export const useEmailStore = create((set, get) => ({
}
},
+ markMailboxAsRead: async (client, mailboxId) => {
+ try {
+ const mailbox = get().mailboxes.find(mb => mb.id === mailboxId);
+ const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
+ const jmapMailboxId = mailbox?.originalId || mailboxId;
+
+ const count = await client.markMailboxAsRead(jmapMailboxId, accountId);
+
+ // Update local state: mark all emails currently visible in this mailbox as read,
+ // and zero-out the mailbox unread counter.
+ set((state) => ({
+ emails: state.emails.map(e =>
+ e.mailboxIds && e.mailboxIds[mailboxId]
+ ? { ...e, keywords: { ...e.keywords, $seen: true } }
+ : e
+ ),
+ selectedEmail: state.selectedEmail && state.selectedEmail.mailboxIds?.[mailboxId]
+ ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } }
+ : state.selectedEmail,
+ mailboxes: state.mailboxes.map(mb =>
+ mb.id === mailboxId
+ ? { ...mb, unreadEmails: 0, unreadThreads: 0 }
+ : mb
+ ),
+ }));
+
+ return count;
+ } catch (error) {
+ set({ error: error instanceof Error ? error.message : 'Failed to mark folder as read' });
+ throw error;
+ }
+ },
+
// Unified mailbox operations
fetchUnifiedEmails: async (accounts, role) => {
set({
From 29197ea355c412a57eb9088bdc53d4bae9b503ab Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 25 Apr 2026 16:30:18 +0200
Subject: [PATCH 14/27] feat: replace folder prompt() calls with proper modal
dialog
---
app/[locale]/page.tsx | 38 +++++++---
components/ui/prompt-dialog.tsx | 126 ++++++++++++++++++++++++++++++++
hooks/use-prompt-dialog.ts | 87 ++++++++++++++++++++++
locales/de/common.json | 9 ++-
locales/en/common.json | 9 ++-
locales/es/common.json | 11 ++-
locales/fr/common.json | 11 ++-
locales/it/common.json | 11 ++-
locales/ja/common.json | 11 ++-
locales/ko/common.json | 11 ++-
locales/lv/common.json | 11 ++-
locales/nl/common.json | 11 ++-
locales/pl/common.json | 11 ++-
locales/pt/common.json | 11 ++-
locales/ru/common.json | 11 ++-
locales/uk/common.json | 11 ++-
locales/zh/common.json | 11 ++-
17 files changed, 338 insertions(+), 63 deletions(-)
create mode 100644 components/ui/prompt-dialog.tsx
create mode 100644 hooks/use-prompt-dialog.ts
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 90ddd3f2..d3797799 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -24,6 +24,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
+import { usePromptDialog } from "@/hooks/use-prompt-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
@@ -36,6 +37,7 @@ import {
ComposerErrorFallback,
} from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
+import { PromptDialog } from "@/components/ui/prompt-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
@@ -68,6 +70,7 @@ export default function Home() {
const [pendingDraft, setPendingDraft] = useState(null);
const [composerSessionId, setComposerSessionId] = useState(0);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
+ const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
@@ -1204,10 +1207,15 @@ export default function Home() {
const handleCreateSubfolderFromContextMenu = async (parentId: string) => {
if (!client) return;
- const name = window.prompt(tCtxMenu('mailbox_context_menu.prompt_new_subfolder'));
- if (!name || !name.trim()) return;
+ const name = await promptDialog({
+ title: tCtxMenu('mailbox_context_menu.new_subfolder'),
+ message: tCtxMenu('mailbox_context_menu.prompt_new_subfolder'),
+ placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
+ confirmText: tCtxMenu('mailbox_context_menu.create'),
+ });
+ if (!name) return;
try {
- await createMailbox(client, name.trim(), parentId);
+ await createMailbox(client, name, parentId);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
@@ -1216,10 +1224,15 @@ export default function Home() {
const handleCreateFolderFromContextMenu = async () => {
if (!client) return;
- const name = window.prompt(tCtxMenu('mailbox_context_menu.prompt_new_folder'));
- if (!name || !name.trim()) return;
+ const name = await promptDialog({
+ title: tCtxMenu('mailbox_context_menu.new_folder'),
+ message: tCtxMenu('mailbox_context_menu.prompt_new_folder'),
+ placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
+ confirmText: tCtxMenu('mailbox_context_menu.create'),
+ });
+ if (!name) return;
try {
- await createMailbox(client, name.trim());
+ await createMailbox(client, name);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
@@ -1230,10 +1243,16 @@ export default function Home() {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
- const name = window.prompt(tCtxMenu('mailbox_context_menu.prompt_rename'), mailbox.name);
- if (!name || !name.trim() || name.trim() === mailbox.name) return;
+ const name = await promptDialog({
+ title: tCtxMenu('mailbox_context_menu.rename'),
+ message: tCtxMenu('mailbox_context_menu.prompt_rename'),
+ placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
+ defaultValue: mailbox.name,
+ confirmText: tCtxMenu('mailbox_context_menu.rename_confirm'),
+ });
+ if (!name || name === mailbox.name) return;
try {
- await renameMailbox(client, mailboxId, name.trim());
+ await renameMailbox(client, mailboxId, name);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_renamed'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_rename'));
@@ -2195,6 +2214,7 @@ export default function Home() {
+
diff --git a/components/ui/prompt-dialog.tsx b/components/ui/prompt-dialog.tsx
new file mode 100644
index 00000000..48effdba
--- /dev/null
+++ b/components/ui/prompt-dialog.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { useEffect, useId, useRef, useState } from "react";
+import { useFocusTrap } from "@/hooks/use-focus-trap";
+import { useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+interface PromptDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit: (value: string) => void;
+ title: string;
+ message?: string;
+ placeholder?: string;
+ defaultValue?: string;
+ confirmText?: string;
+ cancelText?: string;
+}
+
+export function PromptDialog({
+ isOpen,
+ onClose,
+ onSubmit,
+ title,
+ message,
+ placeholder,
+ defaultValue = "",
+ confirmText,
+ cancelText,
+}: PromptDialogProps) {
+ const t = useTranslations("confirm_dialog");
+ const id = useId();
+ const [value, setValue] = useState(defaultValue);
+ const inputRef = useRef(null);
+
+ const dialogRef = useFocusTrap({
+ isActive: isOpen,
+ onEscape: onClose,
+ restoreFocus: true,
+ });
+
+ useEffect(() => {
+ if (isOpen) {
+ setValue(defaultValue);
+ const t = setTimeout(() => {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }, 50);
+ return () => clearTimeout(t);
+ }
+ }, [isOpen, defaultValue]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleBackdropClick = (e: MouseEvent) => {
+ if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
+ onClose();
+ }
+ };
+
+ document.addEventListener("mousedown", handleBackdropClick);
+ return () => document.removeEventListener("mousedown", handleBackdropClick);
+ }, [isOpen, onClose, dialogRef]);
+
+ if (!isOpen) return null;
+
+ const resolvedConfirmText = confirmText || t("confirm");
+ const resolvedCancelText = cancelText || t("cancel");
+ const trimmed = value.trim();
+ const canSubmit = trimmed.length > 0;
+
+ const handleSubmit = (e?: React.FormEvent) => {
+ e?.preventDefault();
+ if (!canSubmit) return;
+ try {
+ onSubmit(trimmed);
+ } finally {
+ onClose();
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/hooks/use-prompt-dialog.ts b/hooks/use-prompt-dialog.ts
new file mode 100644
index 00000000..36df3225
--- /dev/null
+++ b/hooks/use-prompt-dialog.ts
@@ -0,0 +1,87 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+
+interface PromptDialogState {
+ isOpen: boolean;
+ title: string;
+ message?: string;
+ placeholder?: string;
+ defaultValue: string;
+ confirmText?: string;
+ cancelText?: string;
+ onSubmit: (value: string) => void;
+}
+
+const INITIAL_STATE: PromptDialogState = {
+ isOpen: false,
+ title: "",
+ defaultValue: "",
+ onSubmit: () => {},
+};
+
+interface PromptOptions {
+ title: string;
+ message?: string;
+ placeholder?: string;
+ defaultValue?: string;
+ confirmText?: string;
+ cancelText?: string;
+}
+
+export function usePromptDialog() {
+ const [state, setState] = useState(INITIAL_STATE);
+ const resolveRef = useRef<((value: string | null) => void) | null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (resolveRef.current) {
+ resolveRef.current(null);
+ resolveRef.current = null;
+ }
+ };
+ }, []);
+
+ const prompt = useCallback(
+ (options: PromptOptions): Promise => {
+ return new Promise((resolve) => {
+ resolveRef.current = resolve;
+ setState({
+ isOpen: true,
+ title: options.title,
+ message: options.message,
+ placeholder: options.placeholder,
+ defaultValue: options.defaultValue ?? "",
+ confirmText: options.confirmText,
+ cancelText: options.cancelText,
+ onSubmit: (value) => {
+ resolveRef.current = null;
+ resolve(value);
+ },
+ });
+ });
+ },
+ []
+ );
+
+ const close = useCallback(() => {
+ if (resolveRef.current) {
+ resolveRef.current(null);
+ resolveRef.current = null;
+ }
+ setState(INITIAL_STATE);
+ }, []);
+
+ return {
+ dialogProps: {
+ isOpen: state.isOpen,
+ onClose: close,
+ onSubmit: state.onSubmit,
+ title: state.title,
+ message: state.message,
+ placeholder: state.placeholder,
+ defaultValue: state.defaultValue,
+ confirmText: state.confirmText,
+ cancelText: state.cancelText,
+ },
+ prompt,
+ };
+}
diff --git a/locales/de/common.json b/locales/de/common.json
index 129016cb..29d1b622 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -1605,9 +1605,12 @@
"mark_all_confirm_message": "Jede ungelesene Nachricht in deinem persönlichen Konto als gelesen markieren?",
"delete_confirm_title": "Ordner löschen",
"delete_confirm_message": "Den Ordner \"{name}\" dauerhaft löschen? Dies kann nicht rückgängig gemacht werden.",
- "prompt_new_subfolder": "Name des neuen Unterordners:",
- "prompt_new_folder": "Name des neuen Ordners:",
- "prompt_rename": "Ordner umbenennen in:",
+ "prompt_new_subfolder": "Gib einen Namen für den neuen Unterordner ein.",
+ "prompt_new_folder": "Gib einen Namen für den neuen Ordner ein.",
+ "prompt_rename": "Gib einen neuen Namen für diesen Ordner ein.",
+ "placeholder_folder_name": "Ordnername",
+ "create": "Erstellen",
+ "rename_confirm": "Umbenennen",
"toast_marked_read": "Ordner als gelesen markiert",
"toast_marked_read_count": "{count, plural, one {1 Nachricht} other {# Nachrichten}} als gelesen markiert",
"toast_already_read": "Keine ungelesenen Nachrichten",
diff --git a/locales/en/common.json b/locales/en/common.json
index 47777a38..23618207 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -1609,9 +1609,12 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
diff --git a/locales/es/common.json b/locales/es/common.json
index b9f6d301..77d94863 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "Atajos de Teclado",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 83c8c8cc..34fc8281 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "Raccourcis clavier",
diff --git a/locales/it/common.json b/locales/it/common.json
index 89f84974..85c99029 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "Scorciatoie da tastiera",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index bb24ed51..e7592ab0 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "キーボードショートカット",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 5f14da62..2aed2cc0 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "단축키",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index cad72013..44da8b8d 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "Īsinājumtaustiņi",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 223d59a7..44dd31af 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "Sneltoetsen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index e1a644ac..f010cbe6 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "Skróty klawiszowe",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 2a784b84..f903909e 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "Atalhos de Teclado",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 036917a8..0969ec71 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "Сочетания клавиш",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 3c25d6b5..3ea4da5c 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "Комбінації клавіш",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 474e8ef4..f4bb33ea 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -1605,9 +1605,9 @@
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
- "prompt_new_subfolder": "New subfolder name:",
- "prompt_new_folder": "New folder name:",
- "prompt_rename": "Rename folder to:",
+ "prompt_new_subfolder": "Enter a name for the new subfolder.",
+ "prompt_new_folder": "Enter a name for the new folder.",
+ "prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
@@ -1622,7 +1622,10 @@
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
- "toast_error_delete_has_email": "Folder is not empty. Empty it first."
+ "toast_error_delete_has_email": "Folder is not empty. Empty it first.",
+ "placeholder_folder_name": "Folder name",
+ "create": "Create",
+ "rename_confirm": "Rename"
},
"shortcuts": {
"title": "键盘快捷键",
From 0913dbd3e4efbd71ad94c7b6386a4d663951e8c5 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 25 Apr 2026 16:34:45 +0200
Subject: [PATCH 15/27] chore: update version to 1.5.1
---
CHANGELOG.md | 19 +++++++++++++++++++
README.md | 2 +-
VERSION | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
5 files changed, 24 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9dcb8df1..13c179f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
# Changelog
+## 1.5.1 (2026-04-25)
+
+### Features
+
+- **Stalwart**: OAuth auto-setup with dialog and validation for origin and issuer URLs
+- **Mail**: Right-click context menu on the folders sidebar
+- **Mail**: Replace folder `prompt()` calls with a proper modal dialog
+- **Calendar**: Add 'Today' button to the desktop calendar toolbar
+- **Junk**: Setting to show avatars in the Junk folder (off by default)
+
+### Fixes
+
+- **Admin**: Restore admin panel after Stalwart v0.16 REST API removal
+- **Viewer**: Restore broken viewer toolbar actions and improve the mobile menu (#220)
+- **Folders**: Stop flicker on background folder refresh
+- **Email**: Preserve search/filter on batch move and archive
+- **Email**: Preserve search/filter when moving emails via drag-drop
+- **i18n**: Improve Korean flag
+
## 1.5.0 (2026-04-22)
### Breaking Changes
diff --git a/README.md b/README.md
index 407182f2..10ec6614 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[](LICENSE)
[](https://discord.gg/tYCujymGrT)
-[](CHANGELOG.md)
+[](CHANGELOG.md)
[](https://ghcr.io/bulwarkmail/webmail)
diff --git a/VERSION b/VERSION
index bc80560f..26ca5946 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.5.0
+1.5.1
diff --git a/package-lock.json b/package-lock.json
index d561fbe3..894139a7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
- "version": "1.5.0",
+ "version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
- "version": "1.5.0",
+ "version": "1.5.1",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
diff --git a/package.json b/package.json
index 669cd967..7b6ffd51 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
- "version": "1.5.0",
+ "version": "1.5.1",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail ",
"license": "AGPL-3.0-only",
From 5aa9b1d5f9be7e17689ae2492b7adc6303cae53b Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 25 Apr 2026 17:21:17 +0200
Subject: [PATCH 16/27] fix: honor SESSION_SECRET_FILE in dashboard warning
check #222
---
lib/admin/config-manager.ts | 18 ++++++++++++++++--
lib/admin/types.ts | 6 +++---
2 files changed, 19 insertions(+), 5 deletions(-)
diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts
index 8146a538..f094f6a3 100644
--- a/lib/admin/config-manager.ts
+++ b/lib/admin/config-manager.ts
@@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
+import { readFileEnv } from '@/lib/read-file-env';
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
function getAdminDir(): string {
@@ -64,6 +65,12 @@ class ConfigManager {
if (envVal !== undefined) {
return parseEnvValue(envVal, mapping.type) as T;
}
+ if (mapping.fileEnvVar) {
+ const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
+ if (fileVal !== null) {
+ return parseEnvValue(fileVal, mapping.type) as T;
+ }
+ }
if (defaultValue !== undefined) return defaultValue;
return mapping.defaultValue as T;
}
@@ -94,9 +101,16 @@ class ConfigManager {
const envVal = process.env[mapping.envVar];
if (envVal !== undefined) {
result[key] = { value: parseEnvValue(envVal, mapping.type), source: 'env' };
- } else {
- result[key] = { value: mapping.defaultValue, source: 'default' };
+ continue;
}
+ if (mapping.fileEnvVar) {
+ const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
+ if (fileVal !== null) {
+ result[key] = { value: parseEnvValue(fileVal, mapping.type), source: 'env' };
+ continue;
+ }
+ }
+ result[key] = { value: mapping.defaultValue, source: 'default' };
}
}
return result;
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 873a73fc..07171f31 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -106,7 +106,7 @@ export interface AuditEntry {
}
/** Config keys that map to environment variables */
-export const CONFIG_ENV_MAP: Record = {
+export const CONFIG_ENV_MAP: Record = {
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
@@ -124,7 +124,7 @@ export const CONFIG_ENV_MAP: Record
Date: Sat, 25 Apr 2026 18:40:54 +0200
Subject: [PATCH 17/27] feat: composer-sidebar slot + plugin-declared frame-src
origins
---
CHANGELOG.md | 7 ++
VERSION | 2 +-
app/api/admin/marketplace/route.ts | 26 +++++-
app/api/admin/plugins/route.ts | 18 +++-
components/email/email-composer.tsx | 8 +-
lib/__tests__/csp-frame-origins.test.ts | 104 ++++++++++++++++++++++++
lib/__tests__/plugin-store.test.ts | 1 +
lib/admin/csp-frame-origins.ts | 102 +++++++++++++++++++++++
lib/admin/plugin-registry.ts | 5 ++
lib/plugin-api.ts | 6 ++
lib/plugin-types.ts | 12 ++-
package-lock.json | 4 +-
package.json | 2 +-
proxy.ts | 16 +++-
stores/plugin-store.ts | 2 +-
15 files changed, 304 insertions(+), 11 deletions(-)
create mode 100644 lib/__tests__/csp-frame-origins.test.ts
create mode 100644 lib/admin/csp-frame-origins.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13c179f3..f64e5c42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 1.5.2 (2026-04-25)
+
+### Features
+
+- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission — plugins can now render a panel on the left side of the New Message dialog. See `repos/subway-surfers` for an example
+- **Plugins**: Manifests can declare `frameOrigins` — a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
+
## 1.5.1 (2026-04-25)
### Features
diff --git a/VERSION b/VERSION
index 26ca5946..4cda8f19 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.5.1
+1.5.2
diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts
index 341e9627..fdf25e61 100644
--- a/app/api/admin/marketplace/route.ts
+++ b/app/api/admin/marketplace/route.ts
@@ -10,6 +10,10 @@ import {
type ServerPlugin,
type ServerTheme,
} from '@/lib/admin/plugin-registry';
+import {
+ sanitizeFrameOrigins,
+ invalidateFrameOriginsCache,
+} from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
@@ -226,6 +230,22 @@ export async function POST(request: NextRequest) {
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
}
+ // Plugins may declare iframe origins they need for embedded content.
+ // Anything that doesn't pass strict origin validation is silently
+ // dropped — the plugin still installs, but those origins are not
+ // added to the host CSP.
+ const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
+ const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
+ ? (manifest.frameOrigins as unknown[]).filter(
+ (v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
+ )
+ : [];
+ if (droppedFrameOrigins.length > 0) {
+ warnings.push(
+ `Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
+ );
+ }
+
const plugin: ServerPlugin = {
id: (manifest.id as string) || slug,
name: (manifest.name as string) || slug,
@@ -238,10 +258,14 @@ export async function POST(request: NextRequest) {
enabled: true,
installedAt: now,
updatedAt: now,
+ ...(declaredFrameOrigins.length > 0
+ ? { frameOrigins: declaredFrameOrigins }
+ : {}),
};
await savePlugin(plugin, code);
- await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
+ invalidateFrameOriginsCache();
+ await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
return NextResponse.json({ success: true, plugin, warnings });
}
diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts
index 9f2f46ed..491dca0b 100644
--- a/app/api/admin/plugins/route.ts
+++ b/app/api/admin/plugins/route.ts
@@ -8,6 +8,10 @@ import {
deletePlugin as removePlugin,
type ServerPlugin,
} from '@/lib/admin/plugin-registry';
+import {
+ sanitizeFrameOrigins,
+ invalidateFrameOriginsCache,
+} from '@/lib/admin/csp-frame-origins';
// Server-side extraction using the same validation logic
// ZIP parsing needs to happen on the server for admin-uploaded plugins
@@ -152,6 +156,8 @@ export async function POST(request: NextRequest) {
);
}
+ const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
+
const now = new Date().toISOString();
const plugin: ServerPlugin = {
id: manifest.id as string,
@@ -166,12 +172,16 @@ export async function POST(request: NextRequest) {
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
+ ...(declaredFrameOrigins.length > 0
+ ? { frameOrigins: declaredFrameOrigins }
+ : {}),
installedAt: now,
updatedAt: now,
};
await savePlugin(plugin, code);
- await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
+ invalidateFrameOriginsCache();
+ await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
return NextResponse.json({ plugin });
} catch (error) {
@@ -209,6 +219,11 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
+ // Enable/disable changes the set of plugins contributing frame origins.
+ if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
+ invalidateFrameOriginsCache();
+ }
+
await auditLog('plugin.update', { id, ...updates }, ip);
return NextResponse.json({ plugin: updated });
} catch (error) {
@@ -238,6 +253,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
+ invalidateFrameOriginsCache();
await auditLog('plugin.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 2bf9e569..607a4edb 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -1100,8 +1100,13 @@ export function EmailComposer({
};
return (
+
);
}
diff --git a/lib/__tests__/csp-frame-origins.test.ts b/lib/__tests__/csp-frame-origins.test.ts
new file mode 100644
index 00000000..b89e8dfb
--- /dev/null
+++ b/lib/__tests__/csp-frame-origins.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from 'vitest';
+import {
+ isValidFrameOrigin,
+ sanitizeFrameOrigins,
+} from '@/lib/admin/csp-frame-origins';
+
+describe('isValidFrameOrigin', () => {
+ it('accepts plain https origins', () => {
+ expect(isValidFrameOrigin('https://www.youtube-nocookie.com')).toBe(true);
+ expect(isValidFrameOrigin('https://meet.example.com')).toBe(true);
+ expect(isValidFrameOrigin('https://a.b.c.example.com')).toBe(true);
+ });
+
+ it('accepts a wildcard subdomain', () => {
+ expect(isValidFrameOrigin('https://*.example.com')).toBe(true);
+ expect(isValidFrameOrigin('https://*.youtube.com')).toBe(true);
+ });
+
+ it('accepts an explicit port', () => {
+ expect(isValidFrameOrigin('https://meet.example.com:8443')).toBe(true);
+ expect(isValidFrameOrigin('https://*.example.com:443')).toBe(true);
+ });
+
+ it('rejects non-https schemes', () => {
+ expect(isValidFrameOrigin('http://example.com')).toBe(false);
+ expect(isValidFrameOrigin('ftp://example.com')).toBe(false);
+ expect(isValidFrameOrigin('data:text/html,foo')).toBe(false);
+ expect(isValidFrameOrigin('javascript:alert(1)')).toBe(false);
+ });
+
+ it('rejects bare schemes and wildcard hosts', () => {
+ expect(isValidFrameOrigin('https://')).toBe(false);
+ expect(isValidFrameOrigin('https://*')).toBe(false);
+ expect(isValidFrameOrigin('https://*.com')).toBe(false);
+ expect(isValidFrameOrigin('https://localhost')).toBe(false);
+ });
+
+ it('rejects paths, queries, and fragments', () => {
+ expect(isValidFrameOrigin('https://example.com/embed')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com/')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com?x=1')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com#x')).toBe(false);
+ });
+
+ it('rejects userinfo, IPs, and IPv6', () => {
+ expect(isValidFrameOrigin('https://user:pass@example.com')).toBe(false);
+ expect(isValidFrameOrigin('https://1.2.3.4')).toBe(false);
+ expect(isValidFrameOrigin('https://[::1]')).toBe(false);
+ });
+
+ it('rejects values that try to break out of the directive', () => {
+ expect(isValidFrameOrigin("https://example.com'; script-src 'unsafe-eval")).toBe(false);
+ expect(isValidFrameOrigin('https://example.com" data:')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com data:')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com\nhttps://evil.com')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com;https://evil.com')).toBe(false);
+ expect(isValidFrameOrigin('https://exa,mple.com')).toBe(false);
+ });
+
+ it('rejects non-strings and obvious garbage', () => {
+ expect(isValidFrameOrigin(undefined)).toBe(false);
+ expect(isValidFrameOrigin(null)).toBe(false);
+ expect(isValidFrameOrigin(42)).toBe(false);
+ expect(isValidFrameOrigin('')).toBe(false);
+ expect(isValidFrameOrigin('not-a-url')).toBe(false);
+ expect(isValidFrameOrigin('a'.repeat(300))).toBe(false);
+ });
+});
+
+describe('sanitizeFrameOrigins', () => {
+ it('returns empty for non-array input', () => {
+ expect(sanitizeFrameOrigins(undefined)).toEqual([]);
+ expect(sanitizeFrameOrigins(null)).toEqual([]);
+ expect(sanitizeFrameOrigins('https://example.com')).toEqual([]);
+ expect(sanitizeFrameOrigins({})).toEqual([]);
+ });
+
+ it('keeps valid entries and drops invalid ones silently', () => {
+ expect(
+ sanitizeFrameOrigins([
+ 'https://www.youtube-nocookie.com',
+ 'http://insecure.com',
+ 'https://meet.example.com:8443',
+ 'https://example.com/path',
+ 42,
+ 'https://*.vimeo.com',
+ ]),
+ ).toEqual([
+ 'https://www.youtube-nocookie.com',
+ 'https://meet.example.com:8443',
+ 'https://*.vimeo.com',
+ ]);
+ });
+
+ it('dedupes case-insensitively', () => {
+ expect(
+ sanitizeFrameOrigins([
+ 'https://Example.com',
+ 'https://example.com',
+ 'https://EXAMPLE.com',
+ ]),
+ ).toEqual(['https://Example.com']);
+ });
+});
diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts
index 4ea88743..823cc335 100644
--- a/lib/__tests__/plugin-store.test.ts
+++ b/lib/__tests__/plugin-store.test.ts
@@ -47,6 +47,7 @@ function resetStore() {
'email-banner': [],
'email-footer': [],
'composer-toolbar': [],
+ 'composer-sidebar': [],
'sidebar-widget': [],
'email-detail-sidebar': [],
'settings-section': [],
diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts
new file mode 100644
index 00000000..9fa211ea
--- /dev/null
+++ b/lib/admin/csp-frame-origins.ts
@@ -0,0 +1,102 @@
+/**
+ * Computes the union of CSP `frame-src` origins declared by installed and
+ * enabled plugins. The proxy reads this on each request so that plugins can
+ * embed external content (YouTube, Vimeo, Jitsi, …) without us hard-coding
+ * domains in the host CSP.
+ *
+ * Origins are validated at install time and re-validated here as defense in
+ * depth — any malformed value is dropped so a corrupted registry can never
+ * inject arbitrary CSP fragments.
+ */
+
+import { getPluginRegistry } from './plugin-registry';
+
+// `https://host`, `https://host:port`, or `https://*.host[:port]`
+//
+// Each label is alphanumeric with optional inner dashes; the final TLD label
+// MUST start with a letter so we reject raw IPv4 literals.
+//
+// Disallowed by the regex (intentionally):
+// - any scheme other than https
+// - paths, queries, fragments
+// - userinfo, IPv4 literals, IPv6 literals (`[::1]`)
+// - bare wildcards (`https://*`)
+const FRAME_ORIGIN_RE =
+ /^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i;
+
+export function isValidFrameOrigin(origin: unknown): origin is string {
+ if (typeof origin !== 'string') return false;
+ if (origin.length > 200) return false;
+ if (!FRAME_ORIGIN_RE.test(origin)) return false;
+ // Reject control characters / whitespace as a final safeguard against
+ // anything that would let an attacker break out of the directive.
+ if (/[\s'"`;,()]/.test(origin)) return false;
+ return true;
+}
+
+/**
+ * Sanitises a list of candidate origins from a manifest. Drops invalid
+ * entries silently and dedupes (case-insensitive on the host).
+ */
+export function sanitizeFrameOrigins(input: unknown): string[] {
+ if (!Array.isArray(input)) return [];
+ const seen = new Set();
+ const out: string[] = [];
+ for (const value of input) {
+ if (!isValidFrameOrigin(value)) continue;
+ const key = value.toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(value);
+ }
+ return out;
+}
+
+// In-memory cache. The proxy fires on every page navigation; reading the
+// registry JSON every time is fine but cheap to skip when nothing has
+// changed. Five seconds is short enough to make plugin install/uninstall
+// feel snappy without measurable overhead.
+let cachedAt = 0;
+let cachedOrigins: string[] = [];
+const CACHE_TTL_MS = 5_000;
+
+/**
+ * Returns the union of frame origins declared by every enabled plugin in
+ * the server-side registry, deduped and validated.
+ *
+ * Returns an empty array on any failure (missing file, parse error, …) so
+ * a broken registry only ever shrinks the CSP — never widens it.
+ */
+export async function getEnabledPluginFrameOrigins(): Promise {
+ const now = Date.now();
+ if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
+
+ try {
+ const registry = await getPluginRegistry();
+ const seen = new Set();
+ const out: string[] = [];
+ for (const plugin of registry.plugins) {
+ if (!plugin.enabled) continue;
+ const origins = sanitizeFrameOrigins(plugin.frameOrigins);
+ for (const o of origins) {
+ const key = o.toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(o);
+ }
+ }
+ cachedOrigins = out;
+ cachedAt = now;
+ return out;
+ } catch {
+ cachedOrigins = [];
+ cachedAt = now;
+ return [];
+ }
+}
+
+/** Force the next call to re-read the registry. Used by install/uninstall. */
+export function invalidateFrameOriginsCache(): void {
+ cachedAt = 0;
+ cachedOrigins = [];
+}
diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts
index ab44bfab..788aa732 100644
--- a/lib/admin/plugin-registry.ts
+++ b/lib/admin/plugin-registry.ts
@@ -41,6 +41,11 @@ export interface ServerPlugin {
configSchema?: Record;
installedAt: string;
updatedAt: string;
+ /**
+ * Validated CSP origins (https-only, single-origin form) the plugin may
+ * embed. Merged into the host frame-src by the proxy.
+ */
+ frameOrigins?: string[];
}
export interface ServerTheme {
diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts
index 73c2e72b..3efd68d1 100644
--- a/lib/plugin-api.ts
+++ b/lib/plugin-api.ts
@@ -121,6 +121,7 @@ export interface PluginAPI {
registerSettingsSection: (section: SettingsSection) => Disposable;
registerComposerAction: (action: ComposerAction) => Disposable;
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
+ registerComposerSidebar: (widget: SidebarWidget) => Disposable;
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
@@ -609,6 +610,11 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType>, widget.order ?? 100);
},
+ registerComposerSidebar: (widget: SidebarWidget) => {
+ requirePermission(plugin, 'ui:composer-sidebar');
+ return registerSlot(plugin.id, 'composer-sidebar', widget.render as React.ComponentType>, widget.order ?? 100);
+ },
+
registerDetailSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:sidebar-widget');
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType>, widget.order ?? 100);
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index c036ed99..43e05d74 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -41,6 +41,14 @@ export interface PluginManifest {
* so plugins can use api.i18n.t() without calling addTranslations() first.
*/
locales?: Record>;
+ /**
+ * External origins this plugin may embed in iframes (e.g. for YouTube,
+ * Vimeo, Jitsi). Each entry is a single CSP origin like
+ * "https://www.youtube-nocookie.com"
+ * "https://*.example.com:8443"
+ * Validated at install time and merged into the host CSP `frame-src`.
+ */
+ frameOrigins?: string[];
}
export interface SettingFieldSchema {
@@ -101,6 +109,7 @@ export type SlotName =
| 'email-banner'
| 'email-footer'
| 'composer-toolbar'
+ | 'composer-sidebar'
| 'sidebar-widget'
| 'email-detail-sidebar'
| 'settings-section'
@@ -495,7 +504,8 @@ export const ALL_PERMISSIONS = [
'auth:observe',
'http:post',
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
- 'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
+ 'ui:composer-toolbar', 'ui:composer-sidebar',
+ 'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
'ui:calendar-action', 'ui:admin-page',
'admin:config',
diff --git a/package-lock.json b/package-lock.json
index 894139a7..d4443ca0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
- "version": "1.5.1",
+ "version": "1.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
- "version": "1.5.1",
+ "version": "1.5.2",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
diff --git a/package.json b/package.json
index 7b6ffd51..e8b18809 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
- "version": "1.5.1",
+ "version": "1.5.2",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail ",
"license": "AGPL-3.0-only",
diff --git a/proxy.ts b/proxy.ts
index f7e087d6..c1efa1d7 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -1,10 +1,11 @@
import { type NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
+import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
const intlMiddleware = createIntlMiddleware(routing);
-export function proxy(request: NextRequest) {
+export async function proxy(request: NextRequest) {
const nonce = crypto.randomUUID();
const isDev = process.env.NODE_ENV === "development";
@@ -16,6 +17,14 @@ export function proxy(request: NextRequest) {
const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
+ // Plugins may declare iframe origins they need (e.g. for embedded video).
+ // Each origin is validated at install time and re-validated here.
+ const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
+ const frameSrc =
+ pluginFrameOrigins.length > 0
+ ? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
+ : `frame-src 'self' blob:`;
+
const csp = [
`default-src 'self'`,
`script-src ${scriptSrc}`,
@@ -23,7 +32,7 @@ export function proxy(request: NextRequest) {
`img-src 'self' data: blob: https:`,
`font-src 'self'`,
`connect-src ${connectSrc}`,
- `frame-src 'self' blob:`,
+ frameSrc,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
@@ -81,4 +90,7 @@ export function proxy(request: NextRequest) {
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)"],
+ // Read the plugin registry from disk to compute the dynamic frame-src
+ // allowlist. Edge runtime can't access the filesystem.
+ runtime: "nodejs",
};
diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts
index 55548872..46e8bfdf 100644
--- a/stores/plugin-store.ts
+++ b/stores/plugin-store.ts
@@ -20,7 +20,7 @@ import { apiFetch } from '@/lib/browser-navigation';
// ─── Slot State ──────────────────────────────────────────────
const SLOT_NAMES: SlotName[] = [
- 'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
+ 'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar',
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
'calendar-event-actions', 'admin-plugin-page',
];
From cfb4a23c9da80d448d6ea1490b0234ecef7934a0 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 25 Apr 2026 18:54:15 +0200
Subject: [PATCH 18/27] fix: remove unnecessary runtime config for Node.js in
proxy settings
---
proxy.ts | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/proxy.ts b/proxy.ts
index c1efa1d7..70087666 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -88,9 +88,8 @@ export async function proxy(request: NextRequest) {
return response;
}
+// Next 16's Proxy always runs on Node.js runtime, so no `runtime` config is
+// allowed (or needed) — we can read the plugin registry from disk directly.
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)"],
- // Read the plugin registry from disk to compute the dynamic frame-src
- // allowlist. Edge runtime can't access the filesystem.
- runtime: "nodejs",
};
From e683c9040447321d901a902d7abd09a9553ab5b4 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 25 Apr 2026 19:25:15 +0200
Subject: [PATCH 19/27] fix: implement inline matcher for Next.js proxy and
remove unnecessary config
Co-authored-by: Copilot
---
proxy.ts | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/proxy.ts b/proxy.ts
index 70087666..83f02039 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -5,7 +5,17 @@ import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
const intlMiddleware = createIntlMiddleware(routing);
+// Next 16's Proxy always runs on Node.js runtime and route-segment config
+// (e.g. `export const config = { matcher }`) is no longer allowed in the
+// proxy file. We replicate the previous matcher inline by short-circuiting
+// requests for API routes, Next internals and static assets.
+const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/;
+
export async function proxy(request: NextRequest) {
+ if (PROXY_SKIP_PATTERN.test(request.nextUrl.pathname)) {
+ return NextResponse.next();
+ }
+
const nonce = crypto.randomUUID();
const isDev = process.env.NODE_ENV === "development";
@@ -87,9 +97,3 @@ export async function proxy(request: NextRequest) {
return response;
}
-
-// Next 16's Proxy always runs on Node.js runtime, so no `runtime` config is
-// allowed (or needed) — we can read the plugin registry from disk directly.
-export const config = {
- matcher: ["/((?!api|_next|.*\\..*).*)"],
-};
From d657aec391d46ca2fe229a76b0665e4bf0a5c65f Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 25 Apr 2026 19:45:45 +0200
Subject: [PATCH 20/27] feat: add support for right-side composer sidebar and
update related types
---
components/email/email-composer.tsx | 5 +++++
lib/__tests__/plugin-store.test.ts | 1 +
lib/plugin-api.ts | 3 ++-
lib/plugin-types.ts | 7 +++++++
stores/plugin-store.ts | 2 +-
5 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 607a4edb..b8982a40 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -1105,6 +1105,7 @@ export function EmailComposer({
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
/>
+ {/* Right-side composer sidebar slot is rendered after the main content div below. */}
)}
+
);
}
diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts
index 823cc335..72105892 100644
--- a/lib/__tests__/plugin-store.test.ts
+++ b/lib/__tests__/plugin-store.test.ts
@@ -48,6 +48,7 @@ function resetStore() {
'email-footer': [],
'composer-toolbar': [],
'composer-sidebar': [],
+ 'composer-sidebar-right': [],
'sidebar-widget': [],
'email-detail-sidebar': [],
'settings-section': [],
diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts
index 3efd68d1..62d244bc 100644
--- a/lib/plugin-api.ts
+++ b/lib/plugin-api.ts
@@ -612,7 +612,8 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
registerComposerSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:composer-sidebar');
- return registerSlot(plugin.id, 'composer-sidebar', widget.render as React.ComponentType
>, widget.order ?? 100);
+ const slot = widget.side === 'right' ? 'composer-sidebar-right' : 'composer-sidebar';
+ return registerSlot(plugin.id, slot, widget.render as React.ComponentType>, widget.order ?? 100);
},
registerDetailSidebar: (widget: SidebarWidget) => {
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index 43e05d74..1195b3eb 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -110,6 +110,7 @@ export type SlotName =
| 'email-footer'
| 'composer-toolbar'
| 'composer-sidebar'
+ | 'composer-sidebar-right'
| 'sidebar-widget'
| 'email-detail-sidebar'
| 'settings-section'
@@ -159,6 +160,12 @@ export interface SidebarWidget {
label: string;
render: React.ComponentType;
order?: number;
+ /**
+ * For composer sidebars, choose which side of the New Message dialog the
+ * panel renders on. Defaults to `'left'` for backwards compatibility.
+ * Ignored by other sidebar slots.
+ */
+ side?: 'left' | 'right';
}
export interface ContextMenuItem {
diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts
index 46e8bfdf..55499fcd 100644
--- a/stores/plugin-store.ts
+++ b/stores/plugin-store.ts
@@ -20,7 +20,7 @@ import { apiFetch } from '@/lib/browser-navigation';
// ─── Slot State ──────────────────────────────────────────────
const SLOT_NAMES: SlotName[] = [
- 'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar',
+ 'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar', 'composer-sidebar-right',
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
'calendar-event-actions', 'admin-plugin-page',
];
From 9f8588eadcd1896876d88e3b690766e4644b11a4 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 26 Apr 2026 02:12:28 +0200
Subject: [PATCH 21/27] fix: hide preview line in compact density to match
settings preview (#223)
---
components/email/email-list-item.tsx | 2 +-
components/email/thread-list-item.tsx | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx
index caa919af..5f782fe4 100644
--- a/components/email/email-list-item.tsx
+++ b/components/email/email-list-item.tsx
@@ -288,7 +288,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Third Line: Preview (controlled by showPreview setting) */}
- {showPreview && density !== 'extra-compact' && (
+ {showPreview && density !== 'extra-compact' && density !== 'compact' && (
(
{email.subject || "(no subject)"}
- {showPreview && density !== 'extra-compact' && (
+ {showPreview && density !== 'extra-compact' && density !== 'compact' && (
- {showPreview && density !== 'extra-compact' && (
+ {showPreview && density !== 'extra-compact' && density !== 'compact' && (
Date: Sun, 26 Apr 2026 02:47:19 +0200
Subject: [PATCH 22/27] fix: emit RFC 9553 name kinds and decode
QUOTED-PRINTABLE in vCard import #224 #187
---
components/contacts/contact-form.tsx | 17 +++---
lib/__tests__/vcard.test.ts | 75 ++++++++++++++++++++++++-
lib/vcard.ts | 83 ++++++++++++++++++++++++----
3 files changed, 154 insertions(+), 21 deletions(-)
diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx
index 48348ca1..ae449b0b 100644
--- a/components/contacts/contact-form.tsx
+++ b/components/contacts/contact-form.tsx
@@ -147,7 +147,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
const t = useTranslations("contacts.form");
const isEditing = !!contact;
- const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
+ // Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
+ const findComponent = (...kinds: string[]) =>
+ contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
function anniversaryDateToString(date: AnniversaryDate): string {
@@ -210,11 +212,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
};
}
- const [prefix, setPrefix] = useState(findComponent("prefix"));
+ const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
const [givenName, setGivenName] = useState(findComponent("given"));
- const [additionalName, setAdditionalName] = useState(findComponent("additional"));
+ const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
const [surname, setSurname] = useState(findComponent("surname"));
- const [suffix, setSuffix] = useState(findComponent("suffix"));
+ const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
const [nickname, setNickname] = useState(
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
@@ -436,12 +438,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
phonesMap[`p${i}`] = obj;
});
+ // Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
const nameComponents = [];
- if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() });
+ if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
- if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() });
+ if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
- if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() });
+ if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
const titlesMap: Record = {};
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
diff --git a/lib/__tests__/vcard.test.ts b/lib/__tests__/vcard.test.ts
index ccff3dc7..42fe627c 100644
--- a/lib/__tests__/vcard.test.ts
+++ b/lib/__tests__/vcard.test.ts
@@ -34,11 +34,11 @@ describe("parseVCard", () => {
expect(result).toHaveLength(1);
const components = result[0].name?.components || [];
expect(components).toEqual([
- { kind: "prefix", value: "Mr." },
+ { kind: "title", value: "Mr." },
{ kind: "given", value: "John" },
- { kind: "additional", value: "Michael" },
+ { kind: "given2", value: "Michael" },
{ kind: "surname", value: "Doe" },
- { kind: "suffix", value: "Jr." },
+ { kind: "generation", value: "Jr." },
]);
});
@@ -52,6 +52,21 @@ describe("parseVCard", () => {
expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe");
});
+ it("maps prefix and middle name to RFC 9553 standard kinds (issue #224)", () => {
+ // N: family;given;additional;prefix;suffix (RFC 6350 order)
+ const withPrefix = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;;Mr.;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
+ const c1 = withPrefix[0].name?.components || [];
+ expect(c1.find((c) => c.kind === "surname")?.value).toBe("Smith");
+ expect(c1.find((c) => c.kind === "given")?.value).toBe("John");
+ expect(c1.find((c) => c.kind === "title")?.value).toBe("Mr.");
+
+ const withMiddle = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;Mike;;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
+ const c2 = withMiddle[0].name?.components || [];
+ expect(c2.find((c) => c.kind === "surname")?.value).toBe("Smith");
+ expect(c2.find((c) => c.kind === "given")?.value).toBe("John");
+ expect(c2.find((c) => c.kind === "given2")?.value).toBe("Mike");
+ });
+
it("parses vCard with phone, org, and address", () => {
const vcf = [
"BEGIN:VCARD",
@@ -204,6 +219,60 @@ describe("parseVCard", () => {
expect(result[0].kind).toBe("group");
});
+ it("decodes ENCODING=QUOTED-PRINTABLE values with UTF-8 charset", () => {
+ const vcf = [
+ "BEGIN:VCARD",
+ "VERSION:2.1",
+ "N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;Hans;;;",
+ "FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans M=C3=BCller",
+ "NOTE;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Caf=C3=A9 stra=C3=9Fe",
+ "EMAIL:hans@example.com",
+ "END:VCARD",
+ ].join("\r\n");
+
+ const result = parseVCard(vcf);
+ expect(result).toHaveLength(1);
+ const card = result[0];
+
+ const components = card.name?.components || [];
+ expect(components.find((c) => c.kind === "given")?.value).toBe("Hans");
+ expect(components.find((c) => c.kind === "surname")?.value).toBe("Müller");
+ expect(card.notes?.n0?.note).toBe("Café straße");
+ });
+
+ it("joins QUOTED-PRINTABLE soft line breaks (= at end of line)", () => {
+ const vcf = [
+ "BEGIN:VCARD",
+ "VERSION:2.1",
+ "FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans=20J=",
+ "=C3=BCrgen=20M=C3=BCller",
+ "EMAIL:hj@example.com",
+ "END:VCARD",
+ ].join("\r\n");
+
+ const result = parseVCard(vcf);
+ expect(result).toHaveLength(1);
+ const components = result[0].name?.components || [];
+ const given = components.find((c) => c.kind === "given")?.value;
+ const surname = components.find((c) => c.kind === "surname")?.value;
+ expect(given).toBe("Hans");
+ expect(surname).toBe("Jürgen Müller");
+ });
+
+ it("recognizes bare QUOTED-PRINTABLE encoding parameter (vCard 2.1 style)", () => {
+ const vcf = [
+ "BEGIN:VCARD",
+ "VERSION:2.1",
+ "FN;QUOTED-PRINTABLE;CHARSET=UTF-8:Caf=C3=A9",
+ "EMAIL:c@example.com",
+ "END:VCARD",
+ ].join("\r\n");
+
+ const result = parseVCard(vcf);
+ const components = result[0].name?.components || [];
+ expect(components.find((c) => c.kind === "given")?.value).toBe("Café");
+ });
+
it("parses GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, SOURCE", () => {
const vcf = [
"BEGIN:VCARD",
diff --git a/lib/vcard.ts b/lib/vcard.ts
index eafb32bf..3680986f 100644
--- a/lib/vcard.ts
+++ b/lib/vcard.ts
@@ -51,6 +51,53 @@ function unfoldLines(vcf: string): string {
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
+// vCard 2.1 quoted-printable soft line breaks: a line ending in `=` continues
+// onto the next line. This is distinct from RFC 5545/6350 line folding (which
+// uses leading whitespace and is already handled in unfoldLines). Only merge
+// when the originating line declares ENCODING=QUOTED-PRINTABLE so we don't
+// accidentally splice unrelated lines.
+function joinQpSoftBreaks(lines: string[]): string[] {
+ const result: string[] = [];
+ let i = 0;
+ while (i < lines.length) {
+ let line = lines[i];
+ if (/;ENCODING=QUOTED-PRINTABLE/i.test(line)) {
+ while (line.endsWith("=") && i + 1 < lines.length) {
+ i++;
+ line = line.slice(0, -1) + lines[i];
+ }
+ }
+ result.push(line);
+ i++;
+ }
+ return result;
+}
+
+function decodeQuotedPrintable(input: string, charset?: string): string {
+ const cleaned = input.replace(/=\r?\n/g, "");
+ const bytes: number[] = [];
+ let i = 0;
+ while (i < cleaned.length) {
+ const ch = cleaned[i];
+ if (ch === "=" && i + 2 < cleaned.length) {
+ const hex = cleaned.substring(i + 1, i + 3);
+ if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
+ bytes.push(parseInt(hex, 16));
+ i += 3;
+ continue;
+ }
+ }
+ bytes.push(cleaned.charCodeAt(i) & 0xff);
+ i += 1;
+ }
+ const label = (charset || "utf-8").toLowerCase();
+ try {
+ return new TextDecoder(label).decode(new Uint8Array(bytes));
+ } catch {
+ return new TextDecoder("utf-8").decode(new Uint8Array(bytes));
+ }
+}
+
function decodeValue(raw: string): string {
return raw
.replace(/\\n/gi, "\n")
@@ -77,7 +124,9 @@ function parseParams(paramStr: string): Record {
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
} else {
const upper = part.toUpperCase();
- if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
+ if (upper === "QUOTED-PRINTABLE" || upper === "BASE64") {
+ params.ENCODING = upper;
+ } else if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper;
}
}
@@ -118,7 +167,7 @@ function contextToType(contexts: Record | undefined): string {
export function parseVCard(vcfString: string): ContactCard[] {
const text = unfoldLines(vcfString);
- const lines = text.split("\n");
+ const lines = joinQpSoftBreaks(text.split("\n"));
const contacts: ContactCard[] = [];
let current: Record | null = null;
@@ -163,8 +212,13 @@ function buildContact(raw: Record): ContactCard | null {
const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : "";
const params = parseParams(paramStr);
+ const isQuotedPrintable = params.ENCODING?.toUpperCase() === "QUOTED-PRINTABLE";
+
for (const rawValue of values) {
- const val = decodeValue(rawValue);
+ const decoded = isQuotedPrintable
+ ? decodeQuotedPrintable(rawValue, params.CHARSET)
+ : rawValue;
+ const val = decodeValue(decoded);
switch (propName) {
case "FN":
@@ -182,13 +236,17 @@ function buildContact(raw: Record): ContactCard | null {
break;
case "N": {
+ // vCard N: family;given;additional;prefix;suffix (RFC 6350 §6.2.2)
+ // Mapped to JSContact-standard kinds (RFC 9553 §2.2.1):
+ // prefix→title, additional→given2, suffix→generation.
+ // Pushed in natural display order so `isOrdered: true` renders correctly.
const nParts = val.split(";");
const components: NameComponent[] = [];
- if (nParts[3]) components.push({ kind: "prefix", value: nParts[3] });
+ if (nParts[3]) components.push({ kind: "title", value: nParts[3] });
if (nParts[1]) components.push({ kind: "given", value: nParts[1] });
- if (nParts[2]) components.push({ kind: "additional", value: nParts[2] });
+ if (nParts[2]) components.push({ kind: "given2", value: nParts[2] });
if (nParts[0]) components.push({ kind: "surname", value: nParts[0] });
- if (nParts[4]) components.push({ kind: "suffix", value: nParts[4] });
+ if (nParts[4]) components.push({ kind: "generation", value: nParts[4] });
if (components.length > 0) {
card.name = { components, isOrdered: true };
}
@@ -558,11 +616,14 @@ function generateSingleVCard(contact: ContactCard): string {
}
const components = contact.name?.components || [];
- const given = components.find(c => c.kind === "given")?.value || "";
- const surname = components.find(c => c.kind === "surname")?.value || "";
- const prefix = components.find(c => c.kind === "prefix")?.value || "";
- const suffix = components.find(c => c.kind === "suffix")?.value || "";
- const additional = components.find(c => c.kind === "additional")?.value || "";
+ const findKind = (...kinds: string[]) =>
+ components.find(c => kinds.includes(c.kind))?.value || "";
+ const given = findKind("given");
+ const surname = findKind("surname");
+ // Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
+ const prefix = findKind("title", "prefix");
+ const suffix = findKind("generation", "suffix");
+ const additional = findKind("given2", "additional", "middle");
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
if (fn) {
From 4a24d2a11deb681a002e99ebb606923a34eb6687 Mon Sep 17 00:00:00 2001
From: Roman Vanicek
Date: Sat, 25 Apr 2026 14:17:53 +0200
Subject: [PATCH 23/27] feat: add Czech language support
---
components/providers/intl-provider.tsx | 2 +
components/ui/language-switcher.tsx | 1 +
i18n/request.ts | 3 +
i18n/routing.ts | 2 +-
locales/cs/common.json | 2678 ++++++++++++++++++++++++
5 files changed, 2685 insertions(+), 1 deletion(-)
create mode 100644 locales/cs/common.json
diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx
index 54d8726f..474e1acf 100644
--- a/components/providers/intl-provider.tsx
+++ b/components/providers/intl-provider.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
+import csMessages from '@/locales/cs/common.json';
import enMessages from '@/locales/en/common.json';
import frMessages from '@/locales/fr/common.json';
import jaMessages from '@/locales/ja/common.json';
@@ -20,6 +21,7 @@ import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
+ cs: csMessages,
en: enMessages,
fr: frMessages,
ja: jaMessages,
diff --git a/components/ui/language-switcher.tsx b/components/ui/language-switcher.tsx
index 9078d6f4..4c87bd28 100644
--- a/components/ui/language-switcher.tsx
+++ b/components/ui/language-switcher.tsx
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
import { flagComponents } from './flag-icons';
const languages = [
+ { value: 'cs', label: 'Česky' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
diff --git a/i18n/request.ts b/i18n/request.ts
index 2d1de97c..03dc1314 100644
--- a/i18n/request.ts
+++ b/i18n/request.ts
@@ -11,6 +11,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
// Use static imports for better compatibility
let messages;
switch (locale) {
+ case 'cs':
+ messages = (await import('../locales/cs/common.json')).default;
+ break;
case 'fr':
messages = (await import('../locales/fr/common.json')).default;
break;
diff --git a/i18n/routing.ts b/i18n/routing.ts
index bfca928a..bd35db54 100644
--- a/i18n/routing.ts
+++ b/i18n/routing.ts
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'as-needed';
export const routing = defineRouting({
- locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
+ locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
defaultLocale: 'en',
localePrefix
});
diff --git a/locales/cs/common.json b/locales/cs/common.json
new file mode 100644
index 00000000..abb1a3ba
--- /dev/null
+++ b/locales/cs/common.json
@@ -0,0 +1,2678 @@
+{
+ "login": {
+ "title": "Webmail",
+ "username_label": "E-mail",
+ "username_placeholder": "uzivatel@example.com",
+ "password_label": "Heslo",
+ "password_placeholder": "Zadejte heslo",
+ "jmap_endpoint_label": "JMAP server",
+ "jmap_endpoint_placeholder": "https://mail.example.com",
+ "jmap_endpoint_cors_hint": "Server musí povolovat požadavky CORS z této domény.",
+ "sign_in": "Přihlásit se",
+ "signing_in": "Přihlašování...",
+ "loading": "Načítání...",
+ "reconnecting": "Spojení ztraceno. Pokus o opětovné připojení…",
+ "error": {
+ "invalid_credentials": "Neplatná e-mailová adresa nebo heslo. Zkontrolujte přihlašovací údaje a zkuste to znovu.",
+ "connection_failed": "Nelze se připojit k serveru. Zkontrolujte připojení k internetu a zkuste to znovu.",
+ "cors_blocked": "Server je dostupný, ale blokuje požadavky mezi doménami. Zkontrolujte nastavení CORS serveru JMAP a povolte tuto doménu.",
+ "server_error": "Server je dočasně nedostupný. Zkuste to prosím později.",
+ "generic": "Došlo k neočekávané chybě. Pokud problém přetrvává, kontaktujte správce.",
+ "totp_required": "Je vyžadován kód dvoufázového ověření. Zadejte kód níže.",
+ "totp_invalid": "Neplatný ověřovací kód. Zkontrolujte svou ověřovací aplikaci a zkuste to znovu.",
+ "oauth_discovery_failed": "SSO je povoleno, ale nelze se připojit k poskytovateli identity. Zkontrolujte konfiguraci OAuth."
+ },
+ "show_password": "Zobrazit heslo",
+ "hide_password": "Skrýt heslo",
+ "totp_toggle": "Mám 2FA kód",
+ "remember_me": "Zapamatovat si mě",
+ "config_error": {
+ "title": "Chyba konfigurace",
+ "fetch_failed": "Konfiguraci aplikace nelze načíst. Zkuste to prosím později.",
+ "server_not_configured": "Poštovní server nebyl nakonfigurován. Kontaktujte správce."
+ },
+ "remove_from_history": "Odstranit z historie",
+ "totp_label": "Ověřovací kód",
+ "totp_placeholder": "000000",
+ "session_expired": "Platnost relace vypršela. Přihlaste se prosím znovu.",
+ "dismiss": "Zavřít",
+ "or": "nebo",
+ "sign_in_sso": "Přihlásit se přes SSO",
+ "add_account_title": "Přidat účet",
+ "add_account_subtitle": "Přihlásit se k jinému účtu",
+ "cancel": "Zrušit",
+ "website": "Webové stránky",
+ "imprint": "Tiráž",
+ "privacy_policy": "Zásady ochrany osobních údajů",
+ "try_demo": "Vyzkoušet demoverzi",
+ "demo_description": "Prozkoumejte aplikaci s ukázkovými daty - bez nutnosti zakládat účet",
+ "demo_launching": "Spouštění demoverze...",
+ "demo_login_button": "Spustit demoverzi",
+ "demo_tagline": "Vyzkoušejte plně funkční e-mailový klient. Účet není vyžadován.",
+ "demo_no_signup": "Bez registrace - volně prozkoumejte s ukázkovými daty",
+ "oauth_completing": "Dokončování přihlášení...",
+ "oauth_error": {
+ "title": "Ověření selhalo",
+ "invalid_state": "Bezpečnostní ověření selhalo. Zkuste se přihlásit znovu.",
+ "missing_params": "Chybí autorizační data. Zkuste se přihlásit znovu.",
+ "token_exchange_failed": "Ověření se nepodařilo dokončit. Zkuste to znovu.",
+ "access_denied": "Přístup odepřen. Kontaktujte správce.",
+ "back_to_login": "Zpět na přihlášení"
+ }
+ },
+ "sidebar": {
+ "close": "Zavřít",
+ "compose": "Napsat",
+ "compose_hint": "Napsat (c)",
+ "search_placeholder": "Hledat ve zprávách...",
+ "search_placeholder_hint": "Hledat ve zprávách... (stiskněte /)",
+ "storage": "Úložiště",
+ "storage_used": "Využito",
+ "storage_free": "Volno",
+ "storage_total": "Celkem",
+ "sign_out": "Odhlásit se",
+ "sign_out_of": "Odhlásit se z {account}",
+ "sign_out_all": "Odhlásit se ze všech účtů",
+ "add_account": "Přidat účet",
+ "set_as_default": "Nastavit jako výchozí",
+ "switch_account": "Přepnout účet",
+ "contacts": "Kontakty",
+ "calendar": "Kalendář",
+ "settings": "Nastavení",
+ "admin": "Administrace",
+ "files": "Soubory",
+ "loading_mailboxes": "Načítání schránek...",
+ "push_connected": "Aktualizace v reálném čase aktivní",
+ "push_disconnected": "Aktualizace v reálném čase neaktivní",
+ "keyboard_shortcuts": "Klávesové zkratky",
+ "theme": {
+ "light": "Světlý režim",
+ "dark": "Tmavý režim",
+ "system": "Systémový motiv"
+ },
+ "language": {
+ "title": "Jazyk"
+ },
+ "mailboxes": {
+ "inbox": "Doručené",
+ "sent": "Odeslané",
+ "drafts": "Koncepty",
+ "trash": "Koš",
+ "archive": "Archiv",
+ "starred": "S hvězdičkou",
+ "all_mail": "Všechny zprávy",
+ "spam": "Spam",
+ "important": "Důležité"
+ },
+ "unified_inbox": "Sjednocená doručená pošta",
+ "unified_sent": "Všechny odeslané",
+ "unified_drafts": "Všechny koncepty",
+ "unified_trash": "Všechny koše",
+ "unified_archive": "Všechny archivy",
+ "unified_junk": "Všechen spam",
+ "all_accounts": "Všechny účty",
+ "expand": "Rozbalit",
+ "collapse": "Sbalit",
+ "expand_tooltip": "Rozbalit",
+ "collapse_tooltip": "Sbalit",
+ "mobile": {
+ "search": "Hledat",
+ "compose": "Napsat",
+ "go_back": "Zpět"
+ },
+ "clear_search": "Vymazat hledání",
+ "vacation_active": "Automatická odpověď je aktivní",
+ "demo_banner": "Demo režim",
+ "demo_reset": "Resetovat",
+ "demo_tour": "Průvodce",
+ "tags": "Štítky",
+ "folders": "Složky",
+ "mail": "Pošta",
+ "nav_label": "Navigace",
+ "add_app": "Aplikace"
+ },
+ "sidebar_apps": {
+ "modal_title": "Aplikace postranního panelu",
+ "add_new": "Přidat aplikaci",
+ "edit_app": "Upravit aplikaci",
+ "name_label": "Název",
+ "name_placeholder": "Moje aplikace",
+ "name_required": "Název je vyžadován",
+ "url_label": "URL",
+ "url_required": "URL je vyžadováno",
+ "url_invalid": "Zadejte platnou adresu http nebo https",
+ "icon_label": "Ikona",
+ "icon_required": "Ikona je vyžadována",
+ "open_mode_label": "Režim otevírání",
+ "open_new_tab": "Nová karta",
+ "open_inline": "Vložené",
+ "cancel": "Zrušit",
+ "add": "Přidat",
+ "update": "Aktualizovat",
+ "delete": "Odstranit",
+ "delete_confirm_title": "Odstranit aplikaci",
+ "delete_confirm": "Opravdu chcete odstranit \"{name}\"?",
+ "no_apps": "Zatím nebyly přidány žádné aplikace",
+ "no_apps_hint": "Přidejte vlastní aplikace a odkazy do postranního panelu",
+ "search_icons": "Hledat ikony...",
+ "show_popular": "Oblíbené",
+ "show_all": "Všechny",
+ "no_icons_found": "Ikony nenalezeny",
+ "inline_badge": "Vložené",
+ "tab_badge": "Karta",
+ "show_on_mobile": "Zobrazit na mobilu"
+ },
+ "email_list": {
+ "no_emails": "Nenalezeny žádné zprávy",
+ "no_emails_description": "Tato schránka je prázdná",
+ "no_search_results": "Nenalezeny žádné výsledky",
+ "no_search_results_description": "Zkuste změnit hledání nebo filtry",
+ "loading": "Načítání zpráv...",
+ "unread": "nepřečtené",
+ "to_me": "Pro mě",
+ "to_recipients": "Pro {count} příjemců",
+ "and_others": "a {count} dalších",
+ "draft": "Koncept",
+ "starred": "S hvězdičkou",
+ "conversations_count": "{count} z {total} konverzací",
+ "conversations_count_plus": "Více než {count} konverzací",
+ "conversations_count_simple": "{count} konverzací",
+ "no_conversations": "Žádné konverzace",
+ "loading_more": "Načítání dalších zpráv...",
+ "no_more_emails": "Žádné další zprávy k načtení",
+ "batch_actions": {
+ "select": "Vybrat zprávy",
+ "select_all": "Vybrat vše",
+ "mark_read": "Označit jako přečtené",
+ "mark_unread": "Označit jako nepřečtené",
+ "delete": "Odstranit",
+ "delete_confirm_title": "Odstranit zprávy",
+ "delete_confirm_message": "Opravdu chcete odstranit {count, plural, one {1 zprávu} few {# zprávy} other {# zpráv}}?",
+ "clear_selection": "Zrušit výběr"
+ },
+ "permanent_delete": "Trvale odstranit",
+ "permanent_delete_confirm_title": "Trvalé odstranění",
+ "permanent_delete_confirm_message": "Tato zpráva bude trvale odstraněna. Tuto akci nelze vrátit zpět.",
+ "permanent_delete_confirm_batch_message": "{count, plural, one {1 zpráva bude trvale odstraněna} few {# zprávy budou trvale odstraněny} other {# zpráv bude trvale odstraněno}}. Tuto akci nelze vrátit zpět.",
+ "empty_folder": {
+ "button": "Vyprázdnit složku",
+ "confirm_title": "Vyprázdnit složku",
+ "confirm_message": "Všechny zprávy v této složce budou trvale odstraněny. Tuto akci nelze vrátit zpět.",
+ "confirm_button": "Vyprázdnit složku",
+ "junk_hint": "Můžete vyprázdnit složku Spam a trvale odstranit všechny zprávy.",
+ "trash_hint": "Můžete vyprázdnit Koš a trvale odstranit všechny zprávy."
+ }
+ },
+ "email_viewer": {
+ "no_email_selected": "Není vybrána žádná zpráva",
+ "no_email_description": "Vyberte zprávu ze seznamu pro její zobrazení",
+ "no_conversation_selected": "Není vybrána žádná konverzace",
+ "no_conversation_description": "Vyberte konverzaci ze seznamu pro její zobrazení",
+ "compose": "Napsat",
+ "compose_hint": "Napsat novou zprávu",
+ "no_subject": "(Bez předmětu)",
+ "loading_email": "Načítání zprávy...",
+ "loading": "Načítání...",
+ "reply": "Odpovědět",
+ "reply_all": "Odpovědět všem",
+ "forward": "Přeposlat",
+ "delete": "Odstranit",
+ "archive": "Archivovat",
+ "star": "Označit hvězdičkou",
+ "unstar": "Odebrat hvězdičku",
+ "mark_unread": "Označit jako nepřečtené",
+ "mark_read": "Označit jako přečtené",
+ "print": "Tisk",
+ "view_source": "Zobrazit zdrojový kód",
+ "export_email": "Exportovat jako .eml",
+ "import_email": "Importovat .eml",
+ "keyboard_shortcuts": "Klávesové zkratky (?)",
+ "email_source": "Zdrojový kód zprávy",
+ "draft_banner": "Tato zpráva je koncept",
+ "edit_draft": "Upravit",
+ "copy_source": "Kopírovat do schránky",
+ "source_copied": "Zdrojový kód byl zkopírován do schránky",
+ "attachments": "Přílohy",
+ "important": "Důležité",
+ "download": "Stáhnout",
+ "download_all": "Stáhnout vše",
+ "from": "Od",
+ "to": "Komu",
+ "cc": "Kopie",
+ "bcc": "Skrytá kopie",
+ "date": "Datum",
+ "subject": "Předmět",
+ "show_details": "Zobrazit podrobnosti",
+ "hide_details": "Skrýt podrobnosti",
+ "external_content_warning": "Obrázky a externí obsah byly zablokovány",
+ "load_external_content": "Načíst obrázky",
+ "trust_sender": "Vždy důvěřovat tomuto odesílateli",
+ "back_to_list": "Zpět na seznam",
+ "view_contact": "Zobrazit kontakt",
+ "message_details": "Podrobnosti zprávy",
+ "more_reply_options": "Další možnosti odpovědi",
+ "set_color": "Nastavit štítek",
+ "tag": "Štítek",
+ "more_actions": "Další akce",
+ "previous": "Předchozí",
+ "next": "Další",
+ "move_to": "Přesunout do...",
+ "remove_color": "Odebrat štítek",
+ "more_count": "+{count} dalších",
+ "characters_count": "{count} znaků",
+ "quick_reply_placeholder": "Napsat rychlou odpověď...",
+ "more_options": "Další možnosti",
+ "sending": "Odesílání...",
+ "security_authentication": "Zabezpečení a ověřování",
+ "technical_details": "Technické podrobnosti",
+ "message_id_label": "Message-ID:",
+ "reply_to_label": "Reply-To:",
+ "delivery_time_label": "Čas doručení:",
+ "conversation_part_label": "Část konverzace:",
+ "previous_messages": "{count} předchozí zpráva",
+ "previous_messages_plural": "{count} předchozích zpráv",
+ "time": {
+ "day": "den",
+ "days": "dní",
+ "hour": "hodina",
+ "hours": "hodin",
+ "minute": "minuta",
+ "minutes": "minut"
+ },
+ "unknown_sender": "Neznámý",
+ "recipient_me": "já",
+ "recipient_and_others": "{name} a {count} dalších",
+ "recipient_to_prefix": "Komu:",
+ "authentication": {
+ "title": "Ověřování",
+ "status": {
+ "verified": "Ověřeno",
+ "warning": "Upozornění",
+ "none": "Neověřeno"
+ },
+ "spf": {
+ "pass": "SPF úspěšné",
+ "fail": "SPF selhalo",
+ "none": "Bez SPF"
+ },
+ "dkim": {
+ "pass": "DKIM platný",
+ "fail": "DKIM neplatný",
+ "none": "Bez DKIM"
+ },
+ "dmarc": {
+ "pass": "DMARC úspěšné",
+ "fail": "DMARC selhalo",
+ "none": "Bez DMARC"
+ },
+ "spam_score": "Skóre spamu"
+ },
+ "headers": {
+ "routing": "Směrování",
+ "received": "Přijato",
+ "message_id": "ID zprávy",
+ "list_info": "Informace o konferenci"
+ },
+ "color_tag": {
+ "title": "Barevný štítek",
+ "red": "Červený",
+ "orange": "Oranžový",
+ "yellow": "Žlutý",
+ "green": "Zelený",
+ "blue": "Modrý",
+ "purple": "Fialový",
+ "pink": "Růžový",
+ "none": "Žádný"
+ },
+ "tooltips": {
+ "reply": "Odpovědět (r)",
+ "reply_all": "Odpovědět všem (a)",
+ "forward": "Přeposlat (f)",
+ "archive": "Archivovat (e)",
+ "delete": "Odstranit (# nebo Del)",
+ "star": "Označit hvězdičkou (s)",
+ "unstar": "Odebrat hvězdičku (s)",
+ "compose": "Napsat (c)",
+ "previous": "Předchozí zpráva",
+ "next": "Další zpráva",
+ "edit_draft": "Upravit koncept"
+ },
+ "spam": {
+ "button_title": "Nahlásit spam",
+ "not_spam_title": "Označit jako bezpečné",
+ "toast_success": "Přesunuto do Spamu",
+ "toast_batch": "{count} zpráv přesunuto do Spamu",
+ "toast_undo": "Zpět",
+ "toast_not_spam_success": "Přesunuto do Doručených",
+ "toast_not_spam_batch": "{count} zpráv přesunuto do Doručených",
+ "error": "Nahlášení spamu selhalo",
+ "error_not_spam": "Obnovení zprávy selhalo"
+ },
+ "unsubscribe_banner": {
+ "label": "Newsletter",
+ "button": "Odhlásit odběr",
+ "confirm_title": "Odhlásit odběr od tohoto odesílatele?",
+ "confirm_button": "Potvrdit",
+ "cancel": "Zrušit",
+ "success_http": "Stránka pro odhlášení byla otevřena na nové kartě",
+ "success_mailto": "Požadavek na odhlášení byl odeslán do e-mailového klienta",
+ "error": "Odběr nelze odhlásit",
+ "dismiss": "Zavřít"
+ },
+ "calendar_invitation": {
+ "loading": "Načítání podrobností události…",
+ "title": "Pozvánka kalendáře",
+ "published_title": "Publikovaná událost",
+ "response_title": "Odpověď na událost",
+ "update_title": "Aktualizace události",
+ "counter_title": "Návrh na změnu",
+ "refresh_title": "Požadavek na obnovení",
+ "declined_counter_title": "Návrh na změnu zamítnut",
+ "cancelled_title": "Událost zrušena",
+ "organizer": "Organizátor: {name}",
+ "attendees": "{count, plural, one {1 účastník} few {# účastníci} other {# účastníků}}",
+ "accept": "Přijmout",
+ "maybe": "Možná",
+ "decline": "Odmítnout",
+ "add_to_calendar": "Přidat do kalendáře",
+ "added": "Přidáno do kalendáře",
+ "rsvp_sent": "Odpověď odeslána",
+ "parse_error": "Nelze přečíst pozvánku",
+ "action_failed": "Akce kalendáře selhala.",
+ "no_calendar": "Kalendář není dostupný",
+ "published_info": "Tato událost byla sdílena pro vaši informaci.",
+ "response_info": "Tato zpráva obsahuje odpověď účastníka.",
+ "response_info_organizer": "Tato odpověď účastníka aktualizuje vaši událost.",
+ "update_info": "Tato zpráva aktualizuje existující událost.",
+ "counter_info": "Tato zpráva navrhuje změny události.",
+ "counter_info_organizer": "Tento účastník navrhl změny vaší události.",
+ "refresh_info": "Tato zpráva vyžaduje nejnovější podrobnosti o události.",
+ "refresh_info_organizer": "Účastník požádal o nejnovější podrobnosti o události.",
+ "declined_counter_info": "Organizátor odmítl návrh na změnu.",
+ "authentication_failed_info": "Ověřování zpráv u této pozvánky selhalo. S akcemi kalendáře zacházejte opatrně.",
+ "authentication_missing_info": "Tato pozvánka neobsahuje ověření zprávy. Pokud se vám cokoli zdá neobvyklé, potvrďte si podrobnosti s organizátorem.",
+ "sender_mismatch_info": "Tato pozvánka byla odeslána od {sender}, zatímco organizátor v datech kalendáře je {organizer}.",
+ "sender_mismatch_unverified_info": "Tato pozvánka byla odeslána od {sender}, zatímco organizátor v datech kalendáře je {organizer}, a zprávu se nepodařilo ověřit.",
+ "organizer_role": "Jste organizátorem této události",
+ "your_response": "Vaše odpověď: {status}",
+ "response_needed": "Vyžaduje odpověď",
+ "response_accepted": "Přijato",
+ "response_tentative": "Nezávazně",
+ "response_declined": "Odmítnuto",
+ "response_delegated": "Delegováno",
+ "actor_sent_info": "Odeslal(a) {name}.",
+ "actor_response_info": "{name} odpověděl(a) {status}.",
+ "actor_counter_info": "{name} navrhl(a) změny pro tuto událost.",
+ "actor_refresh_info": "{name} požádal(a) o nejnovější podrobnosti o události.",
+ "actor_declined_counter_info": "{name} odmítl(a) návrh na změnu.",
+ "actor_note": "Poznámka: {comment}",
+ "actor_unknown": "Někdo",
+ "proposed_changes": "Navrhované změny",
+ "change_title": "Název",
+ "change_time": "Čas",
+ "change_location": "Místo",
+ "change_description": "Popis",
+ "change_empty": "Žádné",
+ "change_from_to": "{before} -> {after}",
+ "apply_proposal": "Použít navrhované změny",
+ "proposal_applied": "Navrhované změny byly použity.",
+ "review_proposal": "Zkontrolovat návrh",
+ "review_request": "Zkontrolovat požadavek",
+ "view_in_calendar": "Zobrazit v kalendáři",
+ "select_calendar": "Vybrat kalendář",
+ "already_in_calendar": "Již je ve vašem kalendáři",
+ "request_info": "Byli jste pozváni na tuto událost. Odpovězte, abyste informovali organizátora o své dostupnosti.",
+ "cancel_info": "Organizátor tuto událost zrušil.",
+ "event_updated": "Aktualizace #{sequence}",
+ "event_status_tentative": "Nezávazně",
+ "event_status_cancelled": "Zrušeno",
+ "expand": "Zobrazit detaily",
+ "collapse": "Skrýt detaily"
+ },
+ "send": "Odeslat",
+ "more": "více"
+ },
+ "email_composer": {
+ "new_message": "Nová zpráva",
+ "reply": "Odpovědět",
+ "reply_all": "Odpovědět všem",
+ "forward": "Přeposlat",
+ "reply_to": "Odpovědět",
+ "reply_all_to": "Odpovědět všem",
+ "forward_message": "Přeposlat",
+ "from": "Od",
+ "to": "Komu",
+ "cc": "Kopie",
+ "bcc": "Skrytá kopie",
+ "subject": "Předmět",
+ "body_placeholder": "Napište zprávu...",
+ "send": "Odeslat",
+ "cancel": "Zrušit",
+ "attach": "Připojit",
+ "discard": "Zahodit",
+ "discard_draft_title": "Zahodit koncept?",
+ "discard_draft_confirm": "Máte neuložené změny. Chcete tento koncept zahodit?",
+ "saving": "Ukládání...",
+ "draft_saved": "Koncept byl uložen",
+ "save_failed": "Uložení selhalo",
+ "to_placeholder": "E-mailové adresy příjemců",
+ "cc_placeholder": "Příjemci kopie",
+ "bcc_placeholder": "Příjemci skryté kopie",
+ "subject_placeholder": "Předmět",
+ "cc_label": "Kopie:",
+ "bcc_label": "Skrytá kopie:",
+ "subject_label": "Předmět:",
+ "file_size_kb": "KB",
+ "prefix": {
+ "forward": "Fwd:",
+ "reply": "Re:"
+ },
+ "no_subject": "(Bez předmětu)",
+ "unknown_sender": "Neznámý",
+ "quote": {
+ "reply_header": "Dne {date}, {sender} napsal(a):",
+ "forward_header": "---------- Přeposlaná zpráva ----------",
+ "from": "Od: {sender}",
+ "date": "Datum: {date}",
+ "subject": "Předmět: {subject}",
+ "to": "Komu: {recipients}"
+ },
+ "remove_sub_address": "Odebrat subadresu",
+ "use_template": "Šablona",
+ "save_as_template": "Uložit jako šablonu",
+ "validation": {
+ "recipient_required": "Chcete-li zprávu odeslat, přidejte příjemce",
+ "subject_required": "Přidejte předmět",
+ "body_required": "Napište zprávu nebo připojte soubor"
+ },
+ "upload_progress": "Nahrávání {uploaded} / {total}",
+ "upload_cancel": "Zrušit nahrávání",
+ "upload_failed": "Soubor {filename} se nepodařilo nahrát",
+ "drop_files": "Přetáhněte soubory sem a připojte je",
+ "show_less": "Zobrazit méně",
+ "send_failed": "Odeslání zprávy selhalo",
+ "continue_draft": "Pokračovat v úpravě konceptu",
+ "close_draft_title": "Uložit nebo zahodit koncept?",
+ "close_draft_message": "Máte neuložené změny. Chcete je uložit jako koncept, nebo zahodit?",
+ "save_draft": "Uložit koncept",
+ "smime_sign_on": "Podepisování S/MIME zapnuto",
+ "smime_sign_off": "Zapnout podepisování S/MIME",
+ "smime_encrypt_on": "Šifrování S/MIME zapnuto",
+ "smime_encrypt_off": "Zapnout šifrování S/MIME",
+ "smime_encrypt_unavailable": "Šifrování S/MIME není dostupné – chybí certifikáty příjemců",
+ "smime_unlock_title": "Odemknout klíč S/MIME",
+ "smime_unlock_message": "Zadejte heslo pro odemčení klíče k podpisu S/MIME.",
+ "smime_unlock_button": "Odemknout",
+ "smime_passphrase_placeholder": "Heslo",
+ "forgot_attachment": {
+ "title": "Nezapomněli jste na přílohu?",
+ "message": "Vaše zpráva obsahuje slovo \"{keyword}\", ale není k ní připojen žádný soubor. Přesto odeslat?",
+ "send_anyway": "Přesto odeslat",
+ "back": "Zpět k úpravám"
+ }
+ },
+ "confirm_dialog": {
+ "confirm": "Potvrdit",
+ "cancel": "Zrušit"
+ },
+ "common": {
+ "loading": "Načítání...",
+ "error": "Chyba",
+ "success": "Úspěch",
+ "cancel": "Zrušit",
+ "save": "Uložit",
+ "delete": "Odstranit",
+ "edit": "Upravit",
+ "close": "Zavřít",
+ "search": "Hledat",
+ "refresh": "Obnovit",
+ "settings": "Nastavení",
+ "help": "Nápověda",
+ "logout": "Odhlásit se",
+ "yes": "Ano",
+ "no": "Ne",
+ "unknown": "Neznámý",
+ "app_title": "Webmail",
+ "reconnecting": "Spojení ztraceno. Pokus o opětovné připojení…",
+ "rate_limited_title": "Ověřování serveru je dočasně omezeno.",
+ "rate_limited_detail": "Požadavky na pozadí byly pozastaveny, aby se zabránilo zablokování. Další pokus za {seconds} s.",
+ "rate_limited_action_title": "Požadavek pozastaven, aby se zabránilo zablokování.",
+ "rate_limited_action_detail": "Aplikace čeká na ukončení doby blokování serveru před odesláním dalších ověřovaných požadavků. Zkuste to znovu za {seconds} s."
+ },
+ "notifications": {
+ "email_sent": "Zpráva byla úspěšně odeslána",
+ "email_deleted": "Zpráva byla odstraněna",
+ "email_archived": "Zpráva byla archivována",
+ "email_starred": "Zpráva byla označena hvězdičkou",
+ "email_unstarred": "Hvězdička byla odebrána",
+ "email_marked_read": "Zpráva byla označena jako přečtená",
+ "email_marked_unread": "Zpráva byla označena jako nepřečtená",
+ "copied_to_clipboard": "Zkopírováno do schránky",
+ "source_copied": "Zdrojový kód zkopírován do schránky",
+ "error_sending": "Odeslání zprávy selhalo",
+ "error_deleting": "Odstranění zprávy selhalo",
+ "error_loading": "Načtení zprávy selhalo",
+ "new_email": "Nová zpráva",
+ "new_email_from": "Od {sender}",
+ "click_to_view": "Kliknutím zobrazíte",
+ "email_moved": "Zpráva byla přesunuta",
+ "emails_moved": "{count} zpráv bylo přesunuto",
+ "moved_to_mailbox": "Přesunuto do složky {mailbox}",
+ "move_failed": "Přesun selhal",
+ "move_error": "Zprávu nelze přesunout do vybrané složky",
+ "email_tagged": "Zpráva byla označena štítkem",
+ "emails_tagged": "{count} zpráv bylo označeno štítkem",
+ "tag_failed": "Označení štítkem selhalo",
+ "identity_created": "Identita úspěšně vytvořena",
+ "identity_updated": "Identita úspěšně aktualizována",
+ "identity_deleted": "Identita odstraněna",
+ "identity_set_primary": "Hlavní identita byla aktualizována",
+ "identity_create_failed": "Vytvoření identity selhalo: {error}",
+ "identity_update_failed": "Aktualizace identity selhala: {error}",
+ "identity_delete_failed": "Odstranění identity selhalo: {error}",
+ "identity_unauthorized": "Nemáte oprávnění odesílat z této e-mailové adresy",
+ "identity_not_found": "Identita nenalezena",
+ "vacation_saved": "Nastavení automatické odpovědi bylo uloženo",
+ "vacation_save_failed": "Uložení nastavení automatické odpovědi selhalo",
+ "filters_saved": "Filtry úspěšně uloženy",
+ "filters_save_failed": "Uložení filtrů selhalo",
+ "filters_deleted": "Pravidlo filtru bylo odstraněno",
+ "templates_exported": "Šablony úspěšně exportovány",
+ "templates_imported": "{count, plural, one {Importována 1 šablona} few {Importovány # šablony} other {Importováno # šablon}}",
+ "templates_import_errors": "Některé šablony se nepodařilo importovat",
+ "templates_import_empty": "V souboru nebyly nalezeny žádné šablony",
+ "export_email_error": "Export zprávy selhal",
+ "import_email_success": "Zpráva byla úspěšně importována",
+ "import_email_error": "Import zprávy selhal"
+ },
+ "date": {
+ "today": "Dnes",
+ "yesterday": "Včera",
+ "this_week": "Tento týden",
+ "last_week": "Minulý týden",
+ "this_month": "Tento měsíc",
+ "older": "Starší",
+ "just_now": "Právě teď",
+ "minutes_ago": "před {count} minutou",
+ "minutes_ago_plural": "před {count} minutami",
+ "hours_ago": "před {count} hodinou",
+ "hours_ago_plural": "před {count} hodinami",
+ "days_ago": "před {count} dnem",
+ "days_ago_plural": "před {count} dny"
+ },
+ "language": {
+ "title": "Jazyk",
+ "czech": "Česky",
+ "english": "English",
+ "french": "Français",
+ "japanese": "日本語",
+ "spanish": "Español",
+ "italian": "Italiano",
+ "german": "Deutsch",
+ "dutch": "Nederlands",
+ "polish": "Polski",
+ "portuguese": "Português",
+ "russian": "Русский",
+ "select_language": "Vybrat jazyk",
+ "switch_to_czech": "Přepnout na češtinu",
+ "switch_to_english": "Přepnout na angličtinu",
+ "switch_to_french": "Přepnout na francouzštinu",
+ "switch_to_japanese": "Přepnout na japonštinu",
+ "switch_to_spanish": "Přepnout na španělštinu",
+ "switch_to_italian": "Přepnout na italštinu",
+ "switch_to_german": "Přepnout na němčinu",
+ "switch_to_dutch": "Přepnout na nizozemštinu",
+ "switch_to_polish": "Přepnout na polštinu",
+ "switch_to_portuguese": "Přepnout na portugalštinu",
+ "switch_to_russian": "Přepnout na ruštinu",
+ "switching": "Změna jazyka...",
+ "switch": "Změnit jazyk",
+ "current": "Aktuální jazyk",
+ "en": "English",
+ "fr": "Français",
+ "de": "Deutsch",
+ "es": "Español",
+ "it": "Italiano",
+ "ja": "日本語",
+ "ko": "한국어",
+ "lv": "Latviešu",
+ "nl": "Nederlands",
+ "pl": "Polski",
+ "pt": "Português",
+ "ru": "Русский",
+ "uk": "Українська",
+ "zh": "简体中文"
+ },
+ "settings": {
+ "title": "Nastavení",
+ "back_to_mail": "Zpět na poštu",
+ "save_success": "Nastavení bylo uloženo",
+ "import_success": "Nastavení bylo importováno",
+ "import_error": "Import nastavení selhal",
+ "reset_confirm": "Opravdu chcete obnovit výchozí nastavení?",
+ "unsaved_changes": "Máte neuložené změny",
+ "discard_changes": "Zahodit neuložené změny?",
+ "discard": "Zahodit",
+ "keep_editing": "Pokračovat v úpravách",
+ "tabs": {
+ "appearance": "Vzhled",
+ "language": "Jazyk a region",
+ "email": "Chování e-mailu",
+ "composer": "Psaní zpráv",
+ "privacy": "Soukromí a bezpečnost",
+ "account": "Účet",
+ "identities": "Identity",
+ "vacation": "Automatická odpověď",
+ "advanced": "Pokročilé",
+ "calendar": "Kalendář",
+ "filters": "Filtry",
+ "templates": "Šablony",
+ "folders": "Složky",
+ "keywords": "Štítky",
+ "security": "Zabezpečení",
+ "files": "Soubory",
+ "contacts": "Kontakty",
+ "encryption": "Šifrování",
+ "sidebar_apps": "Aplikace postranního panelu",
+ "notifications": "Oznámení",
+ "layout": "Vzhled",
+ "reading": "Čtení",
+ "composing": "Psaní",
+ "content_senders": "Obsah a odesílatelé",
+ "about_data": "Info a data",
+ "debug": "Ladění"
+ },
+ "tab_groups": {
+ "general": "Obecné",
+ "account": "Účet a identita",
+ "organization": "Organizace pošty",
+ "apps": "Aplikace",
+ "system": "Systém",
+ "appearance": "Vzhled",
+ "mail": "Pošta",
+ "privacy": "Soukromí a zabezpečení",
+ "advanced": "Pokročilé"
+ },
+ "appearance": {
+ "title": "Vzhled",
+ "description": "Přizpůsobte si vzhled a chování vašeho webového e-mailu",
+ "theme": {
+ "label": "Motiv",
+ "description": "Vyberte preferované barevné schéma",
+ "light": "Světlý",
+ "dark": "Tmavý",
+ "system": "Systémový"
+ },
+ "language": {
+ "label": "Jazyk",
+ "description": "Vyberte preferovaný jazyk"
+ },
+ "font_size": {
+ "label": "Velikost písma",
+ "description": "Upravte velikost textu pro lepší čitelnost",
+ "small": "Malé",
+ "medium": "Střední",
+ "large": "Velké"
+ },
+ "list_density": {
+ "label": "Hustota zobrazení",
+ "description": "Ovládání rozestupů a výplní v celém rozhraní",
+ "extra_compact": "Velmi kompaktní",
+ "compact": "Kompaktní",
+ "regular": "Běžná",
+ "comfortable": "Pohodlná"
+ },
+ "animations": {
+ "label": "Povolit animace",
+ "description": "Zobrazovat plynulé přechody a efekty"
+ },
+ "toolbar_position": {
+ "label": "Pozice panelu nástrojů",
+ "description": "Kde se mají zobrazovat tlačítka pro akce se zprávou (Odpovědět, Archivovat, Odstranit atd.)",
+ "top": "Nahoře",
+ "below_subject": "Pod předmětem"
+ },
+ "toolbar_labels": {
+ "label": "Zobrazovat popisky panelu nástrojů",
+ "description": "Zobrazovat textové štítky vedle ikon na panelu nástrojů. Vypněte, abyste ušetřili místo, jakmile se s ikonami seznámíte."
+ },
+ "hide_account_switcher": {
+ "label": "Skrýt přepínač účtů v postranním panelu",
+ "description": "Skryje výběr účtu v horní části postranního panelu. Stále můžete přepínat účty pomocí spodního navigačního panelu."
+ },
+ "show_rail_account_list": {
+ "label": "Zobrazovat avatary účtů v navigačním panelu",
+ "description": "Zobrazovat samostatné ikony účtů ve spodní části navigačního panelu pro rychlé přepínání, s tlačítkem odhlášení níže."
+ },
+ "unified_mailbox": {
+ "label": "Sjednocená schránka",
+ "description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů"
+ },
+ "colorful_sidebar_icons": {
+ "label": "Barevné ikony postranního panelu",
+ "description": "Obarví ikony složek a štítků podle typu (modré Doručené, červený Spam, zelené Odeslané atd.). Vypněte pro jednobarevný postranní panel."
+ }
+ },
+ "keywords": {
+ "title": "E-mailové štítky",
+ "description": "Definujte štítky pro organizaci e-mailů pomocí barev. Ukládají se jako klíčová slova JMAP na serveru.",
+ "add_keyword": "Přidat štítek",
+ "reset_defaults": "Obnovit výchozí",
+ "label_field": "Zobrazovaný název",
+ "label_placeholder": "např. Práce, Osobní, Naliehavé",
+ "id_field": "ID štítku",
+ "id_placeholder": "např. prace, osobni",
+ "color_field": "Barva",
+ "id_exists": "Toto ID štítku již existuje",
+ "edit": "Upravit štítek",
+ "delete": "Odstranit štítek",
+ "save": "Uložit",
+ "add": "Přidat",
+ "cancel": "Zrušit",
+ "migrating": "Aktualizace štítku v existujících e-mailech…",
+ "migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech"
+ },
+ "notifications": {
+ "test_sound": "Otestovat zvuk oznámení",
+ "sounds": {
+ "default": "Výchozí (Pípnutí)",
+ "cheerful": "Veselý",
+ "involved": "Dynamický",
+ "swift": "Rychlý",
+ "relax": "Relaxační"
+ },
+ "sound_selection": {
+ "title": "Zvuk oznámení",
+ "description": "Vyberte zvuk, který se přehraje při oznámení",
+ "choose": "Zvuk",
+ "choose_desc": "Vyberte tón oznámení a kliknutím na ikonu reproduktoru si jej poslechněte"
+ },
+ "email": {
+ "title": "E-mailová oznámení",
+ "description": "Nakonfigurujte oznámení pro příchozí e-maily",
+ "enabled": "E-mailová oznámení",
+ "enabled_desc": "Zobrazovat oznámení při příchodu nových e-mailů",
+ "sound": "Zvuk oznámení",
+ "sound_desc": "Přehrát zvukové upozornění při příchodu nových e-mailů"
+ },
+ "calendar": {
+ "title": "Oznámení kalendáře",
+ "description": "Nakonfigurujte oznámení pro události kalendáře",
+ "enabled": "Oznámení o událostech",
+ "enabled_desc": "Zobrazovat upozornění na nadcházející události v kalendáři",
+ "sound": "Zvuk oznámení",
+ "sound_desc": "Přehrát zvukové upozornění pro připomenutí kalendáře",
+ "invitation_parsing": "Rozpoznávat e-mailové pozvánky",
+ "invitation_parsing_desc": "Rozpoznávat pozvánky kalendáře v přílohách e-mailů a zobrazovat akce kalendáře"
+ }
+ },
+ "language_region": {
+ "title": "Jazyk a region",
+ "description": "Nakonfigurujte jazykové a místní předvolby",
+ "language": {
+ "label": "Jazyk",
+ "description": "Vyberte preferovaný jazyk",
+ "english": "Angličtina",
+ "french": "Francouzština"
+ },
+ "date_format": {
+ "label": "Formát data",
+ "description": "Jak se mají zobrazovat data",
+ "regional": "Místní",
+ "iso": "ISO 8601",
+ "custom": "Vlastní"
+ },
+ "time_format": {
+ "label": "Formát času",
+ "description": "Vyberte 12hodinový nebo 24hodinový formát času",
+ "12h": "12hodinový",
+ "24h": "24hodinový"
+ },
+ "first_day": {
+ "label": "První den v týdnu",
+ "description": "Začátek týdne v neděli nebo v pondělí",
+ "sunday": "Neděle",
+ "monday": "Pondělí"
+ }
+ },
+ "email_behavior": {
+ "title": "Chování e-mailu",
+ "description": "Nakonfigurujte způsob zpracování e-mailů",
+ "mark_read": {
+ "label": "Označit jako přečtené",
+ "description": "Kdy označit e-mail jako přečtený po jeho otevření",
+ "instant": "Okamžitě",
+ "delay_3s": "Po 3 sekundách",
+ "delay_5s": "Po 5 sekundách",
+ "never": "Nikdy"
+ },
+ "delete_action": {
+ "label": "Akce odstranění",
+ "description": "Co se má stát po odstranění e-mailu",
+ "trash": "Přesunout do Koše",
+ "permanent": "Trvale odstranit",
+ "warning": "E-maily budou trvale odstraněny a nebude možné je obnovit. Tato akce je nevratná."
+ },
+ "archive_mode": {
+ "label": "Archivovat do",
+ "description": "Jak organizovat e-maily při archivaci",
+ "single": "Jedna složka",
+ "year": "Složka pro každý rok",
+ "month": "Složka pro každý měsíc",
+ "reorganize": "Reorganizovat existující archiv",
+ "reorganize_success": "{count, plural, =0 {Žádné zprávy k reorganizaci} one {Reorganizována 1 zpráva} few {Reorganizovány # zprávy} other {Reorganizováno # zpráv}}",
+ "reorganize_error": "Nepodařilo se reorganizovat archiv"
+ },
+ "permanently_delete_junk": {
+ "label": "Trvale odstraňovat spam",
+ "description": "Trvale odstraňovat zprávy ze složky Spam/Nevyžádaná pošta místo přesunutí do Koše"
+ },
+ "mail_layout": {
+ "label": "Rozložení pošty",
+ "description": "Vyberte si mezi klasickým rozděleným zobrazením a soustředěným seznamem podobným Gmailu.",
+ "split": "Rozdělené zobrazení",
+ "split_description": "Seznam zpráv a panel pro čtení zůstávají viditelné vedle sebe.",
+ "focus": "Soustředěný seznam",
+ "focus_description": "Zobrazit jeden řádek na zprávu a otevřít poštu na plnou šířku s viditelným panelem složek."
+ },
+ "show_preview": {
+ "label": "Zobrazit náhledový text",
+ "description": "Zobrazovat náhledy e-mailů v seznamu",
+ "focus_description": "Zobrazovat vložený náhledový text v jednořádkovém soustředěném seznamu"
+ },
+ "disable_threading": {
+ "label": "Zakázat seskupování konverzací",
+ "description": "Zobrazovat e-maily jako jednotlivé zprávy namísto seskupování do vláken"
+ },
+ "plain_text_mode": {
+ "label": "Pouze prostý text",
+ "description": "Zakázat editor formátovaného textu a odesílat všechny e-maily pouze jako prostý text, včetně odpovědí a přeposílání"
+ },
+ "auto_select_reply_identity": {
+ "label": "Automaticky vybírat adresu pro odpověď",
+ "description": "Při odpovídání automaticky přepnout adresu odesílatele na identitu, která původně obdržela zprávu"
+ },
+ "attachment_click_action": {
+ "label": "Akce po kliknutí na přílohu",
+ "description": "Vyberte, zda má kliknutí na přílohu zobrazit náhled, nebo ji ihned stáhnout",
+ "preview": "Náhled, pokud je to možné",
+ "download": "Okamžitě stáhnout"
+ },
+ "attachment_position": {
+ "label": "Pozice příloh",
+ "description": "Kde zobrazovat přílohy v hlavičce e-mailu",
+ "beside-sender": "Vedle odesílatele",
+ "below-header": "Pod hlavičkou"
+ },
+ "emails_per_page": {
+ "10": "10 zpráv",
+ "25": "25 zpráv",
+ "50": "50 zpráv",
+ "100": "100 zpráv",
+ "label": "Zpráv na stránku",
+ "description": "Počet e-mailů načtených najednou"
+ },
+ "always_light_mode": {
+ "label": "Vždy zobrazovat zprávy ve světlém režimu",
+ "description": "Vykreslovat obsah e-mailu ve světlém režimu, i když je aplikace v tmavém režimu, aby se předešlo problémům s konverzí"
+ },
+ "external_content": {
+ "label": "Externí obsah",
+ "description": "Jak zacházet s obrázky a externím obsahem",
+ "ask": "Vždy se ptát",
+ "block": "Vždy blokovat",
+ "allow": "Vždy povolit"
+ },
+ "trusted_senders": {
+ "label": "Důvěryhodní odesílatelé",
+ "description": "Spravujte odesílatele, jejichž obrázky se načítají automaticky",
+ "count_zero": "Žádní",
+ "count_one": "1 odesílatel",
+ "count_other": "{count} odesílatelů",
+ "modal_title": "Důvěryhodní odesílatelé",
+ "empty_title": "Žádní důvěryhodní odesílatelé",
+ "empty_description": "Při prohlížení e-mailů se zablokovanými obrázky klikněte na „Vždy důvěřovat tomuto odesílateli“, čímž jej přidáte sem.",
+ "add_manually": "Přidat odesílatele ručně",
+ "add_button": "Přidat",
+ "add_placeholder": "Zadejte e-mailovou adresu",
+ "search_placeholder": "Hledat odesílatele...",
+ "no_results": "Hledání neodpovídá žádný odesílatel",
+ "remove": "Odstranit",
+ "close": "Zavřít",
+ "invalid_email": "Zadejte platnou e-mailovou adresu",
+ "already_added": "Tento odesílatel je již důvěryhodný",
+ "save_error": "Uložení selhalo - zkontrolujte ladicí protokol Kontaktů",
+ "use_address_book_label": "Synchronizovat s adresářem",
+ "use_address_book_description": "Ukládat důvěryhodné odesílatele ve vyhrazeném adresáři „Důvěryhodní odesílatelé“, aby se synchronizovali napříč zařízeními"
+ },
+ "hover_actions": {
+ "label": "Rychlé akce po přejetí myší",
+ "description": "Vyberte rychlé akce viditelné po přejetí přes e-mail v seznamu",
+ "delete": "Odstranit",
+ "star": "Označit hvězdičkou / odebrat hvězdičku",
+ "mark_read": "Označit jako přečtené / nepřečtené",
+ "archive": "Archivovat",
+ "tag": "Štítek",
+ "spam": "Označit jako spam",
+ "none_selected": "Nebyly vybrány žádné akce",
+ "mode_label": "Režim zobrazení",
+ "mode_inline": "Vložené",
+ "mode_floating": "Plovoucí",
+ "corner_label": "Plovoucí pozice",
+ "corner_top-left": "Levý horní roh",
+ "corner_top-right": "Pravý horní roh",
+ "corner_bottom-left": "Levý dolní roh",
+ "corner_bottom-right": "Pravý dolní roh"
+ },
+ "default_mail_program": {
+ "label": "Výchozí poštovní program",
+ "description": "Zaregistrovat aplikaci {appName} jako výchozí poštovní program pro odkazy mailto:",
+ "button": "Nastavit jako výchozí",
+ "success": "Prohlížeč požádal o nastavení jako výchozího programu",
+ "error": "Váš prohlížeč tuto funkci nepodporuje"
+ },
+ "attachment_reminder": {
+ "label": "Připomenutí přílohy",
+ "description": "Upozornit před odesláním, když zpráva zmiňuje přílohy, ale žádné nejsou připojeny",
+ "keywords_label": "Klíčová slova spouštějící připomenutí",
+ "keywords_description": "Slova nebo fráze, které po nalezení ve zprávě spustí připomenutí",
+ "add_placeholder": "Přidat klíčové slovo...",
+ "add": "Přidat",
+ "remove": "Odstranit"
+ },
+ "hide_inline_image_attachments": {
+ "label": "Skrýt vložené obrázky ze seznamu příloh",
+ "description": "Obrázky vložené přímo do textu zprávy se nezobrazí jako samostatné přílohy"
+ }
+ },
+ "composer": {
+ "title": "Psaní zpráv",
+ "description": "Nakonfigurujte nastavení pro psaní e-mailů",
+ "autosave": {
+ "label": "Interval automatického ukládání",
+ "description": "Jak často se mají koncepty automaticky ukládat",
+ "30s": "Každých 30 sekund",
+ "1m": "Každou minutu",
+ "2m": "Každé 2 minuty",
+ "5m": "Každých 5 minut"
+ },
+ "send_confirmation": {
+ "label": "Potvrzení odeslání",
+ "description": "Před odesláním zprávy požádat o potvrzení"
+ },
+ "default_reply": {
+ "label": "Výchozí režim odpovědi",
+ "description": "Výchozí akce po kliknutí na Odpovědět",
+ "reply": "Odpovědět",
+ "reply_all": "Odpovědět všem"
+ }
+ },
+ "privacy": {
+ "title": "Soukromí a bezpečnost",
+ "description": "Správa nastavení soukromí a bezpečnosti",
+ "external_images": {
+ "label": "Blokovat externí obrázky",
+ "description": "Zabránit sledování pomocí externích obrázků"
+ },
+ "session_timeout": {
+ "label": "Časový limit relace",
+ "description": "Automaticky odhlásit po určité době nečinnosti",
+ "never": "Nikdy",
+ "30m": "30 minut",
+ "1h": "1 hodina",
+ "4h": "4 hodiny"
+ },
+ "clear_cache": {
+ "label": "Vymazat mezipaměť",
+ "description": "Odstranit data z mezipaměti a dočasné soubory",
+ "button": "Vymazat mezipaměť",
+ "confirm": "Opravdu chcete vymazat mezipaměť?",
+ "success": "Mezipaměť byla vymazána"
+ }
+ },
+ "account": {
+ "title": "Účet",
+ "description": "Zobrazit informace o vašem účtu",
+ "name_label": "Zobrazované jméno",
+ "username_label": "Uživatelské jméno",
+ "account_type_label": "Typ účtu",
+ "auth_method_label": "Ověřování",
+ "auth_method_oauth": "Single Sign-On (OAuth/OIDC)",
+ "auth_method_basic": "Heslo",
+ "demo_account": "Demo účet",
+ "email": {
+ "label": "E-mailová adresa",
+ "value": "{email}"
+ },
+ "server": {
+ "label": "JMAP server",
+ "value": "{server}"
+ },
+ "storage": {
+ "label": "Využití úložiště",
+ "used": "Využito {used} z {total}",
+ "percentage": "Využito {percent} %"
+ },
+ "last_sync": {
+ "label": "Poslední synchronizace",
+ "value": "{time}"
+ }
+ },
+ "security": {
+ "title": "Zabezpečení účtu",
+ "description": "Správa hesla, dvoufázového ověření a nastavení zabezpečení",
+ "detecting": "Zjišťování možností serveru...",
+ "not_available": "Správa zabezpečení účtu není pro tento poštovní server k dispozici. Požadovaná oprávnění mohou být vypnuta. Podrobnosti naleznete v dokumentaci .",
+ "password": {
+ "title": "Změnit heslo",
+ "current": "Aktuální heslo",
+ "new": "Nové heslo",
+ "confirm": "Potvrdit nové heslo",
+ "submit": "Změnit heslo",
+ "success": "Heslo bylo změněno",
+ "error_title": "Změna hesla selhala",
+ "error_mismatch": "Nová hesla se neshodují",
+ "error_min_length": "Heslo musí obsahovat alespoň 8 znaků",
+ "error_generic": "Heslo se nepodařilo změnit"
+ },
+ "display_name": {
+ "label": "Zobrazované jméno",
+ "description": "Vaše jméno zobrazené na serveru",
+ "placeholder": "Zadejte své zobrazované jméno",
+ "save": "Uložit",
+ "success": "Zobrazované jméno bylo aktualizováno",
+ "error": "Nepodařilo se aktualizovat zobrazované jméno"
+ },
+ "totp": {
+ "section_title": "Dvoufázové ověření",
+ "label": "Ověřování TOTP",
+ "description": "Přidejte další vrstvu zabezpečení pomocí jednorázového hesla na bázi času",
+ "active": "Zapnuto",
+ "inactive": "Vypnuto",
+ "enabled": "Dvoufázové ověření zapnuto",
+ "disabled": "Dvoufázové ověření vypnuto",
+ "enable_error": "Nepodařilo se zapnout 2FA",
+ "disable_error": "Nepodařilo se vypnout 2FA",
+ "setup_instructions": "Zkopírujte tuto URL adresu do své ověřovací aplikace (Google Authenticator, Authy atd.):",
+ "verification_code": "Ověřovací kód",
+ "confirm": "Potvrdit",
+ "disable": "Vypnout",
+ "disable_confirm_prompt": "Pro vypnutí dvoufázového ověření zadejte heslo.",
+ "password_required": "Zadejte heslo",
+ "code_required": "Zadejte ověřovací kód",
+ "code_invalid": "Neplatný kód. Zkontrolujte ověřovací aplikaci a zkuste to znovu."
+ },
+ "app_passwords": {
+ "title": "Hesla aplikací",
+ "description": "Vytvořte hesla pro aplikace, které nepodporují dvoufázové ověření",
+ "add": "Přidat",
+ "create": "Vytvořit",
+ "cancel": "Zrušit",
+ "done": "Hotovo",
+ "generate": "Vygenerovat",
+ "name_label": "Název aplikace",
+ "name_placeholder": "např. Thunderbird, iPhone Mail",
+ "expires_label": "Platí do (volitelné)",
+ "allowed_ips_label": "Povolené IP (volitelné)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Oddělte čárkou či mezerou. Pro povolení všech IP nechte prázdné.",
+ "password_label": "Heslo (pro vygenerování nechte prázdné)",
+ "password_placeholder": "Bude vygenerováno automaticky",
+ "copy_now_warning": "Heslo si hned zkopírujte – už se znovu nezobrazí.",
+ "added": "Heslo aplikace vytvořeno",
+ "removed": "Heslo aplikace smazáno",
+ "add_error": "Chyba při vytváření hesla aplikace",
+ "remove_error": "Chyba při mazání hesla aplikace",
+ "none": "Žádná hesla aplikací"
+ },
+ "api_keys": {
+ "title": "API klíče",
+ "description": "Vytvořte si API klíče pro skripty a integrace, které komunikují přímo se serverem.",
+ "name_label": "Název klíče",
+ "name_placeholder": "např. Zálohovací skript, CI runner",
+ "copy_now_warning": "API klíč si hned zkopírujte – už se znovu nezobrazí.",
+ "added": "API klíč vytvořen",
+ "removed": "API klíč smazán",
+ "add_error": "Chyba při vytváření API klíče",
+ "remove_error": "Chyba při mazání API klíče",
+ "none": "Žádné API klíče"
+ },
+ "encryption": {
+ "section_title": "Šifrování dat v klidu",
+ "label": "Šifrování e-mailů",
+ "description": "Šifrovat zprávy uložené na serveru pro větší soukromí",
+ "active": "Šifrování {type} zapnuto",
+ "inactive": "Vypnuto",
+ "enabled": "Šifrování dat v klidu zapnuto",
+ "disabled_success": "Šifrování dat v klidu vypnuto",
+ "error": "Nepodařilo se aktualizovat nastavení šifrování"
+ },
+ "email_client": {
+ "title": "Konfigurace poštovního klienta",
+ "description": "Použijte tyto přihlašovací údaje pro konfiguraci e-mailového klienta na počítači nebo mobilním zařízení (Thunderbird, Apple Mail, Outlook atd.)",
+ "jmap_username_label": "Uživatelské jméno JMAP",
+ "copy": "Kopírovat",
+ "copied": "Zkopírováno",
+ "password_instructions": "Použijte výše uvedené uživatelské jméno JMAP společně s heslem aplikace pro přihlášení v poštovním klientovi. Pokud jste tak ještě neučinili, vytvořte si heslo aplikace v sekci výše."
+ }
+ },
+ "identities": {
+ "title": "Identity odesílatele",
+ "description": "Správa e-mailových adres, ze kterých můžete odesílat zprávy",
+ "identities_count": {
+ "label": "Vaše identity",
+ "description": "E-mailové adresy nakonfigurované pro odesílání",
+ "count_zero": "Žádné identity",
+ "count_one": "1 identita",
+ "count_other": "{count} identit"
+ },
+ "manage": "Spravovat identity",
+ "sub_addressing": {
+ "label": "Subadresování",
+ "description": "Používejte štítky jako uzivatel+stitek@domena.cz pro uspořádání příchozí pošty",
+ "learn_more": "Zjistit více"
+ }
+ },
+ "vacation": {
+ "title": "Automatická odpověď",
+ "description": "Během vaší nepřítomnosti bude automaticky odpovídáno na příchozí e-maily",
+ "loading": "Načítání nastavení automatické odpovědi...",
+ "not_supported": "Váš poštovní server nepodporuje automatické odpovědi v nepřítomnosti.",
+ "fetch_error": "Nepodařilo se načíst nastavení automatické odpovědi. Zkuste to znovu.",
+ "status": {
+ "label": "Automatická odpověď",
+ "description": "Odesílat automatickou odpověď lidem, kteří vám napíší",
+ "active": "Aktivní",
+ "inactive": "Neaktivní"
+ },
+ "date_range": {
+ "title": "Časové období",
+ "description": "Volitelně omezte automatickou odpověď na určité období",
+ "start": "Datum zahájení",
+ "start_description": "Nechte prázdné pro spuštění ihned",
+ "end": "Datum ukončení",
+ "end_description": "Nechte prázdné pro běh bez ukončení"
+ },
+ "message": {
+ "title": "Zpráva automatické odpovědi",
+ "description": "Zpráva, která bude odeslána jako odpověď",
+ "subject_label": "Předmět",
+ "subject_description": "Předmět automatické odpovědi",
+ "subject_placeholder": "Mimo kancelář",
+ "body_label": "Tělo zprávy",
+ "body_description": "Obsah zprávy v prostém textu",
+ "body_placeholder": "Děkuji za vaši zprávu. Momentálně jsem mimo kancelář a odpovím po svém návratu."
+ },
+ "preview": {
+ "title": "Náhled",
+ "show": "Zobrazit náhled",
+ "hide": "Skrýt náhled"
+ },
+ "save": "Uložit změny",
+ "saving": "Ukládání...",
+ "warnings": {
+ "end_before_start": "Datum ukončení musí být po datu zahájení",
+ "start_in_past": "Datum zahájení je v minulosti",
+ "empty_body": "Tělo zprávy je prázdné - příjemci obdrží prázdnou odpověď"
+ }
+ },
+ "folders": {
+ "title": "Složky",
+ "description": "Správa složek pošty a přiřazení standardních rolí",
+ "folder_list": "Vaše složky",
+ "folder_list_description": "Kliknutím na ikonu složky ji upravíte",
+ "standard_roles": "Standardní role složek",
+ "standard_roles_description": "Přiřaďte složky používané pro standardní role poštovní schránky, jako jsou Doručené, Odeslané, Koš atd.",
+ "role_inbox": "Doručené",
+ "role_drafts": "Koncepty",
+ "role_sent": "Odeslané",
+ "role_trash": "Koš",
+ "role_junk": "Spam / Nevyžádaná pošta",
+ "role_archive": "Archiv",
+ "role_none": "Žádná",
+ "create_folder": "Vytvořit složku",
+ "create_subfolder": "Vytvořit podsložku",
+ "subfolder_of": "Ve složce {name}",
+ "subfolder_name": "Název podsložky",
+ "new_folder_name": "Název složky",
+ "rename": "Přejmenovat",
+ "change_icon": "Změnit ikonu",
+ "delete": "Odstranit",
+ "confirm_delete": "Opravdu chcete odstranit složku „{name}“? Zprávy v této složce budou přesunuty do Koše.",
+ "create": "Vytvořit",
+ "cancel": "Zrušit",
+ "no_folders": "Žádné vlastní složky",
+ "cannot_delete_role": "Složku se standardní rolí nelze odstranit. Nejprve tuto roli odstraňte.",
+ "folder_created": "Složka byla vytvořena",
+ "folder_renamed": "Složka byla přejmenována",
+ "folder_deleted": "Složka byla odstraněna",
+ "role_updated": "Role složky byla aktualizována",
+ "error_create": "Složku se nepodařilo vytvořit",
+ "error_rename": "Složku se nepodařilo přejmenovat",
+ "error_delete": "Složku se nepodařilo odstranit",
+ "error_delete_has_children": "Složku nelze odstranit: stále obsahuje podsložky. Nejprve je odstraňte nebo přesuňte.",
+ "error_delete_has_email": "Složku nelze odstranit: stále obsahuje zprávy. Nejprve je přesuňte nebo odstraňte.",
+ "error_role": "Roli složky se nepodařilo aktualizovat"
+ },
+ "advanced": {
+ "title": "Pokročilé",
+ "description": "Pokročilé možnosti a nastavení pro vývojáře",
+ "debug_mode": {
+ "label": "Režim ladění",
+ "description": "Povolit podrobné protokolování pro řešení problémů"
+ },
+ "debug_categories": {
+ "description": "Vyberte kategorie pro protokolování. Vypněte kategorie, které nepotřebujete, pro snížení množství záznamů v konzoli.",
+ "jmap": "Klient JMAP",
+ "jmap_description": "Operace se schránkami, načítání zpráv a požadavky protokolu JMAP",
+ "calendar": "Kalendář",
+ "calendar_description": "Události kalendáře, importy a plánování zpráv",
+ "tasks": "Úkoly",
+ "tasks_description": "Vytváření úkolů kalendáře, načítání a aktualizace",
+ "auth": "Ověřování",
+ "auth_description": "Přihlašování, TOTP, výměna tokenů a správa relací",
+ "filters": "Filtry",
+ "filters_description": "Pravidla filtrů Sieve a skripty pro automatickou odpověď",
+ "email": "Zobrazení e-mailu",
+ "email_description": "Vykreslování e-mailů, zpracování TNEF a označování jako přečtené",
+ "push": "Push oznámení",
+ "push_description": "Konfigurace a doručování push oznámení",
+ "contacts": "Kontakty a adresáře",
+ "contacts_description": "Synchronizace kontaktů, operace s adresářem a důvěryhodní odesílatelé"
+ },
+ "settings_sync": {
+ "label": "Synchronizace nastavení",
+ "description": "Synchronizovat nastavení mezi prohlížeči a zařízeními"
+ },
+ "sender_favicons": {
+ "label": "Favikony odesílatelů (experimentální)",
+ "description": "Zobrazovat ikony webových stránek jako profilové obrázky firemních odesílatelů"
+ },
+ "show_avatars_in_junk": {
+ "label": "Zobrazovat avatary ve spamu",
+ "description": "Zobrazovat profilové fotky a ikony odesílatelů ve spamu. Ve výchozím stavu vypnuto, aby phishingové e-maily nepůsobily důvěryhodně."
+ },
+ "keyboard_shortcuts": {
+ "label": "Klávesové zkratky",
+ "description": "Zobrazit dostupné klávesové zkratky",
+ "button": "Zobrazit zkratky"
+ },
+ "reset_settings": {
+ "label": "Resetovat nastavení",
+ "description": "Obnovit všechna nastavení na výchozí hodnoty",
+ "button": "Obnovit výchozí"
+ },
+ "export_settings": {
+ "label": "Exportovat nastavení",
+ "description": "Stáhnout svá nastavení jako soubor JSON",
+ "button": "Exportovat"
+ },
+ "import_settings": {
+ "label": "Importovat nastavení",
+ "description": "Nahrát nastavení ze souboru JSON",
+ "button": "Importovat"
+ },
+ "about": {
+ "title": "Bulwark Webmail"
+ }
+ },
+ "sidebar_apps": {
+ "title": "Aplikace postranního panelu",
+ "description": "Spravujte vlastní aplikace a odkazy v postranním panelu",
+ "keep_loaded": "Ponechat aplikace načtené",
+ "keep_loaded_description": "Udržovat vložené aplikace spuštěné na pozadí během přepínání, aby se předešlo jejich opětovnému načítání",
+ "manage_title": "Vlastní aplikace",
+ "manage_description": "Přidávat, upravovat nebo odebírat vlastní aplikace z postranního panelu"
+ },
+ "contacts": {
+ "title": "Kontakty",
+ "description": "Import a export kontaktů",
+ "group_by_letter_label": "Seskupit podle prvního písmene",
+ "group_by_letter_description": "Zobrazovat abecední nadpisy v seznamu kontaktů",
+ "import_label": "Importovat kontakty",
+ "import_description": "Importovat kontakty ze souboru vCard (.vcf)",
+ "export_label": "Exportovat kontakty",
+ "export_description": "Exportovat všechny kontakty jako soubor vCard (.vcf)",
+ "manage_title": "Adresáře",
+ "manage_description": "Přejmenovat své adresáře",
+ "no_address_books": "Nenalezeny žádné adresáře",
+ "categories_title": "Kategorie",
+ "categories_description": "Přejmenovat kategorie kontaktů",
+ "no_categories": "Nenalezeny žádné kategorie"
+ },
+ "filters": {
+ "title": "Filtry e-mailů",
+ "description": "Vytvářejte pravidla pro automatické třídění, štítkování a správu příchozích e-mailů",
+ "add_rule": "Přidat pravidlo",
+ "no_rules": "Žádná pravidla filtrů",
+ "no_rules_description": "Vytvářejte pravidla pro automatickou organizaci příchozích e-mailů",
+ "vacation_active": "Automatická odpověď je aktivní",
+ "vacation_active_description": "Automatická odpověď je zapnuta pro příchozí zprávy",
+ "vacation_configure": "Konfigurovat",
+ "edit_rule": "Upravit pravidlo",
+ "new_rule": "Nové pravidlo",
+ "delete_rule": "Odstranit pravidlo",
+ "delete_confirm": "Opravdu chcete toto pravidlo odstranit?",
+ "enable": "Povolit",
+ "disable": "Zakázat",
+ "raw_editor": "Textový editor Sieve",
+ "raw_editor_warning": "Úprava samotného skriptu Sieve může rozbít vizuální úpravu pravidel. Zde provedené změny nahradí vizuálního průvodce.",
+ "validate": "Ověřit",
+ "validation_success": "Skript je platný",
+ "validation_error": "Skript obsahuje chyby",
+ "save": "Uložit pravidla",
+ "saving": "Ukládání...",
+ "saved": "Filtry byly uloženy",
+ "save_failed": "Uložení filtrů selhalo",
+ "loading": "Načítání filtrů...",
+ "not_supported": "Váš poštovní server nepodporuje e-mailové filtry.",
+ "rule_name": "Název pravidla",
+ "rule_name_placeholder": "např. Třídit newslettery",
+ "match_all": "Splňuje VŠECHNY podmínky",
+ "match_any": "Splňuje JAKOUKOLI podmínku",
+ "conditions": "Podmínky",
+ "add_condition": "Přidat podmínku",
+ "actions": "Akce",
+ "add_action": "Přidat akci",
+ "stop_processing": "Zastavit zpracování dalších pravidel",
+ "condition_fields": {
+ "from": "Od",
+ "to": "Komu",
+ "cc": "Kopie",
+ "subject": "Předmět",
+ "header": "Vlastní hlavička",
+ "size": "Velikost",
+ "body": "Tělo zprávy"
+ },
+ "comparators": {
+ "contains": "obsahuje",
+ "not_contains": "neobsahuje",
+ "is": "je přesně",
+ "not_is": "není",
+ "starts_with": "začíná na",
+ "ends_with": "končí na",
+ "matches": "odpovídá vzoru",
+ "greater_than": "je větší než",
+ "less_than": "je menší než"
+ },
+ "action_types": {
+ "move": "Přesunout do složky",
+ "copy": "Kopírovat do složky",
+ "forward": "Přeposlat na",
+ "mark_read": "Označit jako přečtené",
+ "star": "Označit zprávu hvězdičkou",
+ "add_label": "Přidat štítek",
+ "discard": "Zahodit (tiše odstranit)",
+ "reject": "Odmítnout se zprávou",
+ "keep": "Ponechat v Doručených",
+ "stop": "Zastavit zpracování"
+ },
+ "move_to_folder": "Vyberte složku",
+ "copy_to_folder": "Vyberte složku",
+ "forward_to": "Přeposlat na e-mailovou adresu",
+ "forward_placeholder": "email@example.com",
+ "reject_message": "Zpráva o odmítnutí",
+ "reject_placeholder": "Váš e-mail byl odmítnut",
+ "label_name": "Název štítku",
+ "label_placeholder": "Vyberte štítek",
+ "header_name": "Název hlavičky",
+ "header_placeholder": "např. X-Mailing-List",
+ "size_bytes": "Velikost v bajtech",
+ "size_placeholder": "např. 1000000",
+ "system_managed": "Pravidlo spravováno systémem",
+ "opaque_warning": "Tento skript byl upraven mimo vizuálního průvodce. Je k dispozici pouze přímá úprava Sieve.",
+ "open_sieve_editor": "Otevřít textový editor Sieve",
+ "fetch_error": "Nepodařilo se načíst filtry",
+ "expanded_view": "Rozšířené zobrazení",
+ "expanded_view_description": "Zobrazovat pravidla filtrů s podrobnými bloky podmínek a akcí",
+ "if": "Pokud",
+ "then": "Pak",
+ "match_all_conditions": "všechny splněny",
+ "match_any_condition": "jakékoli splněny",
+ "and": "a",
+ "or": "nebo",
+ "cancel": "Zrušit",
+ "confirm_delete": "Odstranit",
+ "rule_list": "Pravidla filtrů",
+ "drag_to_reorder": "Přetáhněte pro změnu pořadí",
+ "match_type": "Typ shody",
+ "reset_to_visual": "Resetovat na vizuálního průvodce",
+ "reset_warning": "Tímto zahodíte aktuální skript a začnete znovu.",
+ "confirm_reset": "Resetovat",
+ "validation_empty_name": "Název pravidla je vyžadován",
+ "validation_empty_conditions": "Je vyžadována alespoň jedna podmínka s hodnotou",
+ "validation_empty_actions": "Je vyžadována alespoň jedna akce",
+ "templates_section": "Začít ze šablony",
+ "template_newsletters": "Přesunout newslettery do složky",
+ "template_receipts": "Automaticky archivovat účtenky",
+ "template_important": "Označit důležité e-maily",
+ "template_notifications": "Filtrovat oznámení",
+ "sieve_editor": {
+ "title": "Editor skriptů Sieve",
+ "warning": "Úprava samotného skriptu Sieve může rozbít vizuální úpravu pravidel. Zde provedené změny nahradí vizuálního průvodce.",
+ "script_content": "Skript Sieve",
+ "valid": "Skript je platný",
+ "invalid": "Skript obsahuje chyby",
+ "save_warning": "Uložení přepíše všechna vizuální pravidla. Tuto akci nelze vrátit zpět. Potvrďte opětovným kliknutím na Uložit.",
+ "validating": "Ověřování...",
+ "validate": "Ověřit",
+ "cancel": "Zrušit",
+ "save": "Uložit",
+ "confirm_save": "Potvrdit uložení",
+ "validation_failed": "Požadavek na ověření selhal"
+ },
+ "rule_summary": {
+ "conditions_count": "{count, plural, one {1 podmínka} few {# podmínky} other {# podmínek}}",
+ "actions_count": "{count, plural, one {1 akce} few {# akce} other {# akcí}}"
+ },
+ "origin_external": "Externí",
+ "managed_by_tooltip": "Spravováno aplikací {source}. Úpravy provádějte v příslušné aplikaci nebo použijte editor zdrojového kódu Sieve."
+ },
+ "templates": {
+ "title": "E-mailové šablony",
+ "description": "Vytvářejte opakovaně použitelné e-mailové šablony s proměnnými zástupnými symboly",
+ "add": "Nová šablona",
+ "edit": "Upravit šablonu",
+ "name": "Název šablony",
+ "name_placeholder": "např. Kontrolní zpráva",
+ "category": "Kategorie",
+ "category_placeholder": "např. Práce, Osobní",
+ "subject": "Předmět",
+ "subject_placeholder": "Předmět e-mailu",
+ "body": "Tělo zprávy",
+ "body_placeholder": "Text e-mailu...",
+ "recipients_placeholder": "email@example.com",
+ "identity": "Odeslat jako",
+ "default_identity": "Výchozí identita",
+ "favorite": "Oblíbené",
+ "cancel": "Zrušit",
+ "create": "Vytvořit",
+ "update": "Aktualizovat",
+ "confirm_delete": "Odstranit",
+ "no_templates": "Žádné šablony",
+ "manage": "Spravovat šablony",
+ "count": "{count, plural, one {1 šablona} few {# šablony} other {# šablon}}",
+ "export_import": "Export a import",
+ "export_import_description": "Vytvořte zálohu šablon nebo je přeneste do jiného zařízení",
+ "export": "Exportovat",
+ "import": "Importovat",
+ "validation": {
+ "empty": "Název šablony je vyžadován",
+ "too_long": "Název šablony může mít maximálně 200 znaků"
+ }
+ },
+ "files": {
+ "display": {
+ "title": "Zobrazení",
+ "description": "Nakonfigurujte způsob zobrazení souborů a složek"
+ },
+ "folder_layout": {
+ "label": "Navigace složek",
+ "description": "Vyberte způsob zobrazení složek: společně se soubory nebo ve stromové struktuře postranního panelu",
+ "inline": "Vložená",
+ "sidebar": "Postranní panel"
+ },
+ "default_view": {
+ "label": "Výchozí zobrazení",
+ "description": "Vyberte rozložení do mřížky nebo seznamu",
+ "list": "Seznam",
+ "grid": "Mřížka"
+ },
+ "default_sort": {
+ "label": "Výchozí řazení",
+ "description": "Vyberte výchozí řazení souborů",
+ "name": "Název",
+ "size": "Velikost",
+ "modified": "Změněno"
+ },
+ "sort_direction": {
+ "label": "Směr řazení",
+ "description": "Vyberte vzestupné nebo sestupné pořadí",
+ "ascending": "Vzestupně",
+ "descending": "Sestupně"
+ },
+ "icons": {
+ "title": "Ikony",
+ "description": "Nakonfigurujte vzhled ikon souborů"
+ },
+ "show_icons": {
+ "label": "Zobrazovat ikony souborů",
+ "description": "Zobrazovat ikony vedle souborů a složek"
+ },
+ "colored_icons": {
+ "label": "Barevné ikony",
+ "description": "Používat barevné ikony místo monochromatických"
+ },
+ "show_thumbnails": {
+ "label": "Zobrazovat miniatury",
+ "description": "Zobrazovat náhledy obrázků místo ikon pro obrazové soubory"
+ },
+ "behavior": {
+ "title": "Chování",
+ "description": "Nakonfigurujte chování prohlížeče souborů"
+ },
+ "show_hidden": {
+ "label": "Zobrazovat skryté soubory",
+ "description": "Zobrazovat soubory a složky začínající tečkou"
+ },
+ "preview": {
+ "label": "Náhled"
+ }
+ }
+ },
+ "errors": {
+ "page_error_title": "Něco se pokazilo",
+ "page_error_description": "Došlo k neočekávané chybě. Zkuste to znovu nebo se vraťte na domovskou stránku.",
+ "sidebar_error": "Nepodařilo se načíst poštovní schránky",
+ "email_list_error": "Nepodařilo se načíst zprávy",
+ "viewer_error_title": "Zprávu nelze zobrazit",
+ "viewer_error_description": "Během vykreslování této zprávy došlo k problému. Může obsahovat nepodporovaný obsah.",
+ "composer_error": "Editor zpráv se nepodařilo načíst",
+ "settings_error_title": "Nastavení jsou nedostupná",
+ "settings_error_description": "Nepodařilo se načíst nastavení. Vaše předvolby možná nebudou uloženy.",
+ "try_again": "Zkusit znovu",
+ "reload": "Obnovit",
+ "reload_emails": "Obnovit zprávy",
+ "reload_settings": "Obnovit nastavení",
+ "retry": "Opakovat",
+ "go_home": "Přejít do doručené pošty"
+ },
+ "context_menu": {
+ "reply": "Odpovědět",
+ "reply_all": "Odpovědět všem",
+ "forward": "Přeposlat",
+ "mark_read": "Označit jako přečtené",
+ "mark_unread": "Označit jako nepřečtené",
+ "star": "Označit hvězdičkou",
+ "unstar": "Odebrat hvězdičku",
+ "move_to": "Přesunout do...",
+ "archive": "Archivovat",
+ "delete": "Odstranit",
+ "mark_as_spam": "Nahlásit spam",
+ "not_spam": "Není spam",
+ "color_tag": "Štítek",
+ "remove_color": "Odebrat štítek",
+ "items_selected": "{count} vybraných zpráv",
+ "edit_draft": "Upravit koncept"
+ },
+ "shortcuts": {
+ "title": "Klávesové zkratky",
+ "tip": "Kdykoli stiskněte ? pro zobrazení této nápovědy",
+ "sections": {
+ "navigation": "Navigace",
+ "actions": "Akce zprávy",
+ "global": "Globální",
+ "threads": "Vlákna",
+ "composer": "Editor zpráv"
+ },
+ "navigation": {
+ "next_email": "Další zpráva",
+ "previous_email": "Předchozí zpráva",
+ "open_email": "Otevřít zprávu",
+ "close_email": "Zavřít / Zrušit výběr"
+ },
+ "actions": {
+ "reply": "Odpovědět",
+ "reply_all": "Odpovědět všem",
+ "forward": "Přeposlat",
+ "star": "Přepnout hvězdičku",
+ "archive": "Archivovat",
+ "delete": "Odstranit",
+ "mark_unread": "Označit jako nepřečtené",
+ "mark_read": "Označit jako přečtené",
+ "toggle_spam": "Nahlásit spam / Není spam"
+ },
+ "global": {
+ "compose": "Napsat novou zprávu",
+ "search": "Přejít do hledání",
+ "help": "Zobrazit zkratky",
+ "refresh": "Obnovit zprávy",
+ "select_all": "Vybrat vše"
+ },
+ "threads": {
+ "expand_collapse": "Rozbalit/sbalit vlákno"
+ },
+ "composer": {
+ "template_picker": "Otevřít výběr šablon"
+ }
+ },
+ "threads": {
+ "messages_one": "{count} zpráva",
+ "messages_other": "{count} zpráv",
+ "messages_tooltip": "{count, plural, one {1 zpráva v této konverzaci} few {# zprávy v této konverzaci} other {# zpráv v této konverzaci}}",
+ "expand": "Rozbalit konverzaci",
+ "collapse": "Sbalit konverzaci",
+ "loading": "Načítání konverzace...",
+ "mark_read": "Označit konverzaci jako přečtenou",
+ "mark_unread": "Označit konverzaci jako nepřečtenou",
+ "archive": "Archivovat konverzaci",
+ "delete": "Odstranit konverzaci",
+ "star": "Označit konverzaci hvězdičkou",
+ "unstar": "Odebrat hvězdičku z konverzace",
+ "toggle_thread": "Přepnout zobrazení vlákna"
+ },
+ "identities": {
+ "modal_title": "Správa identit odesílatele",
+ "create_new": "Vytvořit novou identitu",
+ "edit_identity": "Upravit identitu",
+ "delete_confirm": "Odstranit tuto identitu? Tuto akci nelze vrátit zpět.",
+ "cannot_delete": "Tuto identitu nelze odstranit",
+ "primary_identity": "Hlavní",
+ "set_as_primary": "Nastavit jako hlavní",
+ "no_identities": "Identity nenalezeny",
+ "display": {
+ "reply_to": "Odpovědět na:",
+ "bcc": "Skrytá kopie:",
+ "signature": "Podpis:",
+ "preview": "Náhled:"
+ },
+ "validation_errors": {
+ "invalid_emails": "Neplatné e-mailové adresy: {emails}",
+ "unknown_error": "Neznámá chyba"
+ },
+ "form": {
+ "name_label": "Zobrazované jméno",
+ "name_placeholder": "např. Pracovní e-mail, Soukromý",
+ "name_required": "Jméno je vyžadováno",
+ "email_label": "E-mailová adresa",
+ "email_placeholder": "vas.email@example.com",
+ "email_required": "E-mailová adresa je vyžadována",
+ "email_invalid": "Zadejte platnou e-mailovou adresu",
+ "email_immutable": "E-mailovou adresu nelze po vytvoření změnit",
+ "reply_to_label": "Odpovědět na (volitelně)",
+ "reply_to_placeholder": "jiny@email.com",
+ "bcc_label": "Automatická skrytá kopie (volitelně)",
+ "bcc_placeholder": "archiv@email.com",
+ "text_signature_label": "Textový podpis",
+ "html_signature_label": "HTML podpis",
+ "save": "Uložit identitu",
+ "cancel": "Zrušit",
+ "creating": "Vytváření...",
+ "updating": "Aktualizování..."
+ },
+ "sub_address": {
+ "button_tooltip": "Použít subadresu",
+ "popover_title": "Přidat štítek subadresy",
+ "tag_input_placeholder": "Zadejte štítek (např. nakupy)",
+ "preview_label": "Náhled:",
+ "recent_tags": "Nedávné štítky",
+ "suggested_tags": "Doporučené",
+ "use_address": "Použít tuto adresu",
+ "invalid_tag": "Štítek může obsahovat pouze písmena, číslice a pomlčky",
+ "tag_too_long": "Štítek může mít maximálně 30 znaků",
+ "help_text": "Zprávy odeslané na adresu uzivatel+stitek@domena.cz budou doručeny do vaší doručené pošty",
+ "validation": {
+ "empty": "Štítek nesmí být prázdný",
+ "too_long": "Štítek může mít maximálně {max} znaků",
+ "invalid_chars": "Štítek může obsahovat pouze písmena, číslice a pomlčky"
+ }
+ },
+ "badge": {
+ "sent_via": "přes",
+ "sub_address_tag": "Odesláno pomocí subadresy: {tag}",
+ "identity_name": "Odesláno pomocí identity: {name}",
+ "identity_short": "přes {name}",
+ "subaddress_tag": "+{tag}"
+ },
+ "delete_button": "Odstranit",
+ "delete_confirm_title": "Odstranit identitu"
+ },
+ "templates": {
+ "picker_title": "Vyberte šablonu",
+ "search_placeholder": "Hledat šablony...",
+ "section_favorites": "Oblíbené",
+ "section_recent": "Nedávné",
+ "section_uncategorized": "Ostatní",
+ "no_templates": "Zatím žádné šablony",
+ "no_results": "Nenalezeny žádné šablony",
+ "fill_placeholders": "Vyplnit hodnoty proměnných",
+ "enter_value": "Zadejte hodnotu...",
+ "preview": "Náhled",
+ "insert_with_values": "Vložit s hodnotami",
+ "insert_raw": "Vložit beze změn",
+ "copy_suffix": "(kopie)",
+ "placeholder": "Proměnná",
+ "placeholders": {
+ "recipient_name": "Jméno příjemce",
+ "company": "Název společnosti",
+ "date": "Aktuální datum",
+ "day_of_week": "Den v týdnu",
+ "sender_name": "Vaše jméno"
+ }
+ },
+ "contacts": {
+ "title": "Kontakty",
+ "search_placeholder": "Hledat kontakty...",
+ "create_new": "Nový kontakt",
+ "no_category": "Bez kategorie",
+ "rename_category": "Přejmenovat kategorii",
+ "category_name_label": "Název kategorie",
+ "category_renamed": "Kategorie byla přejmenována",
+ "category_rename_failed": "Přejmenování kategorie selhalo",
+ "category_added": "Kontakt přidán do {name}",
+ "category_added_plural": "{count} kontaktů bylo přidáno do {name}",
+ "empty_state": "Žádné kontakty",
+ "empty_state_title": "Žádné kontakty",
+ "empty_state_subtitle": "Vytvořte první kontakt nebo importujte ze souboru vCard",
+ "empty_search": "Hledání neodpovídají žádné kontakty",
+ "empty_search_hint": "Zkuste použít jiný hledaný výraz",
+ "empty_filtered": "Žádné kontakty neodpovídají filtrům",
+ "empty_filtered_hint": "Zkuste filtry upravit nebo zrušit",
+ "clear_search": "Vymazat hledání",
+ "import_vcard": "Importovat vCard",
+ "delete_confirm_title": "Odstranit kontakt",
+ "delete_confirm": "Opravdu chcete odstranit tento kontakt?",
+ "local_mode": "Kontakty jsou uloženy lokálně (server nepodporuje JMAP Contacts)",
+ "back_to_contacts": "Zpět na kontakty",
+ "tabs": {
+ "all": "Všechny",
+ "groups": "Skupiny"
+ },
+ "shared": {
+ "title": "Sdílené"
+ },
+ "address_books": {
+ "title": "Moje adresáře",
+ "shared_prefix": "Sdílené: {name}",
+ "moved": "Kontakt přesunut do {name}",
+ "moved_plural": "{count} kontaktů bylo přesunuto do {name}",
+ "move_failed": "Přesun kontaktu selhal",
+ "address_book": "Adresář",
+ "rename": "Přejmenovat adresář",
+ "name_label": "Název adresáře",
+ "renamed": "Adresář byl přejmenován",
+ "rename_failed": "Přejmenování adresáře selhalo",
+ "default": "Výchozí",
+ "manage": "Spravovat adresáře"
+ },
+ "detail": {
+ "emails": "E-mailové adresy",
+ "phones": "Telefonní čísla",
+ "organizations": "Organizace",
+ "addresses": "Adresy",
+ "notes": "Poznámky",
+ "titles": "Pozice a role",
+ "online_services": "Online služby",
+ "anniversaries": "Výročí",
+ "personal_info": "Osobní informace",
+ "languages": "Jazyky",
+ "categories": "Kategorie",
+ "related_contacts": "Související kontakty",
+ "crypto_keys": "Kryptografické klíče",
+ "cert_issuer": "Vystavitel",
+ "cert_expires": "Vyprší",
+ "cert_expired": "Vypršela platnost",
+ "cert_fingerprint": "Otisk",
+ "cert_algorithm": "Algoritmus",
+ "import_to_smime": "Importovat do S/MIME",
+ "cert_already_imported": "Již importováno do S/MIME",
+ "cert_imported": "Certifikát byl importován do úložiště S/MIME",
+ "cert_import_failed": "Import certifikátu selhal",
+ "no_contact_selected": "Vyberte kontakt pro zobrazení podrobností",
+ "compose_email": "Napsat e-mail",
+ "copy_email": "Kopírovat e-mail",
+ "copy_phone": "Kopírovat telefon",
+ "copy_url": "Kopírovat URL",
+ "copied": "Zkopírováno do schránky",
+ "copy_failed": "Kopírování do schránky selhalo",
+ "created": "Vytvořeno",
+ "updated": "Poslední aktualizace",
+ "timezone": "Časové pásmo",
+ "anniversary_birth": "Narozeniny",
+ "anniversary_death": "Úmrtí",
+ "anniversary_wedding": "Výročí svatby",
+ "anniversary_other": "Ostatní",
+ "personal_expertise": "Odbornost",
+ "personal_hobby": "Koníčky",
+ "personal_interest": "Zájmy",
+ "personal_other": "Ostatní",
+ "gender": "Pohlaví",
+ "gender_masculine": "Muž",
+ "gender_feminine": "Žena",
+ "gender_other": "Jiné",
+ "gender_none": "Neuvádí se",
+ "gender_unknown": "Neznámé",
+ "calendar": "Kalendář",
+ "calendar_uri": "URL kalendáře",
+ "scheduling_uri": "URL pro plánování",
+ "freebusy_uri": "URL dostupnosti",
+ "section_contact": "Kontaktní údaje",
+ "section_work": "Práce",
+ "section_personal": "Osobní",
+ "email_default_label": "E-mail",
+ "phone_default_label": "Telefon",
+ "address_default_label": "Adresa",
+ "online_service_default_label": "Online",
+ "organization_label": "Organizace",
+ "title_label": "Pozice",
+ "role_label": "Role",
+ "language_label": "Jazyk",
+ "related_default_label": "Vztahy",
+ "more_actions": "Další akce",
+ "age_years": "{count, plural, one {1 rok} few {# roky} other {# let}}",
+ "years_since": "{count, plural, one {1 rok} few {# roky} other {# let}}"
+ },
+ "activity": {
+ "recent_emails": "Nedávné e-maily",
+ "upcoming_events": "Nadcházející události",
+ "no_emails": "Žádné nedávné e-maily",
+ "no_events": "Žádné nadcházející události",
+ "no_subject": "(Bez předmětu)",
+ "no_title": "(Bez názvu)",
+ "load_failed": "Nepodařilo se načíst",
+ "unknown_sender": "Neznámý odesílatel",
+ "all_day": "Celý den"
+ },
+ "form": {
+ "create_title": "Nový kontakt",
+ "edit_title": "Upravit kontakt",
+ "section_address_book": "Adresář",
+ "select_address_book": "Vyberte adresář...",
+ "section_identity": "Jméno a identita",
+ "section_work": "Práce a organizace",
+ "prefix": "Titul",
+ "prefix_placeholder": "Dr., Pan, Paní",
+ "given_name": "Jméno",
+ "middle_name": "Prostřední jméno",
+ "surname": "Příjmení",
+ "suffix": "Přípona",
+ "suffix_placeholder": "Jr., Sr., Ph.D.",
+ "nickname": "Přezdívka",
+ "nickname_placeholder": "Přezdívka",
+ "email": "E-mail",
+ "email_placeholder": "email@example.com",
+ "phone": "Telefon",
+ "phone_placeholder": "+420 123 456 789",
+ "phone_type": "Typ",
+ "phone_voice": "Hlasový",
+ "phone_cell": "Mobilní",
+ "phone_fax": "Fax",
+ "phone_pager": "Pager",
+ "phone_video": "Video",
+ "phone_text": "SMS",
+ "organization": "Organizace",
+ "organization_placeholder": "Název společnosti",
+ "department": "Oddělení",
+ "department_placeholder": "Oddělení",
+ "job_title": "Pozice",
+ "job_title_placeholder": "např. Softwarový inženýr",
+ "role": "Role",
+ "role_placeholder": "např. Vedoucí týmu",
+ "addresses": "Adresy",
+ "add_address": "Přidat adresu",
+ "street": "Ulice",
+ "city": "Město",
+ "region": "Stát / Kraj",
+ "postcode": "PSČ",
+ "country": "Země",
+ "online_services": "Online služby",
+ "add_online_service": "Přidat online službu",
+ "url_placeholder": "https://...",
+ "service_placeholder": "Služba",
+ "anniversaries": "Výročí",
+ "add_anniversary": "Přidat datum",
+ "anniversary_birth": "Narozeniny",
+ "anniversary_wedding": "Výročí svatby",
+ "anniversary_death": "Úmrtí",
+ "anniversary_other": "Ostatní",
+ "personal_info": "Osobní informace",
+ "add_personal_info": "Přidat záznam",
+ "personal_info_placeholder": "např. Fotografie",
+ "personal_expertise": "Odbornost",
+ "personal_hobby": "Koníčky",
+ "personal_interest": "Zájmy",
+ "personal_other": "Ostatní",
+ "level": "Úroveň",
+ "level_high": "Vysoká",
+ "level_medium": "Střední",
+ "level_low": "Nízká",
+ "categories": "Kategorie",
+ "categories_placeholder": "např. Rodina, Přátelé, Kolegové",
+ "categories_hint": "Pište pro vyhledání nebo přidání kategorií",
+ "category_add": "Přidat",
+ "note": "Poznámky",
+ "note_placeholder": "Přidat poznámku...",
+ "gender": "Pohlaví",
+ "gender_sex": "Biologické pohlaví",
+ "gender_male": "Muž",
+ "gender_female": "Žena",
+ "gender_other": "Jiné",
+ "gender_none": "Neuvádí se",
+ "gender_unknown": "Neznámé",
+ "gender_identity": "Genderová identita",
+ "gender_identity_placeholder": "Genderová identita...",
+ "calendar": "Kalendář",
+ "calendar_uri": "URL kalendáře",
+ "scheduling_uri": "URL pro plánování",
+ "freebusy_uri": "URL volný/obsazený",
+ "context_work": "Pracovní",
+ "context_private": "Soukromé",
+ "add_email": "Přidat e-mail",
+ "add_phone": "Přidat telefon",
+ "save": "Uložit",
+ "cancel": "Zrušit",
+ "creating": "Vytváření...",
+ "updating": "Aktualizování...",
+ "name_required": "Je vyžadováno alespoň jméno nebo příjmení",
+ "email_invalid": "Zadejte platnou e-mailovou adresu",
+ "email_error_inline": "Neplatný formát e-mailové adresy",
+ "save_failed": "Uložení kontaktu selhalo",
+ "delete": "Odstranit",
+ "upload_photo": "Nahrát fotku",
+ "remove_photo": "Odebrat fotku",
+ "photo_hint": "JPG nebo PNG, max. 10 MB. Velikost se upraví.",
+ "photo_too_large": "Obrázek je moc velký (max. 10 MB)",
+ "photo_invalid": "Neplatný formát obrázku",
+ "change_photo": "Změnit"
+ },
+ "groups": {
+ "create": "Nová skupina",
+ "edit": "Upravit skupinu",
+ "empty": "Žádné skupiny",
+ "delete_confirm_title": "Odstranit skupinu",
+ "delete_confirm": "Opravdu chcete odstranit tuto skupinu?",
+ "name_label": "Název skupiny",
+ "name_placeholder": "např. Tým, Rodina",
+ "name_required": "Název skupiny je vyžadován",
+ "save_failed": "Uložení skupiny selhalo",
+ "members_label": "Členové",
+ "search_members": "Hledat kontakty k přidání...",
+ "no_members": "Tato skupina nemá žádné členy",
+ "member_count": "{count, plural, =0 {Žádní členové} one {1 člen} few {# členové} other {# členů}}"
+ },
+ "import": {
+ "title": "Importovat kontakty",
+ "drop_hint": "Klikněte pro výběr souboru vCard",
+ "file_types": "soubory .vcf nebo .vcard",
+ "no_contacts": "V souboru nebyly nalezeny žádné kontakty",
+ "parse_error": "Zpracování souboru vCard selhalo",
+ "found": "{count, plural, one {Nalezen 1 kontakt} few {Nalezeny # kontakty} other {Nalezeno # kontaktů}}",
+ "duplicate": "Duplikát",
+ "select_all": "Vybrat vše",
+ "deselect_all": "Zrušit výběr všeho",
+ "selected": "{count, plural, one {1 vybrán} few {# vybrány} other {# vybráno}}",
+ "import_button": "Importovat",
+ "importing": "Importování...",
+ "success": "{count, plural, one {Importován 1 kontakt} few {Importovány # kontakty} other {Importováno # kontaktů}}",
+ "failed": "Import selhal",
+ "close": "Zavřít",
+ "file_too_large": "Soubor je příliš velký (max. 5 MB)"
+ },
+ "export": {
+ "title": "Exportovat kontakty",
+ "success": "{count, plural, one {Exportován 1 kontakt} few {Exportovány # kontakty} other {Exportováno # kontaktů}}"
+ },
+ "bulk": {
+ "selected": "{count, plural, one {1 vybrán} few {# vybrány} other {# vybráno}}",
+ "select_all": "Vybrat vše",
+ "delete": "Odstranit",
+ "delete_confirm_title": "Odstranit kontakty",
+ "delete_confirm": "Odstranit {count, plural, one {1 kontakt} few {# kontakty} other {# kontaktů}}?",
+ "deleted": "{count, plural, one {Odstraněn 1 kontakt} few {Odstraněny # kontakty} other {Odstraněno # kontaktů}}",
+ "add_to_group": "Přidat do skupiny",
+ "choose_group": "Vyberte skupinu",
+ "adding_contacts": "Přidávání {count, plural, one {1 kontaktu} few {# kontaktů} other {# kontaktů}}",
+ "added_to_group": "Kontakty byly přidány do skupiny",
+ "export": "Exportovat",
+ "clear": "Zrušit výběr"
+ },
+ "toast": {
+ "created": "Kontakt byl vytvořen",
+ "updated": "Kontakt byl aktualizován",
+ "deleted": "Kontakt byl odstraněn",
+ "error_create": "Vytvoření kontaktu selhalo",
+ "error_update": "Aktualizace kontaktu selhala",
+ "error_delete": "Odstranění kontaktu selhalo"
+ },
+ "context_menu": {
+ "open": "Otevřít",
+ "edit": "Upravit",
+ "send_email": "Poslat e-mail",
+ "add_to_group": "Přidat do skupiny",
+ "export_vcard": "Exportovat jako vCard",
+ "delete": "Smazat",
+ "call": "Zavolat",
+ "duplicate": "Duplikovat",
+ "print": "Vytisknout"
+ },
+ "filters": {
+ "toggle": "Filtry",
+ "select": "Vybrat",
+ "clear": "Vymazat",
+ "close": "Zavřít",
+ "title": "Pokročilé filtry",
+ "organization": "Firma",
+ "organization_placeholder": "např. Acme Corp",
+ "job_title": "Pozice",
+ "job_title_placeholder": "např. Designér",
+ "location": "Lokalita",
+ "location_placeholder": "Město nebo země",
+ "email_domain": "E-mailová doména",
+ "email_domain_placeholder": "example.com",
+ "birthday_month": "Narozeniny v",
+ "any_month": "Kterýkoli měsíc",
+ "has_email": "Má e-mail",
+ "has_phone": "Má telefon",
+ "has_photo": "Má fotku"
+ }
+ },
+ "calendar": {
+ "title": "Kalendář",
+ "back_to_email": "Zpět na poštu",
+ "back_to_month": "Zpět na měsíc",
+ "my_calendars": "Kalendáře",
+ "birthday_calendar": "Narozeniny",
+ "mini_calendar_change": "Kliknutím změníte měsíc",
+ "views": {
+ "month": "Měsíc",
+ "week": "Týden",
+ "day": "Den",
+ "agenda": "Agenda",
+ "today": "Dnes",
+ "month_hint": "Měsíc (m)",
+ "week_hint": "Týden (w)",
+ "day_hint": "Den (d)",
+ "agenda_hint": "Agenda (a)",
+ "tasks": "Úkoly",
+ "tasks_hint": "Úkoly (k)"
+ },
+ "events": {
+ "create": "Vytvořit událost",
+ "edit": "Upravit událost",
+ "delete": "Odstranit událost",
+ "details": "Podrobnosti události",
+ "no_events": "Žádné události",
+ "all_day": "Celý den",
+ "more": "+{count} dalších",
+ "no_title": "(Bez názvu)",
+ "resize": "Změnit velikost události",
+ "duplicate": "Duplikovat",
+ "today_header": "Dnes",
+ "tomorrow_header": "Zítra",
+ "export_ics": "Exportovat jako .ics",
+ "copy_title": "Kopírovat název",
+ "copy_link": "Kopírovat odkaz na schůzku"
+ },
+ "detail": {
+ "add_note": "Přidat poznámku...",
+ "save_note": "Uložit",
+ "note_saved": "Poznámka přidána",
+ "open_link": "Otevřít odkaz",
+ "meeting_link": "Odkaz na schůzku",
+ "tentative": "Nezávazně",
+ "cancelled": "Zrušeno",
+ "delete_confirm": "Odstranit tuto událost?"
+ },
+ "form": {
+ "title": "Název",
+ "description": "Popis",
+ "location": "Místo",
+ "meeting_link": "Odkaz na schůzku",
+ "start_date": "Datum zahájení",
+ "end_date": "Datum ukončení",
+ "start_time": "Čas zahájení",
+ "end_time": "Čas ukončení",
+ "all_day_event": "Celodenní událost",
+ "calendar_select": "Kalendář",
+ "save": "Uložit",
+ "cancel": "Zrušit",
+ "delete_confirm": "Opravdu chcete odstranit tuto událost?",
+ "color": "Barva"
+ },
+ "participants": {
+ "title": "Účastníci",
+ "add": "Přidat účastníka",
+ "organizer": "Organizátor",
+ "attendee": "Účastník",
+ "accepted": "Přijato",
+ "declined": "Odmítnuto",
+ "tentative": "Nezávazně",
+ "needs_action": "Vyžaduje akci",
+ "remove": "Odstranit",
+ "edit": "Upravit",
+ "email_placeholder": "Přidejte e-mailovou adresu nebo vyhledejte kontakty",
+ "send_invitations": "Odeslat pozvánky účastníkům",
+ "status_summary": "{accepted} přijato, {pending} čeká",
+ "invited_by": "Pozvánka od {name}",
+ "respond_below": "Odpovězte pomocí tlačítek níže",
+ "rsvp_label": "Vaše odpověď",
+ "cancel_notification": "Účastníci budou upozorněni na zrušení",
+ "you_organizer": "Jste organizátorem",
+ "you_attendee": "Jste účastníkem",
+ "no_participants": "Žádní účastníci",
+ "count": "{count, plural, one {1 účastník} few {# účastníci} other {# účastníků}}"
+ },
+ "recurrence": {
+ "title": "Opakování",
+ "none": "Neopakuje se",
+ "daily": "Denně",
+ "weekly": "Týdně",
+ "monthly": "Měsíčně",
+ "yearly": "Ročně",
+ "every_n_days": "Každých {count} dní",
+ "every_n_weeks": "Každých {count} týdnů",
+ "every_n_months": "Každých {count} měsíců",
+ "until": "Do",
+ "occurrences": "{count} opakování"
+ },
+ "recurrence_scope": {
+ "edit_title": "Upravit opakující se událost",
+ "delete_title": "Odstranit opakující se událost",
+ "description": "Toto je opakující se událost. Které události chcete upravit?",
+ "this_event": "Pouze tuto událost",
+ "this_and_future": "Tuto a všechny následující události",
+ "all_events": "Všechny události",
+ "cancel": "Zrušit",
+ "save": "Uložit",
+ "delete": "Odstranit"
+ },
+ "alerts": {
+ "title": "Připomenutí",
+ "none": "Žádné připomenutí",
+ "at_time": "V čase události",
+ "minutes_before": "{count, plural, one {1 minutu před} few {# minuty před} other {# minut před}}",
+ "hours_before": "{count, plural, one {1 hodinu před} few {# hodiny před} other {# hodin před}}",
+ "days_before": "{count, plural, one {1 den před} few {# dny před} other {# dnů před}}"
+ },
+ "settings": {
+ "title": "Nastavení kalendáře",
+ "default_view": "Výchozí zobrazení",
+ "week_starts_on": "Týden začíná v",
+ "time_format": "Formát času",
+ "default_calendar": "Výchozí kalendář",
+ "default_reminder": "Výchozí připomenutí",
+ "time_format_12h": "12hodinový",
+ "time_format_24h": "24hodinový",
+ "notifications_enabled": "Oznámení o událostech",
+ "notifications_enabled_desc": "Zobrazovat upozornění na nadcházející události v kalendáři",
+ "notification_sound": "Zvuk oznámení",
+ "notification_sound_desc": "Přehrát zvukové upozornění pro kalendář",
+ "invitation_parsing": "Analyzovat e-mailové pozvánky",
+ "invitation_parsing_desc": "Rozpoznávat pozvánky kalendáře v přílohách e-mailů a zobrazovat akce kalendáře",
+ "show_time_in_month_view": "Zobrazit čas v měsíčním zobrazení",
+ "show_time_in_month_view_desc": "Zobrazovat časy událostí v měsíčním zobrazení kalendáře",
+ "show_week_numbers": "Zobrazit čísla týdnů",
+ "show_week_numbers_desc": "Zobrazovat čísla týdnů v minikalendáři",
+ "enable_tasks": "Povolit úkoly",
+ "enable_tasks_desc": "Zobrazovat zobrazení úkolů v kalendáři pro správu úkolů",
+ "show_tasks_on_calendar": "Zobrazit úkoly v kalendáři",
+ "show_tasks_on_calendar_desc": "Zobrazovat značky úkolů v denním a týdenním zobrazení kalendáře",
+ "hover_preview": "Náhled události po přejetí",
+ "hover_preview_desc": "Zobrazovat vyskakovací okno s podrobnostmi při najetí myší na události v kalendáři",
+ "hover_preview_instant": "Okamžitě",
+ "hover_preview_delay_500ms": "Zpoždění 0,5 sekundy",
+ "hover_preview_delay_1s": "Zpoždění 1 sekunda",
+ "hover_preview_delay_2s": "Zpoždění 2 sekundy",
+ "hover_preview_off": "Vypnuto",
+ "show_birthday_calendar": "Kalendář narozenin",
+ "show_birthday_calendar_desc": "Zobrazovat virtuální kalendář s narozeninami z vašich kontaktů"
+ },
+ "days": {
+ "monday": "Pondělí",
+ "tuesday": "Úterý",
+ "wednesday": "Středa",
+ "thursday": "Čtvrtek",
+ "friday": "Pátek",
+ "saturday": "Sobota",
+ "sunday": "Neděle",
+ "mon": "Po",
+ "tue": "Út",
+ "wed": "St",
+ "thu": "Čt",
+ "fri": "Pá",
+ "sat": "So",
+ "sun": "Ne"
+ },
+ "notifications": {
+ "event_created": "Událost byla vytvořena",
+ "event_updated": "Událost byla aktualizována",
+ "event_deleted": "Událost byla odstraněna",
+ "calendar_created": "Kalendář byl vytvořen",
+ "calendar_deleted": "Kalendář byl odstraněn",
+ "event_move_error": "Přesun události selhal",
+ "event_resize_error": "Změna velikosti události selhala",
+ "alert_title": "Nadcházející událost",
+ "alert_now": "Začíná nyní",
+ "alert_in_minutes": "Za {count} min",
+ "invitation_sent": "Pozvánky byly odeslány",
+ "rsvp_updated": "Odpověď byla aktualizována",
+ "rsvp_error": "Aktualizace odpovědi selhala",
+ "event_duplicated": "Událost byla duplikována",
+ "event_error": "Uložení události selhalo",
+ "task_due": "Termín úkolu",
+ "event_exported": "Událost exportována",
+ "title_copied": "Název zkopírován",
+ "link_copied": "Odkaz zkopírován"
+ },
+ "status": {
+ "loading_calendars": "Načítání kalendářů...",
+ "loading_events": "Načítání událostí..."
+ },
+ "quick_create": {
+ "placeholder": "Název nové události",
+ "aria_label": "Rychlé vytvoření události"
+ },
+ "nav_prev": "Předchozí",
+ "nav_next": "Další",
+ "import": {
+ "title": "Importovat kalendář",
+ "tab_file": "Soubor",
+ "tab_url": "URL",
+ "select_file": "Vyberte soubor .ics",
+ "drop_file": "nebo sem přetáhněte soubor",
+ "supported_formats": "Podporuje soubory iCalendar (.ics)",
+ "url_description": "Zadejte adresu URL externího zdroje iCalendar (.ics) pro import událostí.",
+ "url_placeholder": "https://example.com/calendar.ics",
+ "url_hint": "Podporuje adresy URL CalDAV a iCalendar (.ics)",
+ "fetch": "Načíst",
+ "invalid_url": "Zadejte platnou adresu URL",
+ "url_fetch_failed": "Načtení kalendáře z adresy URL selhalo",
+ "parsing": "Analyzování souboru kalendáře...",
+ "parsed_events": "Nalezeno {count} událostí",
+ "no_events": "V souboru nebyly nalezeny žádné události",
+ "select_all": "Vybrat vše",
+ "deselect_all": "Zrušit výběr všeho",
+ "target_calendar": "Importovat do kalendáře",
+ "import_button": "Importovat vybrané",
+ "importing": "Importování událostí...",
+ "success": "Úspěšně importováno {count} událostí",
+ "error": "Import kalendáře selhal",
+ "file_too_large": "Soubor překračuje limit 10 MB",
+ "invalid_format": "Neplatný formát souboru kalendáře"
+ },
+ "management": {
+ "title": "Správa kalendáře",
+ "description": "Vytvářejte, přejmenovávejte a přizpůsobujte si své kalendáře. Klikněte pravým tlačítkem na kalendář v postranním panelu pro rychlou změnu jeho barvy.",
+ "name": "Název",
+ "name_placeholder": "Název kalendáře",
+ "color": "Barva",
+ "change_color": "Změnit barvu",
+ "add_calendar": "Přidat kalendář",
+ "edit": "Upravit",
+ "delete": "Odstranit",
+ "save": "Uložit",
+ "create": "Vytvořit",
+ "cancel": "Zrušit",
+ "default": "Výchozí",
+ "confirm_delete": "Odstranit \"{name}\"? Všechny události v tomto kalendáři budou odstraněny.",
+ "confirm_clear": "Vymazat všechny události z \"{name}\"? Tuto akci nelze vrátit zpět.",
+ "clear_events": "Vymazat události",
+ "events_cleared": "Vymazáno {count} událostí",
+ "error_clear": "Vymazání událostí kalendáře selhalo",
+ "calendar_created": "Kalendář byl vytvořen",
+ "calendar_updated": "Kalendář byl aktualizován",
+ "calendar_deleted": "Kalendář byl odstraněn",
+ "color_updated": "Barva kalendáře byla aktualizována",
+ "error_create": "Vytvoření kalendáře selhalo",
+ "error_update": "Aktualizace kalendáře selhala",
+ "error_delete": "Odstranění kalendáře selhalo",
+ "caldav_url": "URL CalDAV",
+ "copy_url": "Kopírovat URL CalDAV",
+ "url_copied": "URL CalDAV zkopírováno do schránky"
+ },
+ "subscription": {
+ "title": "Odběr iCal",
+ "section_title": "Odběry iCal",
+ "description": "Odebírejte externí zdroj iCalendar. Události budou automaticky synchronizovány do samostatného kalendáře. Podporuje adresy URL https:// a webcal://.",
+ "url_label": "URL kalendáře",
+ "url_placeholder": "https://example.com/calendar.ics nebo webcal://...",
+ "name_label": "Název kalendáře",
+ "name_placeholder": "např. Státní svátky",
+ "color_label": "Barva",
+ "refresh_interval": "Interval obnovy",
+ "interval_15": "Každých 15 minut",
+ "interval_30": "Každých 30 minut",
+ "interval_60": "Každou hodinu",
+ "interval_360": "Každých 6 hodin",
+ "interval_1440": "Denně",
+ "subscribe": "Odebírat",
+ "subscribing": "Přihlašování k odběru...",
+ "save": "Uložit změny",
+ "saving": "Ukládání...",
+ "edit": "Upravit",
+ "edit_title": "Upravit odběr",
+ "updated": "Aktualizováno \"{name}\"",
+ "update_error": "Aktualizace odběru selhala",
+ "invalid_url": "Zadejte platnou adresu URL",
+ "success": "Odebíráte \"{name}\"",
+ "error": "Přidání odběru selhalo",
+ "refresh": "Obnovit nyní",
+ "refresh_success": "Odběr byl obnoven",
+ "refresh_error": "Obnovení odběru selhalo",
+ "unsubscribe": "Zrušit odběr",
+ "confirm_delete": "Zrušit odběr \"{name}\"? Kalendář a všechny jeho události budou odstraněny.",
+ "deleted": "Odběr byl odstraněn",
+ "delete_error": "Odstranění odběru selhalo",
+ "last_refreshed": "Poslední aktualizace: {time}"
+ },
+ "tasks": {
+ "label": "Úkoly",
+ "no_tasks": "Žádné úkoly",
+ "no_title": "(Bez názvu)",
+ "mark_complete": "Označit jako dokončené",
+ "mark_incomplete": "Označit jako nedokončené",
+ "filter_all": "Všechny",
+ "filter_pending": "Čekající",
+ "filter_completed": "Dokončené",
+ "filter_overdue": "Po termínu",
+ "show_completed": "Zobrazit dokončené",
+ "create": "Nový úkol",
+ "edit": "Upravit úkol",
+ "title_placeholder": "Název úkolu",
+ "description_placeholder": "Přidat popis...",
+ "due_date": "Termín",
+ "include_time": "Zahrnout čas",
+ "priority": "Priorita",
+ "priority_none": "Žádná",
+ "priority_high": "Vysoká",
+ "priority_medium": "Střední",
+ "priority_low": "Nízká",
+ "progress": "Stav",
+ "progress_needs_action": "Vyžaduje akci",
+ "progress_in_process": "Probíhá",
+ "progress_completed": "Dokončeno",
+ "progress_cancelled": "Zrušeno",
+ "calendar": "Kalendář",
+ "alert": "Připomenutí",
+ "alert_none": "Žádné",
+ "alert_at_time": "V čase termínu",
+ "alert_5min": "5 minut před",
+ "alert_15min": "15 minut před",
+ "alert_30min": "30 minut před",
+ "alert_1hr": "1 hodinu před",
+ "alert_1day": "1 den před",
+ "delete": "Odstranit",
+ "cancel": "Zrušit",
+ "save": "Uložit",
+ "quick_add_placeholder": "Přidat úkol...",
+ "due_today": "Dnes",
+ "due_tomorrow": "Zítra",
+ "overdue": "Po termínu"
+ }
+ },
+ "advanced_search": {
+ "title": "Pokročilé hledání",
+ "from": "Od",
+ "from_placeholder": "E-mailová adresa nebo jméno odesílatele",
+ "to": "Komu",
+ "to_placeholder": "E-mailová adresa nebo jméno příjemce",
+ "subject": "Předmět",
+ "subject_placeholder": "Předmět obsahuje...",
+ "body": "Tělo zprávy",
+ "body_placeholder": "Tělo zprávy obsahuje...",
+ "folder": "Složka",
+ "all_folders": "Všechny složky",
+ "has_attachment": "Obsahuje přílohu",
+ "date_after": "Po",
+ "date_before": "Před",
+ "starred": "S hvězdičkou",
+ "unread": "Nepřečtené",
+ "read": "Přečtené",
+ "yes": "Ano",
+ "no": "Ne",
+ "clear": "Vymazat",
+ "clear_all": "Vymazat vše",
+ "filters_active": "{count} filtr",
+ "filters_active_plural": "{count} filtry",
+ "toggle_filters": "Více",
+ "search_hint": "Použijte pokročilé filtry pro přesné vyhledávání",
+ "advanced_filters_tooltip": "Pokročilé filtry hledání",
+ "results_found": "{count, plural, =0 {Nenalezeny žádné výsledky} one {Nalezen 1 výsledek} few {Nalezeny # výsledky} other {Nalezeno # výsledků}}",
+ "results_found_more": "Nalezeno více než {count} výsledků"
+ },
+ "welcome": {
+ "title": "Vítejte ve své poštovní schránce",
+ "tip_compose": "Stiskněte c pro napsání nové zprávy",
+ "tip_shortcuts": "Stiskněte ? pro zobrazení všech klávesových zkratek",
+ "tip_sidebar": "Kalendář, Kontakty a Nastavení najdete v postranním panelu",
+ "tip_settings": "Přizpůsobte si aplikaci v Nastavení",
+ "got_it": "Rozumím",
+ "settings": "Nastavení",
+ "dismiss": "Zavřít",
+ "start_tour": "Spustit průvodce"
+ },
+ "demo_welcome": {
+ "title": "Vítejte v Bulwark Mail",
+ "description": "Prozkoumejte plně funkčního webového e-mailového klienta - přímo v prohlížeči. Všechna data zůstávají na vašem zařízení, takže můžete bez obav vše otestovat.",
+ "feature_email": "Čtěte a pište e-maily",
+ "feature_organize": "Štítky, hvězdičky a složky",
+ "feature_shortcuts": "Klávesové zkratky",
+ "feature_privacy": "100% soukromá demoverze",
+ "hint": "Klikněte na libovolnou zprávu vlevo pro začátek, nebo využijte průvodce níže."
+ },
+ "files": {
+ "title": "Soubory",
+ "search_placeholder": "Hledat soubory...",
+ "empty_state_title": "Žádné soubory",
+ "empty_state_description": "Nahrajte soubory nebo vytvořte složky pro začátek",
+ "upload": "Nahrát",
+ "upload_files": "Nahrát soubory",
+ "new_folder": "Nová složka",
+ "new_folder_name": "Název složky",
+ "rename": "Přejmenovat",
+ "rename_title": "Přejmenovat",
+ "new_name": "Nový název",
+ "delete": "Odstranit",
+ "delete_confirm_title": "Odstranit položku",
+ "delete_confirm_message": "Opravdu chcete odstranit \"{name}\"? Tuto akci nelze vrátit zpět.",
+ "download": "Stáhnout",
+ "name": "Název",
+ "size": "Velikost",
+ "modified": "Změněno",
+ "type": "Typ",
+ "folder": "Složka",
+ "file": "Soubor",
+ "parent_directory": "Nadřazený adresář",
+ "breadcrumb_root": "Domů",
+ "drop_files_here": "Přetáhněte sem soubory nebo složky pro jejich nahrání",
+ "uploading": "Nahrávání...",
+ "upload_success": "{count, plural, one {Nahrán 1 soubor} few {Nahrány # soubory} other {Nahráno # souborů}}",
+ "upload_error": "Nahrání souboru selhalo",
+ "create_folder_success": "Složka byla vytvořena",
+ "create_folder_error": "Vytvoření složky selhalo",
+ "delete_success": "Úspěšně odstraněno",
+ "delete_error": "Odstranění selhalo",
+ "rename_success": "Úspěšně přejmenováno",
+ "rename_error": "Přejmenování selhalo",
+ "download_error": "Stahování selhalo",
+ "not_available": "Úložiště souborů není na tomto serveru k dispozici",
+ "cancel": "Zrušit",
+ "create": "Vytvořit",
+ "save": "Uložit",
+ "no_results": "Hledání neodpovídají žádné soubory",
+ "batch_delete_confirm_message": "Opravdu chcete odstranit {count, plural, one {1 položku} few {# položky} other {# položek}}? Tuto akci nelze vrátit zpět.",
+ "batch_delete_success": "{count, plural, one {Odstraněna 1 položka} few {Odstraněny # položky} other {Odstraněno # položek}}",
+ "grid_view": "Zobrazení mřížky",
+ "list_view": "Zobrazení seznamu",
+ "details": "Podrobnosti",
+ "path": "Cesta",
+ "preview": "Náhled",
+ "preview_error": "Náhled se nepodařilo načíst",
+ "cut": "Vyjmout",
+ "copy": "Kopírovat",
+ "paste": "Vložit",
+ "move_success": "{count, plural, one {Přesunuta 1 položka} few {Přesunuty # položky} other {Přesunuto # položek}}",
+ "move_error": "Přesun selhal",
+ "paste_success": "Úspěšně vloženo",
+ "paste_error": "Vložení selhalo",
+ "new_text_file": "Nový textový soubor",
+ "file_name": "Název souboru",
+ "retry": "Opakovat",
+ "refresh": "Obnovit",
+ "toggle_favorite": "Přepnout oblíbené",
+ "duplicate": "Duplikovat",
+ "duplicate_success": "Úspěšně duplikováno",
+ "duplicate_error": "Duplikování selhalo",
+ "create_file_success": "Soubor byl vytvořen",
+ "create_file_error": "Vytvoření souboru selhalo",
+ "favorites": "Oblíbené",
+ "recent": "Nedávné",
+ "properties": "Vlastnosti",
+ "open_folder": "Otevřít složku",
+ "upload_folder": "Nahrát složku",
+ "file_too_large": "\"{name}\" překračuje maximální velikost souboru ({max})",
+ "undo": "Zpět",
+ "undo_success": "Akce vrácena zpět",
+ "undo_error": "Akci nelze vrátit zpět",
+ "toolbar": "Akce se soubory",
+ "file_list": "Soubory a složky",
+ "context_menu": "Akce",
+ "settings_title": "Nastavení souborů",
+ "settings_display": "Zobrazení",
+ "settings_default_view": "Výchozí zobrazení",
+ "settings_default_view_desc": "Vyberte rozložení mřížky nebo seznamu",
+ "settings_default_sort": "Výchozí řazení",
+ "settings_default_sort_desc": "Vyberte výchozí řazení souborů",
+ "settings_sort_direction": "Směr řazení",
+ "settings_sort_direction_desc": "Vyberte vzestupné nebo sestupné pořadí",
+ "settings_ascending": "Vzestupně",
+ "settings_descending": "Sestupně",
+ "settings_icons": "Ikony",
+ "settings_show_icons": "Zobrazovat ikony souborů",
+ "settings_show_icons_desc": "Zobrazovat ikony vedle souborů a složek",
+ "settings_colored_icons": "Barevné ikony",
+ "settings_colored_icons_desc": "Používat barevné ikony místo monochromatických",
+ "settings_show_thumbnails": "Zobrazovat miniatury",
+ "settings_show_thumbnails_desc": "Zobrazovat náhledy obrázků místo ikon pro obrazové soubory",
+ "settings_behavior": "Chování",
+ "settings_show_hidden": "Zobrazovat skryté soubory",
+ "settings_show_hidden_desc": "Zobrazovat soubory a složky začínající tečkou",
+ "settings_folder_layout": "Navigace po složkách",
+ "settings_folder_layout_desc": "Vyberte způsob zobrazení složek: společně se soubory nebo ve stromové struktuře v postranním panelu",
+ "settings_folder_layout_inline": "Vložené",
+ "settings_folder_layout_sidebar": "Postranní panel",
+ "disabled_title": "Funkce Soubory je zakázána správcem",
+ "disabled_description": "Nahrávání velkých souborů přes WebDAV může způsobit nestabilitu Stalwart/RocksDB, včetně pádů z důvodu nedostatku paměti a nevratného zaplnění disku. Odstraněné soubory nemusí být okamžitě odstraněny z úložiště. Tato funkce se nedoporučuje v produkčním prostředí.",
+ "stability_warning": "Nahrávání velkých souborů může způsobit nestabilitu serveru. Odstraněné soubory nemusí být okamžitě odstraněny z úložiště. Používejte s opatrností."
+ },
+ "smime": {
+ "your_certificates": "Vaše certifikáty",
+ "your_certificates_desc": "Importujte a spravujte své certifikáty S/MIME pro podepisování a šifrování e-mailů",
+ "recipient_certificates": "Certifikáty příjemců",
+ "recipient_certificates_desc": "Veřejné certifikáty pro šifrování e-mailů odesílaných příjemcům",
+ "identity_bindings": "Vazby klíčů na identity",
+ "identity_bindings_desc": "Svažte certifikáty S/MIME s e-mailovými identitami",
+ "defaults_title": "Výchozí",
+ "defaults_desc": "Nakonfigurujte výchozí chování pro podepisování a šifrování",
+ "import_pkcs12": "Importovat PKCS#12 (.p12/.pfx)",
+ "import_public_cert": "Importovat certifikát",
+ "no_certificates": "Zatím nebyly importovány žádné certifikáty",
+ "no_recipient_certs": "Žádné certifikáty příjemců",
+ "expires": "Vyprší",
+ "expired": "Vypršela platnost",
+ "bound_to": "Vázáno k",
+ "no_key_bound": "Žádné",
+ "lock": "Uzamknout klíč",
+ "unlock": "Odemknout klíč",
+ "details": "Zobrazit podrobnosti",
+ "delete": "Odstranit",
+ "encrypt_by_default": "Výchozí šifrování",
+ "encrypt_by_default_desc": "Automaticky šifrovat e-maily, pokud mají všichni příjemci certifikáty",
+ "remember_unlocked": "Pamatovat si odemčené klíče",
+ "remember_unlocked_desc": "Ponechat klíče odemčené po dobu trvání této relace prohlížeče",
+ "sign_default_for": "Výchozí podepisování pro",
+ "enter_p12_passphrase": "Zadejte heslo PKCS#12",
+ "p12_passphrase_desc": "Zadejte heslo chránící tento soubor certifikátu",
+ "enter_storage_passphrase": "Nastavte heslo úložiště",
+ "storage_passphrase_desc": "Vyberte heslo pro ochranu tohoto klíče při uložení v prohlížeči",
+ "next": "Další",
+ "import": "Importovat",
+ "unlock_key": "Odemknout klíč",
+ "unlock_key_desc": "Zadejte heslo úložiště pro odemčení tohoto klíče pro podepisování nebo dešifrování",
+ "passphrase_placeholder": "Zadejte heslo",
+ "confirm_passphrase_placeholder": "Potvrďte heslo",
+ "passphrase_mismatch": "Hesla se neshodují",
+ "cancel": "Zrušit",
+ "processing": "Zpracování…",
+ "close": "Zavřít",
+ "certificate_details": "Podrobnosti o certifikátu",
+ "cert_subject": "Subjekt",
+ "cert_issuer": "Vystavitel",
+ "cert_email": "E-mail",
+ "cert_serial": "Sériové číslo",
+ "cert_validity": "Platnost",
+ "cert_fingerprint": "Otisk (SHA-256)",
+ "cert_algorithm": "Algoritmus",
+ "cert_capabilities": "Možnosti",
+ "cert_source": "Zdroj",
+ "cert_expired": "Tento certifikát vypršel",
+ "cert_not_yet_valid": "Tento certifikát ještě není platný",
+ "cap_sign": "Podepisování",
+ "cap_encrypt": "Šifrování",
+ "cap_none": "Žádné",
+ "show_passphrase": "Zobrazit heslo",
+ "hide_passphrase": "Skrýt heslo",
+ "sign_toggle": "Podepsat",
+ "encrypt_toggle": "Šifrovat",
+ "missing_recipient_certs": "Chybí certifikáty pro: {emails}",
+ "missing_sender_cert": "Chybí certifikát vázaný k této identitě",
+ "status_encrypted_ok": "Tato zpráva byla zašifrována",
+ "status_encrypted_no_key": "Tato zpráva je zašifrována, ale nebyl nalezen odpovídající klíč",
+ "status_encrypted_failed": "Nepodařilo se dešifrovat tuto zprávu",
+ "status_signed_valid": "Podpis byl ověřen",
+ "status_signed_invalid": "Ověření podpisu selhalo",
+ "status_signed_expired_cert": "Podepsáno certifikátem, jehož platnost vypršela",
+ "status_signed_self_signed": "Podpis je platný, ale certifikát je vydán sám sobě (nedůvěryhodný)",
+ "status_signed_mismatch": "Podpis je platný, ale podepisující se neshoduje s odesílatelem",
+ "status_unsupported": "Nepodporovaný formát S/MIME",
+ "auto_import_signer_certs": "Automaticky importovat certifikáty podepisujících",
+ "auto_import_signer_certs_desc": "Automaticky ukládat certifikáty z ověřených podepsaných e-mailů pro budoucí šifrování",
+ "export": "Exportovat",
+ "enter_export_passphrase": "Nastavte heslo pro export",
+ "export_passphrase_desc": "Vyberte heslo pro ochranu exportovaného souboru PKCS#12",
+ "export_storage_desc": "Zadejte heslo úložiště pro dešifrování klíče k exportu",
+ "incorrect_passphrase": "Nesprávné heslo"
+ },
+ "tour": {
+ "step_counter": "Krok {current} z {total}",
+ "skip": "Přeskočit průvodce",
+ "back": "Zpět",
+ "next": "Další",
+ "finish": "Dokončit",
+ "take_a_tour": "Prozkoumejte rozhraní v krátkém průvodci",
+ "restart_title": "Úvodní průvodce",
+ "restart_desc": "Přehrát průvodce rozhraním krok za krokem",
+ "restart_button": "Spustit průvodce znovu",
+ "sidebar_title": "Vaše poštovní schránky",
+ "sidebar_desc": "Toto je postranní panel se složkami. Kliknutím na libovolnou schránku zobrazíte její zprávy. Můžete vytvářet složky, přetahovat zprávy mezi nimi a okamžitě vidět počet nepřečtených e-mailů.",
+ "compose_title": "Napsat zprávu",
+ "compose_desc": "Kliknutím sem napíšete novou zprávu. Můžete přidávat příjemce, přílohy a využívat formátovaný text.",
+ "search_title": "Prohledávat poštu",
+ "search_desc": "Vyhledávejte podle odesílatele, předmětu nebo obsahu. Kliknutím na ikonu filtru využijete pokročilé možnosti, jako jsou časová období, přílohy a zprávy označené hvězdičkou.",
+ "email_list_title": "Seznam zpráv",
+ "email_list_desc": "Zde se zobrazují zprávy. Kliknutím na jednu z nich si ji přečtete napravo. Pomocí zaškrtávacích políček můžete vybrat více zpráv a hromadně je přesunout, odstranit nebo označit štítkem.",
+ "email_viewer_title": "Panel čtení",
+ "email_viewer_desc": "Zde se otevírá vybraná zpráva. Pomocí tlačítek na panelu nástrojů můžete odpovědět, přeposlat ji, archivovat nebo odstranit. Můžete také označovat zprávy hvězdičkou nebo přidávat barevné štítky.",
+ "keywords_title": "Barevné štítky",
+ "keywords_desc": "Organizujte poštu pomocí barevných štítků. Přetažením zprávy na štítek ji označíte, nebo klikněte na zprávu pravým tlačítkem a štítky přiřaďte.",
+ "calendar_title": "Kalendář",
+ "calendar_desc": "Přepněte se do kalendáře pro správu událostí. Můžete vytvářet události, nastavovat připomenutí a přepínat mezi zobrazením dne, týdne nebo měsíce.",
+ "contacts_title": "Kontakty",
+ "contacts_desc": "Zde se nachází váš adresář. Importujte kontakty, vytvářejte skupiny a kliknutím na libovolný kontakt zobrazíte jeho úplné údaje.",
+ "settings_title": "Nastavení",
+ "settings_desc": "Přizpůsobte si vše: motiv, hustotu zobrazení, podpisy, filtry, klávesové zkratky, výchozí nastavení kalendáře a mnoho dalšího.",
+ "shortcuts_title": "Klávesové zkratky",
+ "shortcuts_desc": "Oblíbená funkce pokročilých uživatelů. Kdykoli stiskněte ? pro zobrazení všech dostupných zkratek. Můžete procházet aplikací, psát a spravovat zprávy bez použití myši.",
+ "compose_open_title": "Editor zpráv",
+ "compose_open_desc": "Toto je editor zpráv. Přidávejte příjemce, pište obsah, připojujte soubory a formátujte text. Můžete také ukládat koncepty a používat šablony.",
+ "calendar_view_title": "Váš kalendář",
+ "calendar_view_desc": "Zde vidíte kalendář s ukázkovými událostmi. Pomocí panelu nástrojů můžete přepínat zobrazení dne, týdne, měsíce a agendy.",
+ "create_event_title": "Vytvořit událost",
+ "create_event_desc": "Kliknutím na toto tlačítko vytvoříte novou událost v kalendáři. Můžete nastavit název, datum, čas a přidat účastníky.",
+ "event_modal_title": "Podrobnosti události",
+ "event_modal_desc": "Toto je formulář události. Vyplňte název, vyberte datum a čas, přidejte místo konání nebo účastníky. Až budete hotovi, klikněte na uložit - nebo okno zavřete a pokračujte dále.",
+ "contacts_list_title": "Vaše kontakty",
+ "contacts_list_desc": "Zde jsou vaše kontakty. Kliknutím na libovolný kontakt zobrazíte jeho úplné údaje na pravé straně. Můžete také vytvářet nové kontakty, importovat soubory vCard a organizovat kontakty do skupin.",
+ "settings_tabs_title": "Menu nastavení",
+ "settings_tabs_desc": "Zde najdete všechny kategorie nastavení. Upravte vzhled, spravujte identity, nastavte e-mailové filtry, předvolby kalendáře a mnoho dalšího.",
+ "files_title": "Úložiště souborů",
+ "files_desc": "Prohlížeč souborů umožňuje nahrávat, organizovat a sdílet soubory - jako váš osobní cloudový disk zabudovaný přímo v e-mailu.",
+ "demo_banner_title": "Ovládání demoverze",
+ "demo_banner_desc": "Nacházíte se v demo režimu - vše zůstává ve vašem prohlížeči. Kdykoli klikněte na „Resetovat“, abyste začali znovu s čistými ukázkovými daty.",
+ "quota_title": "Využití úložiště",
+ "quota_desc": "Zde můžete sledovat velikost své poštovní schránky. Kruh se plní podle využití kapacity."
+ },
+ "unified_mailbox": {
+ "search_unavailable": "Vyhledávání není ve sjednoceném zobrazení k dispozici"
+ }
+}
From 511740bb6d006cb4c66e78b057e8b8ad8a91bd41 Mon Sep 17 00:00:00 2001
From: Roman Vanicek
Date: Sun, 26 Apr 2026 10:45:00 +0200
Subject: [PATCH 24/27] Additional Czech translations for recent changes.
---
locales/cs/common.json | 37 +++++++++++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/locales/cs/common.json b/locales/cs/common.json
index abb1a3ba..b4059a9c 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -1594,6 +1594,43 @@
"remove_color": "Odebrat štítek",
"items_selected": "{count} vybraných zpráv",
"edit_draft": "Upravit koncept"
+ },
+ "mailbox_context_menu": {
+ "mark_folder_read": "Označit složku jako přečtenou",
+ "mark_folder_tree_read": "Označit složku a podsložky jako přečtené",
+ "mark_all_folders_read": "Označit všechny složky jako přečtené",
+ "new_subfolder": "Nová podsložka...",
+ "new_folder": "Nová složka...",
+ "rename": "Přejmenovat...",
+ "empty_folder": "Vyprázdnit složku",
+ "empty_folder_generic": "Vyprázdnit složku",
+ "delete_folder": "Smazat složku",
+ "refresh": "Obnovit",
+ "mark_all_confirm_title": "Označit všechny složky jako přečtené",
+ "mark_all_confirm_message": "Označit všechny nepřečtené zprávy ve vašem osobním účtu jako přečtené?",
+ "delete_confirm_title": "Smazat složku",
+ "delete_confirm_message": "Trvale smazat složku \"{name}\"? Tuto akci nelze vrátit.",
+ "prompt_new_subfolder": "Zadejte název nové podsložky.",
+ "prompt_new_folder": "Zadejte název nové složky.",
+ "prompt_rename": "Zadejte nový název této složky.",
+ "placeholder_folder_name": "Název složky",
+ "create": "Vytvořit",
+ "rename_confirm": "Přejmenovat",
+ "toast_marked_read": "Složka označena jako přečtená",
+ "toast_marked_read_count": "{count, plural, one {1 zpráva označena jako přečtená} few {# zprávy označeny jako přečtené} other {# zpráv označeno jako přečtených}}",
+ "toast_already_read": "Žádné nepřečtené zprávy",
+ "toast_marked_all_read": "Všechny složky označeny jako přečtené",
+ "toast_emptied": "Složka vyprázdněna",
+ "toast_folder_created": "Složka vytvořena",
+ "toast_folder_renamed": "Složka přejmenována",
+ "toast_folder_deleted": "Složka smazána",
+ "toast_error_mark_read": "Nepodařilo se označit jako přečtené",
+ "toast_error_empty": "Nepodařilo se vyprázdnit složku",
+ "toast_error_create": "Nepodařilo se vytvořit složku",
+ "toast_error_rename": "Nepodařilo se přejmenovat složku",
+ "toast_error_delete": "Nepodařilo se smazat složku",
+ "toast_error_delete_has_children": "Složka obsahuje podsložky. Nejprve je odstraňte.",
+ "toast_error_delete_has_email": "Složka není prázdná. Nejprve ji vyprázdněte."
},
"shortcuts": {
"title": "Klávesové zkratky",
From 3e1de10213b498b5016870816c656a6ee5dee997 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 26 Apr 2026 20:10:04 +0200
Subject: [PATCH 25/27] feat: add JMAP sharing for calendars and address books
---
app/[locale]/calendar/page.tsx | 83 ++++-
app/[locale]/contacts/page.tsx | 48 ++-
.../calendar/calendar-sidebar-panel.tsx | 232 ++++++------
components/calendar/create-calendar-modal.tsx | 151 ++++++++
components/calendar/event-modal.tsx | 3 +
components/contacts/contact-form.tsx | 8 +-
components/contacts/contacts-sidebar.tsx | 87 ++++-
.../address-book-management-settings.tsx | 36 +-
.../settings/calendar-management-settings.tsx | 37 +-
.../settings/share-collection-dialog.tsx | 347 ++++++++++++++++++
lib/__tests__/calendar-alerts.test.ts | 2 +-
lib/birthday-calendar.ts | 2 +-
lib/demo/demo-client.ts | 11 +-
lib/demo/fixtures/calendars.ts | 6 +-
lib/jmap/client-interface.ts | 9 +-
lib/jmap/client.ts | 102 ++++-
lib/jmap/types.ts | 17 +-
locales/cs/common.json | 57 ++-
locales/de/common.json | 35 +-
locales/en/common.json | 35 +-
locales/es/common.json | 35 +-
locales/fr/common.json | 35 +-
locales/it/common.json | 35 +-
locales/ja/common.json | 35 +-
locales/ko/common.json | 35 +-
locales/lv/common.json | 35 +-
locales/nl/common.json | 35 +-
locales/pl/common.json | 35 +-
locales/pt/common.json | 35 +-
locales/ru/common.json | 35 +-
locales/uk/common.json | 35 +-
locales/zh/common.json | 35 +-
stores/calendar-store.ts | 26 +-
stores/contact-store.ts | 43 ++-
34 files changed, 1605 insertions(+), 192 deletions(-)
create mode 100644 components/calendar/create-calendar-modal.tsx
create mode 100644 components/settings/share-collection-dialog.tsx
diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx
index 1b67de9c..0bcad9a8 100644
--- a/app/[locale]/calendar/page.tsx
+++ b/app/[locale]/calendar/page.tsx
@@ -47,7 +47,11 @@ 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 type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
+import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
+import { ConfirmDialog } from "@/components/ui/confirm-dialog";
+import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
+import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug";
@@ -72,7 +76,8 @@ export default function CalendarPage() {
calendars, events, selectedDate, viewMode, selectedCalendarIds,
isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
- setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
+ setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
+ removeCalendar, clearCalendarEvents,
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
@@ -91,6 +96,11 @@ export default function CalendarPage() {
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState(null);
+ const [sharingCalendarId, setSharingCalendarId] = useState(null);
+ const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState(undefined);
+ const [showCreateCalendar, setShowCreateCalendar] = useState(false);
+ const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
+ const tMgmt = useTranslations("calendar.management");
const [editEvent, setEditEvent] = useState(null);
const [defaultModalDate, setDefaultModalDate] = useState();
const [defaultModalEndDate, setDefaultModalEndDate] = useState();
@@ -1102,6 +1112,42 @@ export default function CalendarPage() {
}
updateCalendar(client, calendarId, { color });
} : undefined}
+ onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
+ onCreateEvent={(cal: Calendar) => {
+ setDefaultCalendarIdForCreate(cal.id);
+ openCreateModal();
+ }}
+ onClearCalendar={client ? async (cal: Calendar) => {
+ const ok = await confirmAction({
+ title: tMgmt("clear_events"),
+ message: tMgmt("confirm_clear", { name: cal.name }),
+ variant: "destructive",
+ confirmText: tMgmt("clear_events"),
+ });
+ if (!ok) return;
+ try {
+ const count = await clearCalendarEvents(client, cal.id);
+ toast.success(tMgmt("events_cleared", { count }));
+ } catch {
+ toast.error(tMgmt("error_clear"));
+ }
+ } : undefined}
+ onDeleteCalendar={client ? async (cal: Calendar) => {
+ const ok = await confirmAction({
+ title: tMgmt("delete"),
+ message: tMgmt("confirm_delete", { name: cal.name }),
+ variant: "destructive",
+ confirmText: tMgmt("delete"),
+ });
+ if (!ok) return;
+ try {
+ await removeCalendar(client, cal.id);
+ toast.success(tMgmt("calendar_deleted"));
+ } catch {
+ toast.error(tMgmt("error_delete"));
+ }
+ } : undefined}
+ onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client}
@@ -1165,11 +1211,12 @@ export default function CalendarPage() {
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
+ defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
- onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
+ onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }}
onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails}
isMobile={false}
@@ -1261,11 +1308,12 @@ export default function CalendarPage() {
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
+ defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
- onClose={() => { setShowEventModal(false); setEditEvent(null); }}
+ onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }}
currentUserEmails={currentUserEmails}
isMobile={true}
/>
@@ -1305,6 +1353,33 @@ export default function CalendarPage() {
onSelect={handleScopeSelect}
onClose={() => setPendingScopeAction(null)}
/>
+
+
+
+ {showCreateCalendar && client && (
+ setShowCreateCalendar(false)}
+ />
+ )}
+
+ {sharingCalendarId && client && (() => {
+ const cal = allCalendars.find((c) => c.id === sharingCalendarId);
+ if (!cal) return null;
+ return (
+ {
+ await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
+ }}
+ onClose={() => setSharingCalendarId(null)}
+ />
+ );
+ })()}
);
}
diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx
index 1190019f..8bcc3891 100644
--- a/app/[locale]/contacts/page.tsx
+++ b/app/[locale]/contacts/page.tsx
@@ -27,7 +27,8 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
-import type { ContactCard, AddressBook } from "@/lib/jmap/types";
+import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
+import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
type View =
| "list"
@@ -75,6 +76,8 @@ export default function ContactsPage() {
bulkAddToGroup,
moveContactToAddressBook,
renameAddressBook,
+ removeAddressBook,
+ shareAddressBook,
renameKeyword,
importContacts,
} = useContactStore();
@@ -83,6 +86,8 @@ export default function ContactsPage() {
const [activeCategory, setActiveCategory] = useState("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState(null);
+ const [sharingAddressBookId, setSharingAddressBookId] = useState(null);
+ const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState(undefined);
const [renamingKeyword, setRenamingKeyword] = useState(null);
const [selectedGroupId, setSelectedGroupId] = useState(null);
const hasFetched = useRef(false);
@@ -329,6 +334,7 @@ export default function ContactsPage() {
addLocalContact(localContact);
toast.success(t("toast.created"));
}
+ setDefaultBookIdForCreate(undefined);
setView("list");
}, [supportsSync, client, createContact, addLocalContact, t]);
@@ -346,6 +352,7 @@ export default function ContactsPage() {
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
const handleCancel = () => {
+ setDefaultBookIdForCreate(undefined);
if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list");
} else if (view === "bulk-add-to-group") {
@@ -517,7 +524,7 @@ export default function ContactsPage() {
const renderRightPanel = () => {
switch (view) {
case "create":
- return ;
+ return ;
case "edit":
if (!selectedContact) return null;
@@ -690,6 +697,26 @@ export default function ContactsPage() {
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
+ onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
+ onCreateContactInBook={(book) => {
+ setDefaultBookIdForCreate(book.id);
+ handleCreateNew();
+ }}
+ onDeleteAddressBook={client ? async (book) => {
+ const ok = await confirmDialog({
+ title: t("address_books.delete"),
+ message: t("address_books.confirm_delete", { name: book.name }),
+ variant: "destructive",
+ confirmText: t("address_books.delete"),
+ });
+ if (!ok) return;
+ try {
+ await removeAddressBook(client, book);
+ toast.success(t("address_books.deleted"));
+ } catch {
+ toast.error(t("address_books.delete_failed"));
+ }
+ } : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/>
@@ -838,6 +865,23 @@ export default function ContactsPage() {
)}
+ {sharingAddressBookId && client && (() => {
+ const book = addressBooks.find((b) => b.id === sharingAddressBookId);
+ if (!book) return null;
+ return (
+ {
+ await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
+ }}
+ onClose={() => setSharingAddressBookId(null)}
+ />
+ );
+ })()}
);
}
diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx
index 6355e43b..7c22a2f5 100644
--- a/components/calendar/calendar-sidebar-panel.tsx
+++ b/components/calendar/calendar-sidebar-panel.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useState, useRef, useEffect, useMemo } from "react";
+import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
-import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
+import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -11,6 +11,8 @@ 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 { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
+import { useContextMenu } from "@/hooks/use-context-menu";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
interface CalendarSidebarPanelProps {
@@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps {
selectedCalendarIds: string[];
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
+ onShareCalendar?: (calendar: Calendar) => void;
+ onCreateEvent?: (calendar: Calendar) => void;
+ onClearCalendar?: (calendar: Calendar) => void;
+ onDeleteCalendar?: (calendar: Calendar) => void;
+ onCreateCalendar?: () => void;
onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null;
@@ -28,12 +35,18 @@ export function CalendarSidebarPanel({
selectedCalendarIds,
onToggleVisibility,
onColorChange,
+ onShareCalendar,
+ onCreateEvent,
+ onClearCalendar,
+ onDeleteCalendar,
+ onCreateCalendar,
onSubscribe,
onEditSubscription,
client,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
const tSub = useTranslations("calendar.subscription");
+ const tMgmt = useTranslations("calendar.management");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
@@ -49,11 +62,8 @@ export function CalendarSidebarPanel({
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
}, [tasks]);
- const [colorPickerId, setColorPickerId] = useState(null);
- const [contextMenuCalId, setContextMenuCalId] = useState(null);
+ const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu();
const [refreshingSubId, setRefreshingSubId] = useState(null);
- const colorPickerRef = useRef(null);
- const contextMenuRef = useRef(null);
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
const sharedAccountGroups = useMemo(() => {
@@ -69,30 +79,6 @@ export function CalendarSidebarPanel({
return Array.from(groups.values());
}, [calendars]);
- useEffect(() => {
- if (!colorPickerId && !contextMenuCalId) return;
- const handleClick = (e: MouseEvent) => {
- if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
- setColorPickerId(null);
- }
- if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
- setContextMenuCalId(null);
- }
- };
- const handleKey = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- setColorPickerId(null);
- setContextMenuCalId(null);
- }
- };
- document.addEventListener('mousedown', handleClick);
- document.addEventListener('keydown', handleKey);
- return () => {
- document.removeEventListener('mousedown', handleClick);
- document.removeEventListener('keydown', handleKey);
- };
- }, [colorPickerId, contextMenuCalId]);
-
const getSubscriptionForCalendar = (calendarId: string) => {
return icalSubscriptions.find(s => s.calendarId === calendarId);
};
@@ -100,7 +86,6 @@ export function CalendarSidebarPanel({
const handleRefreshSubscription = async (subId: string) => {
if (!client) return;
setRefreshingSubId(subId);
- setContextMenuCalId(null);
try {
await refreshICalSubscription(client, subId);
toast.success(tSub('refresh_success'));
@@ -113,7 +98,6 @@ export function CalendarSidebarPanel({
const handleUnsubscribe = async (subId: string) => {
if (!client) return;
- setContextMenuCalId(null);
try {
await removeICalSubscription(client, subId);
toast.success(tSub('deleted'));
@@ -127,21 +111,13 @@ export function CalendarSidebarPanel({
const renderCalendarItem = (cal: Calendar) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
+ const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
return (
onToggleVisibility(cal.id)}
- onContextMenu={(e) => {
- e.preventDefault();
- if (isSubscriptionCalendar(cal.id) && client) {
- setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
- setColorPickerId(null);
- } else if (onColorChange) {
- setColorPickerId(colorPickerId === cal.id ? null : cal.id);
- setContextMenuCalId(null);
- }
- }}
+ onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
@@ -169,70 +145,101 @@ export function CalendarSidebarPanel({
)}
-
- {/* Subscription context menu on right-click */}
- {contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
- const sub = getSubscriptionForCalendar(cal.id);
- if (!sub) return null;
- return (
-
-
{
- setContextMenuCalId(null);
- onEditSubscription?.(sub.id);
- }}
- className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
- >
-
- {tSub('edit')}
-
-
handleRefreshSubscription(sub.id)}
- className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
- >
-
- {tSub('refresh')}
-
-
handleUnsubscribe(sub.id)}
- className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
- >
-
- {tSub('unsubscribe')}
-
- {sub.lastRefreshed && (
-
- {tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
-
- )}
-
- );
- })()}
-
- {/* Color picker popover on right-click */}
- {colorPickerId === cal.id && onColorChange && (
-
-
{t("management.change_color")}
-
{
- onColorChange(cal.id, c);
- setColorPickerId(null);
- }}
- allowCustom
- />
-
- )}
);
};
+ const renderCalendarMenu = () => {
+ const cal = contextMenu.data;
+ if (!cal) return null;
+
+ if (isSubscriptionCalendar(cal.id)) {
+ const sub = getSubscriptionForCalendar(cal.id);
+ if (!sub || !client) return null;
+ return (
+
+ { closeContextMenu(); onEditSubscription?.(sub.id); }}
+ />
+ { closeContextMenu(); handleRefreshSubscription(sub.id); }}
+ />
+
+ { closeContextMenu(); handleUnsubscribe(sub.id); }}
+ destructive
+ />
+ {sub.lastRefreshed && (
+
+ {tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
+
+ )}
+
+ );
+ }
+
+ const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
+ const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
+ const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
+ const canChangeColor = !!onColorChange;
+ const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
+ const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
+ const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
+ const color = cal.color || "#3b82f6";
+
+ return (
+
+ {canCreate && (
+ { closeContextMenu(); onCreateEvent(cal); }}
+ />
+ )}
+ {canShare && (
+ { closeContextMenu(); onShareCalendar(cal); }}
+ />
+ )}
+ {canChangeColor && (
+
+
+ { onColorChange(cal.id, c); closeContextMenu(); }}
+ allowCustom
+ />
+
+
+ )}
+ {showSeparator && }
+ {canClear && (
+ { closeContextMenu(); onClearCalendar(cal); }}
+ />
+ )}
+ {canDelete && (
+ { closeContextMenu(); onDeleteCalendar(cal); }}
+ destructive
+ />
+ )}
+
+ );
+ };
+
return (
{enableCalendarTasks && (
@@ -250,9 +257,22 @@ export function CalendarSidebarPanel({
)}
)}
-
- {t("my_calendars")}
-
+
+ {onCreateCalendar ? (
+
+ {t('my_calendars')}
+
+
+ ) : (
+
+ {t('my_calendars')}
+
+ )}
+
{personalCalendars.map(renderCalendarItem)}
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
))}
+
+ {renderCalendarMenu()}
);
}
diff --git a/components/calendar/create-calendar-modal.tsx b/components/calendar/create-calendar-modal.tsx
new file mode 100644
index 00000000..e5b675e7
--- /dev/null
+++ b/components/calendar/create-calendar-modal.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
+import type { IJMAPClient } from "@/lib/jmap/client-interface";
+import { useCalendarStore } from "@/stores/calendar-store";
+import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
+import { toast } from "@/stores/toast-store";
+
+interface CreateCalendarModalProps {
+ client: IJMAPClient;
+ onClose: () => void;
+}
+
+export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) {
+ const t = useTranslations("calendar.management");
+ const tCommon = useTranslations("common");
+ const createCalendar = useCalendarStore((s) => s.createCalendar);
+
+ const [name, setName] = useState("");
+ const [color, setColor] = useState("#3b82f6");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const modalRef = useRef(null);
+
+ const isValid = name.trim().length > 0;
+
+ const handleSubmit = useCallback(async () => {
+ const trimmed = name.trim();
+ if (!trimmed) return;
+ setIsSubmitting(true);
+ try {
+ const created = await createCalendar(client, { name: trimmed, color });
+ if (created) {
+ toast.success(t("calendar_created"));
+ onClose();
+ } else {
+ toast.error(t("error_create"));
+ }
+ } catch {
+ toast.error(t("error_create"));
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [name, color, client, createCalendar, onClose, t]);
+
+ useEffect(() => {
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && !isSubmitting) onClose();
+ };
+ window.addEventListener("keydown", handleKey);
+ return () => window.removeEventListener("keydown", handleKey);
+ }, [onClose, isSubmitting]);
+
+ useEffect(() => {
+ const modal = modalRef.current;
+ if (!modal) return;
+ const focusableEls = modal.querySelectorAll(
+ 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
+ );
+ const firstEl = focusableEls[0];
+ const lastEl = focusableEls[focusableEls.length - 1];
+
+ const handler = (e: KeyboardEvent) => {
+ if (e.key !== "Tab") return;
+ if (e.shiftKey && document.activeElement === firstEl) {
+ e.preventDefault();
+ lastEl?.focus();
+ } else if (!e.shiftKey && document.activeElement === lastEl) {
+ e.preventDefault();
+ firstEl?.focus();
+ }
+ };
+ modal.addEventListener("keydown", handler);
+ firstEl?.focus();
+ return () => modal.removeEventListener("keydown", handler);
+ }, []);
+
+ return (
+
+
!isSubmitting && onClose()}
+ aria-hidden="true"
+ />
+
+
+
+
+
{t("add_calendar")}
+
+
+
+
+
+
+
+
+
+ {t("name")}
+
+ setName(e.target.value)}
+ placeholder={t("name_placeholder")}
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
+ disabled={isSubmitting}
+ onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
+ />
+
+
+
+
+ {t("color")}
+
+
+
+
+
+
+
+ {tCommon("cancel")}
+
+
+ {isSubmitting ? (
+ <>
+
+ {tCommon("loading")}
+ >
+ ) : (
+ t("create")
+ )}
+
+
+
+
+ );
+}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 25e1edcd..fe65a867 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -35,6 +35,7 @@ interface EventModalProps {
calendars: Calendar[];
defaultDate?: Date;
defaultEndDate?: Date;
+ defaultCalendarId?: string;
onSave: (data: Partial
, sendSchedulingMessages?: boolean) => void | Promise;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
onDuplicate?: (data: Partial) => void;
@@ -113,6 +114,7 @@ export function EventModal({
calendars,
defaultDate,
defaultEndDate,
+ defaultCalendarId,
onSave,
onDelete,
onDuplicate,
@@ -200,6 +202,7 @@ export function EventModal({
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
const [calendarId, setCalendarId] = useState(() => {
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
+ if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
const defaultCal = calendars.find(c => c.isDefault);
return defaultCal?.id || calendars[0]?.id || "";
});
diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx
index ae449b0b..5fa6c363 100644
--- a/components/contacts/contact-form.tsx
+++ b/components/contacts/contact-form.tsx
@@ -50,6 +50,7 @@ interface ContactFormProps {
contact?: ContactCard | null;
addressBooks?: AddressBook[];
allKeywords?: string[];
+ defaultAddressBookId?: string;
onSave: (data: Partial) => Promise;
onCancel: () => void;
}
@@ -143,7 +144,7 @@ function Select({ value, onChange, children, className }: {
);
}
-export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
+export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
const t = useTranslations("contacts.form");
const isEditing = !!contact;
@@ -330,8 +331,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
return ids[0];
}
}
+ if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
+ return defaultAddressBookId;
+ }
return "";
- }, [contact]);
+ }, [contact, defaultAddressBookId, addressBooks]);
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
const initialPhotoEntry = useMemo(() => {
diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx
index 71051635..328ef384 100644
--- a/components/contacts/contacts-sidebar.tsx
+++ b/components/contacts/contacts-sidebar.tsx
@@ -27,6 +27,9 @@ interface ContactsSidebarProps {
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
+ onShareAddressBook?: (addressBook: AddressBook) => void;
+ onCreateContactInBook?: (addressBook: AddressBook) => void;
+ onDeleteAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
className?: string;
}
@@ -62,6 +65,9 @@ export function ContactsSidebar({
onDropContacts,
onDropContactsToCategory,
onRenameAddressBook,
+ onShareAddressBook,
+ onCreateContactInBook,
+ onDeleteAddressBook,
onRenameKeyword,
className,
}: ContactsSidebarProps) {
@@ -288,7 +294,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
- onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
+ onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
@@ -430,7 +436,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
- onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
+ onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
@@ -438,24 +444,65 @@ export function ContactsSidebar({
{/* Address book context menu */}
- {bookContextMenu.data && onRenameAddressBook && (
-
- {
- const book = bookContextMenu.data!;
- closeBookContextMenu();
- onRenameAddressBook(book);
- }}
- />
-
- )}
+ {bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
+ const book = bookContextMenu.data;
+ const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
+ const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
+ const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
+ const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
+ const showSeparator = (canCreate || canRename || canShare) && canDelete;
+ return (
+
+ {canCreate && (
+ {
+ closeBookContextMenu();
+ onCreateContactInBook(book);
+ }}
+ />
+ )}
+ {canRename && (
+ {
+ closeBookContextMenu();
+ onRenameAddressBook(book);
+ }}
+ />
+ )}
+ {canShare && (
+ {
+ closeBookContextMenu();
+ onShareAddressBook(book);
+ }}
+ />
+ )}
+ {showSeparator && }
+ {canDelete && (
+ {
+ closeBookContextMenu();
+ onDeleteAddressBook(book);
+ }}
+ destructive
+ />
+ )}
+
+ );
+ })()}
{/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && (
diff --git a/components/settings/address-book-management-settings.tsx b/components/settings/address-book-management-settings.tsx
index 635c691c..668307a8 100644
--- a/components/settings/address-book-management-settings.tsx
+++ b/components/settings/address-book-management-settings.tsx
@@ -2,13 +2,14 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
-import { Book, Pencil, Share2, Tag } from "lucide-react";
+import { Book, Pencil, Share2, Tag, Users } 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";
+import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
+import { ShareCollectionDialog } from "./share-collection-dialog";
function AddressBookEditRow({
initial,
@@ -70,9 +71,10 @@ export function AddressBookManagementSettings() {
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
- const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
+ const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState(null);
const [editingKeyword, setEditingKeyword] = useState(null);
+ const [sharingId, setSharingId] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -148,6 +150,16 @@ export function AddressBookManagementSettings() {