From b04dfaf252d375a16d28a9408b998708f3d27e9d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:37:17 +0200 Subject: [PATCH 01/27] fix: preserve search/filter when moving emails via drag-drop --- hooks/use-mailbox-drop.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index d123db54..218545d0 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -31,7 +31,7 @@ interface UseMailboxDropReturn { export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn { const [isOver, setIsOver] = useState(false); const { client } = useAuthStore(); - const { moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, mailboxes } = useEmailStore(); + const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(); const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext(); // Determine if this is a valid drop target @@ -115,8 +115,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: clearSelection(); } - // Refresh the current mailbox view - await fetchEmails(client, selectedMailbox); + // Refresh the current mailbox view (honors active search/filters) + await refreshCurrentMailbox(client); const mailboxPath = getMailboxPath(mailbox, mailboxes); @@ -144,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: } finally { endDrag(); } - }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]); + }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]); const valid = isValidTarget(); From 077a4f03a708aef265863b082dffe776c28ec5ef Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:01:21 +0200 Subject: [PATCH 02/27] fix: preserve search/filter on batch move and archive --- stores/email-store.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/stores/email-store.ts b/stores/email-store.ts index af6bd724..672a91b2 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1254,9 +1254,9 @@ export const useEmailStore = create((set, get) => ({ isLoading: false }); - // Refresh emails to get updated list + // Refresh emails to get updated list (honors active search/filters) if (!get().isUnifiedView) { - await get().fetchEmails(client, get().selectedMailbox); + await get().refreshCurrentMailbox(client); } } catch (error) { set({ @@ -1267,7 +1267,7 @@ export const useEmailStore = create((set, get) => ({ }, batchArchive: async (client) => { - const { selectedEmailIds, emails, mailboxes, fetchMailboxes, fetchEmails, selectedMailbox } = get(); + const { selectedEmailIds, emails, mailboxes, fetchMailboxes } = get(); if (selectedEmailIds.size === 0) return; const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive'); @@ -1293,7 +1293,8 @@ export const useEmailStore = create((set, get) => ({ set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false }); await fetchMailboxes(client); - await fetchEmails(client, selectedMailbox); + // Refresh the current mailbox view (honors active search/filters) + await get().refreshCurrentMailbox(client); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to archive emails', From 6c3529b368d9b7376c68ecf1159230e9064e4af2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:06:40 +0200 Subject: [PATCH 03/27] fix: stop flicker on background folder refresh --- stores/email-store.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/stores/email-store.ts b/stores/email-store.ts index 672a91b2..c4aed6a6 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -289,7 +289,12 @@ export const useEmailStore = create((set, get) => ({ // JMAP operations fetchMailboxes: async (client) => { - set({ isLoading: true, error: null }); + // Only toggle the email list's isLoading on the initial load. Background + // refreshes (after a move/archive that may have created new folders) must + // not flash the list's loading state, which hides the results-count bar + // and dims the list while folders re-fetch. + const isInitialLoad = get().mailboxes.length === 0; + if (isInitialLoad) set({ isLoading: true, error: null }); try { const mailboxes = await client.getAllMailboxes(); @@ -297,21 +302,22 @@ export const useEmailStore = create((set, get) => ({ // doesn't exist in the fetched list (e.g. after an account switch) const currentSelectedMailbox = get().selectedMailbox; const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox); + const loadingPatch = isInitialLoad ? { isLoading: false } : {}; if (!selectionValid) { // Find inbox from PRIMARY account (not shared accounts) const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared); if (inboxMailbox) { - set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false }); + set({ mailboxes, selectedMailbox: inboxMailbox.id, ...loadingPatch }); } else { - set({ mailboxes, selectedMailbox: '', isLoading: false }); + set({ mailboxes, selectedMailbox: '', ...loadingPatch }); } } else { - set({ mailboxes, isLoading: false }); + set({ mailboxes, ...loadingPatch }); } } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to fetch mailboxes", - isLoading: false + ...(isInitialLoad ? { isLoading: false } : {}) }); } }, From 081e8a03108c356b3da5380eb8afecda1f9c8e7c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:23:48 +0200 Subject: [PATCH 04/27] feat: add setting to show avatars in junk folder, disabled by default --- components/email/email-list-item.tsx | 3 +++ components/email/thread-list-item.tsx | 6 ++++++ components/settings/appearance-settings.tsx | 6 +++++- components/ui/avatar.tsx | 12 ++++++++---- locales/de/common.json | 4 ++++ locales/en/common.json | 4 ++++ locales/es/common.json | 4 ++++ locales/fr/common.json | 4 ++++ locales/it/common.json | 4 ++++ locales/ja/common.json | 4 ++++ locales/ko/common.json | 4 ++++ locales/lv/common.json | 4 ++++ locales/nl/common.json | 4 ++++ locales/pl/common.json | 4 ++++ locales/pt/common.json | 4 ++++ locales/ru/common.json | 4 ++++ locales/uk/common.json | 4 ++++ locales/zh/common.json | 4 ++++ stores/settings-store.ts | 3 +++ 19 files changed, 81 insertions(+), 5 deletions(-) diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 54e35320..caa919af 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -37,6 +37,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const { identities } = useAuthStore(); const isChecked = selectedEmailIds.has(email.id); const isUnread = !email.keywords?.$seen; @@ -49,6 +50,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const isFocusedMailLayout = mailLayout === 'focus'; + const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray @@ -164,6 +166,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl email={sender?.email} size="md" className="flex-shrink-0 shadow-sm" + disableImages={hideJunkAvatarImages} /> )} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 21e1c1fe..1e9ee259 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -64,6 +64,8 @@ const SingleEmailItem = React.forwardRef( const emailKeywords = useSettingsStore((state) => state.emailKeywords); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); + const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); + const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; const isUnifiedView = useEmailStore((state) => state.isUnifiedView); const getAccountById = useAccountStore((state) => state.getAccountById); const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; @@ -182,6 +184,7 @@ const SingleEmailItem = React.forwardRef( email={sender?.email} size="md" className="flex-shrink-0 shadow-sm" + disableImages={hideJunkAvatarImages} /> )} @@ -359,6 +362,7 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); + const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const isMobile = useUIStore((state) => state.isMobile); const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const isFocusedMailLayout = mailLayout === 'focus'; @@ -376,6 +380,7 @@ export const ThreadListItem = React.forwardRef )} diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx index 79653d21..4b1a1a47 100644 --- a/components/settings/appearance-settings.tsx +++ b/components/settings/appearance-settings.tsx @@ -67,7 +67,7 @@ export function AppearanceSettings() { const tAdvanced = useTranslations('settings.advanced'); const tTour = useTranslations('tour'); const { theme, setTheme } = useThemeStore(); - const { fontSize, density, animationsEnabled, senderFavicons, updateSetting } = useSettingsStore(); + const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore(); const { startTour, resetTourCompletion } = useTour(); const { isSettingLocked, isSettingHidden } = usePolicyStore(); @@ -130,6 +130,10 @@ export function AppearanceSettings() { updateSetting('senderFavicons', checked)} /> + + updateSetting('showAvatarsInJunk', checked)} /> + + + ) : ( + {t('more_actions')} + )} +
- - {/* Move to folder */} - {moveTree.length > 0 && onMoveToMailbox && ( + {moreMenuSub === null && ( <> + + {/* Move to folder (opens sub-view) */} + {moveTree.length > 0 && onMoveToMailbox && ( + + )} + {/* Tag (opens sub-view) */} + {colorOptions.length > 0 && ( + + )} + {/* Spam */} + {(onMarkAsSpam || onUndoSpam) && ( + + )} + {/* Toggle read state */} + + + + {effectiveEmailContent.isHtml && ( + + )}
-
{t('move_to')}
- {(() => { - const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} -
- )} - {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} -
- ); - }); - }; - return renderMobileNodes(moveTree); - })()} -
+ + + {onShowShortcuts && ( + + )} )} - {/* Tags */} - {colorOptions.length > 0 && ( + {moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => { + const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderMobileNodes(moveTree); + })()} + {moreMenuSub === 'tag' && colorOptions.length > 0 && ( <> -
-
{t('tag')}
{colorOptions.map((option) => { const isActive = currentColors.includes(option.value); return ( )} -
)} - {/* Spam */} - {(onMarkAsSpam || onUndoSpam) && ( - - )} - {/* Toggle read state */} - - - - {effectiveEmailContent.isHtml && ( - - )} -
- - - {onShowShortcuts && ( - - )}
)} From e5083ec1df301be1d36355da40aff2ff8954e22b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:12:33 +0200 Subject: [PATCH 08/27] fix: restore admin panel after Stalwart v0.16 REST API removal --- app/admin/layout.tsx | 105 ++++++++++++++------------ app/api/admin/auth/route.ts | Bin 5977 -> 8742 bytes components/layout/navigation-rail.tsx | 11 ++- 3 files changed, 62 insertions(+), 54 deletions(-) diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 305b47d4..41fe9e05 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -65,6 +65,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const router = useRouter(); const pathname = usePathname(); const [authenticated, setAuthenticated] = useState(null); + const [authError, setAuthError] = useState(null); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); @@ -73,54 +74,59 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl); useEffect(() => { - if (pathname !== '/admin/login') { - checkAuth(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pathname]); + if (pathname === '/admin/login') return; + let cancelled = false; - function getJmapHeaders(): Record { - return getActiveAccountSlotHeaders(); - } + async function checkAuth() { + try { + const jmapHeaders = getActiveAccountSlotHeaders(); + const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders }); + const data = await res.json(); + if (cancelled) return; - async function checkAuth() { - try { - const jmapHeaders = getJmapHeaders(); - const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders }); - const data = await res.json(); + const stalwartAdmin = data.stalwartAdmin === true; + setIsStalwartAdmin(stalwartAdmin); - const stalwartAdmin = data.stalwartAdmin === true; - setIsStalwartAdmin(stalwartAdmin); + // If neither password-based admin nor Stalwart admin, redirect away + if (!data.enabled && !stalwartAdmin) { + router.replace('/'); + return; + } - // If neither password-based admin nor Stalwart admin, redirect away - if (!data.enabled && !stalwartAdmin) { - router.replace('/'); - return; - } - - if (data.authenticated) { - setAuthenticated(true); - return; - } - - // If Stalwart admin but not yet authenticated, auto-login - if (stalwartAdmin) { - const loginRes = await apiFetch('/api/admin/auth', { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...jmapHeaders }, - body: JSON.stringify({ stalwartAuth: true }), - }); - if (loginRes.ok) { + if (data.authenticated) { setAuthenticated(true); return; } - } - router.replace('/admin/login'); - } catch { - router.replace('/admin/login'); + // If Stalwart admin but not yet authenticated, auto-login + if (stalwartAdmin) { + const loginRes = await apiFetch('/api/admin/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...jmapHeaders }, + body: JSON.stringify({ stalwartAuth: true }), + }); + if (cancelled) return; + if (loginRes.ok) { + setAuthenticated(true); + return; + } + const body = await loginRes.json().catch(() => ({})); + setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`); + setAuthenticated(false); + return; + } + + router.replace('/admin/login'); + } catch (err) { + if (cancelled) return; + setAuthError(err instanceof Error ? err.message : 'Network error during admin check'); + setAuthenticated(false); + } } - } + + checkAuth(); + return () => { cancelled = true; }; + }, [pathname, router]); async function handleLogout() { await apiFetch('/api/admin/auth', { method: 'DELETE' }); @@ -132,14 +138,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) return <>{children}; } - if (authenticated === null) { - return ( -
-
Loading...
-
- ); - } - return (
{/* Slim webmail nav rail */} @@ -269,7 +267,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) {/* Main content */}
- {children} + {authError ? ( +
+

Admin authentication failed

+

{authError}

+
+ ) : authenticated === null ? ( +
+ Loading admin panel… +
+ ) : authenticated ? ( + children + ) : null}
diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 3ba03af683674df424422e04320a35b182808735..4d94f59cac36252d1d910341d218c0d906a0bde4 100644 GIT binary patch literal 8742 zcmdT}ZEqXL5$YiX;x>wl>hMP6CD zp-EZHsg+|zud?L_TeiM=_`rpoE_d}HoxA*%sjBOuOuK|7ZnD~XxLl;Bw$DvfKQE>( z?~TDRXL}&Hu2Q&dYK#b49o8=`~e%sSAN%-ttwaK!=kuywoBv8no{I% zy(nR%E3Ld5EbEz^JItKT>yuY|kF8Yl#;N0(9bdg>d)(aBd$*N%dyw(#jh$whI`+Wc zJ@Rs6c~(rPw%l5*4>#7qd1tlBu1#4FOPj(PXR>N*U*%Wy#?2Cz^6)|L@#BXN=rIl1 zE^-s9XO_mxvV`1cXK_GIed|i`wN=GP#J5R|iF&t?0+o!7G~EdRC+~ zHd&T50zMojPrjnk&WjH=mD64ypPkd(L%a!wIZZ&n#TejK{?i znk{TOcM|uCY@RL(m)9LAl9K&-Q6O-HQyZ;ZRU<0M5=bhG<#eVRF7p%yvhd4gnfHU2 z`tRpvaS6p2X5=zguM(Cd(k_4Q4^)L7N~T<0!V*cVO2t&G%D?}IzHSul()G-ZXYi*; znwBQ7*#m|86dFVO+U9udpCojm@$jB3Z4EU!9Hp(6S=|*u5-?cnLzV01Z?MX5vt?P7 z)UH@tfkt`5#K_S&H{2)l$#H3Dgo|_L_H4itd|v~1(xSUP2-u#Az!k<)G7xav0_~B z;e#86DAAhZl#3hp3s|Rp%$C$1mTU>$)eK>qHYN zt|2=Mf^vIJr)KdjQWB}%1q6RE+0yoDR20Cj{2NYT09Dned^sP%r#DaA9Vu2*t@1HV zmibt6d1CAF?4Pclox>w=W_wv?eF}0Jp8MpX{RF>s`t%Bb?kf8&f+!gOls+PdNKL}O zby-Lvsg{EgTEs9u6(z!A4nhfQfWqPQc889hvMetpF}%Os>2A2RaTeP|2=GdkAS4{; zH<@b#WEJDsBKA;{By3KXDmTpyj9fcKAJL79L(_6tfov4Zpf2}=prUB)7e0J&!vMJD zQ^aU|70_q5FO;IIzZm`BSz<{6Z|j%j$4U) zaJtmnPj&^#mw)^ys}rraH)VG7hwzADJ2!KCb15E%uaMThR-qFc)nsG>?b<KHMq$J-N_Ju5FebTp>((Pudxr$LjMR?F)Ts{r&j7#Z5&1%tUc9 zt;PJPDBZsWQd=lDHacm{6C-$+_bD-RXg_L{O^T~dbD+`|l<=fzw?h0i5dq3fJkU0z zn|E3qTTb7;Aw}DMa@M#cD z{Ja#%!Gx4s-IoQvc0V)Jy&nC5#==~`OX#L&mW!I(Q4h=(_A{Ty4LC?1nO+iFYYYU(oQ21bED%?ATD+=l2 zsfS`qQ->^*$$^YUn9xXe)aBB~xPu8oRwp1R5OiwlSu(aRYwM=wZz6d$bgIGUo+)b^+J*lNy<4*4QuAvpN zXF%T$ItH`F7TLC@=p9ABhGJWY<@+9H(|Oz1iw1NJ_s}3>secl}o#bj#PeEw!T(G#JIx&jC#>>O`<#Ex{Yvma|S`1P3Os@UquzPTflL_Pj{#r;O->c zaCGhwMeuz+RNY?>V;;iu;{3okFlyLCIAr`2ZnItt9Qc?9Syd?L;clfva2^8e{TR_! z9w5VId<>9}N`$#^PS==?@{l>CN+(Qu_mdmw80g78+(wh(_V%KsCwlqrf(qPjH=dX3 z+ARhV3-)dbqREEFcrw9U8P>u@!Pt!+nd1wc<{UT_sLaX;S_hs)J!a z+R|?g8Scs*4Tis`<3zA9Pt|N1pCWsdqp?2mPQP|Vr41w>@*MqOsAFcnS?0w-RrB3% ztrx`1jtHP`#05>~-HYaYqScx4Uz7Vh z%{0%F_#jFObug5#KIs4hd|kTp!f#pk)JPWqu)`?{|}@r B`-K1i delta 1721 zcmaJ>L2nyX5LOBzWfMhC;@C;-wZ};l+ljjtmD03{(h$_BBqSxRLnR1-KGrYs()F&j z?>Ui)Oo|XEB-C^c2vHMsC(KqVyRJ=?JxD3@J(-pn`O_szWh z>heSD&V{3k?;ky=qSbW~OI;Rz+Lgv{yOs|PBR@4|z!YropzH|Y_?`e+ViFv$=-5nh zC`jh+vp@nXmK_f&fwSkhe3uJL)vfGMItNyzrzdqq5vqa*P+|fI$K^~&@I4N`P8B?- zDr#Ue4-U)BW4l}diJFkSeAeZA+=YM(zZ%$_5WFI=fP+oMoFWf6{q-7DRR6r84kRpH zUSGNT_Qvw{^_4^(KJ>3E{TZFQ(5DAtt@+gyi;1YA}Fk66A_J9aKDAkQjJo;8R7_lgzY z@uU`jr@PmBh?ER!a6oUo(S80I_5P$gwq?){(ApKUsE}4p%Gf@0B88TCaYrZm=FG7#PH|daCZaDxEmTiJO+7J_+BOpvf~lQp6ynPTx2TQ>^%M%n(R=&c2d372;`vEN4crYY%D@6tYURqd72nxco6sjnTw@< z-|S4@VXojh)u=bN?r}dntJWMDL~(W?ewxztrr?(_fgcW}yA1q#U>Y9|Ox_(_N@%d= zARW=Vx!M5Lj}5Ns)%qZs#a{=fo^Pht4eSd~U?MVxH^NE09Ua5>MtWQG#BajW_#4F^ zhfO-0!k30-le&^PDy8{6Tvs0o*D2Fh+9ht^!h1uLqxw+qls>VT>-zh=sE9loTBLTb z5FFsgkr(mD$jhN7nWlZjDHBbSy4C0mrEMOwc@&);Xd~}2*U^ZH$T+SI_v2f`FW{Zw z4doL#ZW!1f9>cy7laNx8B(9D0oTA|U!K9KHrmTNY<=%)(+4l$2_;+$zUCnxH8t$lt zpO3!To=7+rYfnUZ^m=R>KZ-3<{r9nT9EzVM?&G)Z%3M?R`q+P$H#SCg{0s#$(NjCqBe;v@2Df!=uC`K1ht=?}>Abk=4~# z0e>;`ab4CR^j}Vl;O*q*Q{1=!<1Hs<+AgTQ{0=6lCKWN!NcHLJ|Ft`g`>AW~CTi5m G@_zsb(*}wF diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 595dc55a..4bdccb05 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -8,7 +8,6 @@ import { icons as lucideIcons, type LucideIcon } from "lucide-react"; import { useConfig } from "@/hooks/use-config"; import { useThemeStore } from "@/stores/theme-store"; import { usePathname, Link, useRouter } from "@/i18n/navigation"; -import NextLink from "next/link"; import { useTranslations } from "next-intl"; import { useCalendarStore } from "@/stores/calendar-store"; import { useEmailStore } from "@/stores/email-store"; @@ -336,9 +335,9 @@ export function NavigationRail({ ); })} - {/* Admin (Stalwart admins) */} + {/* Admin (Stalwart admins) — hard nav because /admin lives outside the [locale] tree */} {isStalwartAdmin && ( - {t("admin") || "Admin"} - + )} {/* Settings */} @@ -514,13 +513,13 @@ export function NavigationRail({ {/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
{isStalwartAdmin && ( - - + )} Date: Sat, 25 Apr 2026 01:13:17 +0200 Subject: [PATCH 09/27] fix: restore admin panel after Stalwart v0.16 REST API removal --- app/api/admin/auth/route.ts | Bin 8742 -> 8758 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 4d94f59cac36252d1d910341d218c0d906a0bde4..d3aea57f3d9fd926dd670364fc5ba89d39834a8d 100644 GIT binary patch delta 40 vcmZ4Hvdv|~3s&iX#G=I9)RNSqVw;l6g4Fypg|yU? Date: Sat, 25 Apr 2026 01:37:18 +0200 Subject: [PATCH 10/27] feat: implement OAuth auto-setup functionality for Stalwart integration --- app/admin/auth/page.tsx | 77 ++++++- app/api/admin/oauth/setup/route.ts | 238 ++++++++++++++++++++++ app/api/auth/session/route.ts | 5 +- app/api/auth/totp-token-exchange/route.ts | 5 +- lib/oauth/token-exchange.ts | 20 +- 5 files changed, 334 insertions(+), 11 deletions(-) create mode 100644 app/api/admin/oauth/setup/route.ts diff --git a/app/admin/auth/page.tsx b/app/admin/auth/page.tsx index 54006784..1786aba4 100644 --- a/app/admin/auth/page.tsx +++ b/app/admin/auth/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Save, Loader2, RotateCcw } from 'lucide-react'; +import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; interface ConfigEntry { @@ -69,6 +69,47 @@ export default function AdminAuthPage() { } } + const [setupRunning, setSetupRunning] = useState(false); + const [setupOauthOnly, setSetupOauthOnly] = useState(false); + + async function handleAutoSetup() { + if (typeof window === 'undefined') return; + const oauthOnlyText = setupOauthOnly ? '\n\n • Disable password login (OAuth only)' : ''; + const ok = window.confirm( + `Auto-configure OAuth between this webmail and the connected Stalwart server?\n\nThis will:\n • Create or update an OAuth client called "bulwark-webmail" on the Stalwart server\n • Generate a new client secret\n • Register redirect URIs for ${window.location.origin}\n • Save OAuth settings to admin config (survives env changes)${oauthOnlyText}\n\nYour Stalwart user must have admin permissions.` + ); + if (!ok) return; + + setSetupRunning(true); + setMessage(null); + try { + const res = await apiFetch('/api/admin/oauth/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + origin: window.location.origin, + oauthOnly: setupOauthOnly, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ + type: 'success', + text: `OAuth client ${data.action} on Stalwart. ${data.redirectUriCount} redirect URI(s) registered. Webmail config updated.`, + }); + setEdits({}); + await fetchConfig(); + } else { + const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : ''; + setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail }); + } + } catch (err) { + setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' }); + } finally { + setSetupRunning(false); + } + } + const hasEdits = Object.keys(edits).length > 0; if (loading) { @@ -100,6 +141,40 @@ export default function AdminAuthPage() {
)} + {/* Auto-setup */} +
+
+
+
+ +

Auto-configure OAuth (Stalwart)

+
+

+ Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here. + Requires your Stalwart account to have admin permissions. +

+ +
+ +
+
+ {/* OAuth */}
diff --git a/app/api/admin/oauth/setup/route.ts b/app/api/admin/oauth/setup/route.ts new file mode 100644 index 00000000..b4cde06a --- /dev/null +++ b/app/api/admin/oauth/setup/route.ts @@ -0,0 +1,238 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { randomBytes } from 'node:crypto'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { configManager } from '@/lib/admin/config-manager'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; +import { locales as ALL_LOCALES } from '@/i18n/routing'; + +const CLIENT_ID = 'bulwark-webmail'; +const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)'; +const JMAP_TIMEOUT_MS = 10_000; + +interface JmapMethodCall { + using: string[]; + methodCalls: Array<[string, Record, string]>; +} + +interface JmapMethodResponse { + methodResponses?: Array<[string, Record, string]>; +} + +async function fetchWithTimeout(url: string, init: Parameters[1]): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function jmapCall( + serverUrl: string, + authHeader: string, + body: JmapMethodCall, +): Promise { + const res = await fetchWithTimeout(`${serverUrl}/jmap/`, { + method: 'POST', + headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`); + } + return res.json() as Promise; +} + +async function getStalwartAccountId( + serverUrl: string, + authHeader: string, +): Promise { + const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, { + method: 'GET', + headers: { 'Authorization': authHeader }, + }); + if (!res.ok) return null; + const session = await res.json() as { primaryAccounts?: Record }; + return session.primaryAccounts?.['urn:stalwart:jmap'] + ?? session.primaryAccounts?.['urn:ietf:params:jmap:mail'] + ?? Object.values(session.primaryAccounts ?? {})[0] + ?? null; +} + +function buildRedirectUris(origin: string, localeList: readonly string[]): Record { + const out: Record = {}; + for (const loc of localeList) { + out[`${origin}/${loc}/auth/callback`] = true; + } + return out; +} + +interface SetupRequestBody { + origin?: string; + locales?: string[]; + oauthOnly?: boolean; +} + +export async function POST(request: NextRequest) { + try { + const auth = await requireAdminAuth(); + if ('error' in auth) return auth.error; + + const ip = getClientIP(request); + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json( + { error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' }, + { status: 400 }, + ); + } + + const body = await request.json() as SetupRequestBody; + const origin = (body.origin ?? '').trim().replace(/\/+$/, ''); + if (!/^https?:\/\/[^/]+$/.test(origin)) { + return NextResponse.json( + { error: 'Origin must be a URL like "https://mail.example.com" with no path.' }, + { status: 400 }, + ); + } + const localeList = Array.isArray(body.locales) && body.locales.length > 0 + ? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l)) + : Array.from(ALL_LOCALES); + if (localeList.length === 0) { + return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 }); + } + const oauthOnly = body.oauthOnly === true; + + const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader); + if (!accountId) { + return NextResponse.json( + { error: 'Could not resolve Stalwart account from JMAP session.' }, + { status: 502 }, + ); + } + + const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, { + using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + methodCalls: [[ + 'x:OAuthClient/query', + { accountId, filter: { clientId: CLIENT_ID } }, + '0', + ]], + }); + + const queryEntry = queryRes.methodResponses?.[0]; + if (!queryEntry || queryEntry[0] === 'error') { + return NextResponse.json({ + error: 'Stalwart denied OAuthClient/query — your Stalwart account likely lacks admin permissions.', + detail: queryEntry?.[1], + }, { status: 403 }); + } + const existingIds = (queryEntry[1].ids as string[] | undefined) ?? []; + + const secret = randomBytes(32).toString('base64url'); + const redirectUris = buildRedirectUris(origin, localeList); + + let setArgs: Record; + let action: 'created' | 'updated'; + if (existingIds.length > 0) { + const targetId = existingIds[0]; + action = 'updated'; + setArgs = { + accountId, + update: { + [targetId]: { + secret, + redirectUris, + description: CLIENT_DESCRIPTION, + }, + }, + }; + } else { + action = 'created'; + setArgs = { + accountId, + create: { + new: { + clientId: CLIENT_ID, + description: CLIENT_DESCRIPTION, + secret, + redirectUris, + contacts: { [creds.username]: true }, + }, + }, + }; + } + + const setRes = await jmapCall(creds.serverUrl, creds.authHeader, { + using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + methodCalls: [['x:OAuthClient/set', setArgs, '0']], + }); + + const setEntry = setRes.methodResponses?.[0]; + if (!setEntry || setEntry[0] === 'error') { + return NextResponse.json({ + error: 'Stalwart denied OAuthClient/set — admin permissions required.', + detail: setEntry?.[1], + }, { status: 403 }); + } + const setBody = setEntry[1] as { + notCreated?: Record; + notUpdated?: Record; + }; + if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) { + return NextResponse.json( + { error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated }, + { status: 502 }, + ); + } + if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) { + return NextResponse.json( + { error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated }, + { status: 502 }, + ); + } + + await configManager.ensureLoaded(); + const updates: Record = { + oauthEnabled: true, + oauthClientId: CLIENT_ID, + oauthClientSecret: secret, + oauthIssuerUrl: origin, + }; + if (oauthOnly) updates.oauthOnly = true; + await configManager.setAdminConfig(updates); + + await auditLog('admin.oauth_setup', { + action, + clientId: CLIENT_ID, + issuer: origin, + redirectUriCount: localeList.length, + oauthOnly, + }, ip); + + logger.info('Admin OAuth setup', { + action, + clientId: CLIENT_ID, + issuer: origin, + locales: localeList.length, + }); + + return NextResponse.json({ + ok: true, + action, + clientId: CLIENT_ID, + issuerUrl: origin, + redirectUriCount: localeList.length, + }); + } catch (error) { + logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 }, + ); + } +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 06d11230..e9f9a7cf 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -9,6 +9,7 @@ import { clearStalwartAuthContextInStore, setStalwartAuthContextInStore, } from '@/lib/stalwart/auth-context'; +import { configManager } from '@/lib/admin/config-manager'; const COOKIE_OPTIONS = { ...getCookieOptions(), @@ -25,7 +26,9 @@ function getSlot(request: NextRequest): number { export async function POST(request: NextRequest) { try { - if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') { + const oauthEnabled = configManager.get('oauthEnabled', false); + const oauthOnly = configManager.get('oauthOnly', false); + if (oauthEnabled && oauthOnly) { return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 }); } diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 39ef5bac..2ee7226b 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -5,6 +5,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery'; import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { readFileEnv } from '@/lib/read-file-env'; +import { configManager } from '@/lib/admin/config-manager'; /** * Exchange basic auth credentials (with TOTP appended) for OAuth tokens. @@ -113,8 +114,8 @@ async function attemptAllStrategies( ): Promise { logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint }); - const clientId = process.env.OAUTH_CLIENT_ID; - const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE); + const clientId = configManager.get('oauthClientId', '') || process.env.OAUTH_CLIENT_ID; + const clientSecret = configManager.get('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE); const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; const attempts: Array<{ strategy: string; error: string }> = []; diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts index abeb2348..c54a92ed 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -2,18 +2,23 @@ import { logger } from '@/lib/logger'; import { discoverOAuth } from '@/lib/oauth/discovery'; import type { OAuthMetadata } from '@/lib/oauth/discovery'; import { readFileEnv } from '@/lib/read-file-env'; +import { configManager } from '@/lib/admin/config-manager'; -const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || ''; +function getClientSecret(): string { + const adminSecret = configManager.get('oauthClientSecret', ''); + if (adminSecret) return adminSecret; + return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || ''; +} export function getRequiredConfig() { - const clientId = process.env.OAUTH_CLIENT_ID; - const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL; - const issuerUrl = process.env.OAUTH_ISSUER_URL; + const clientId = configManager.get('oauthClientId', '') || process.env.OAUTH_CLIENT_ID; + const serverUrl = configManager.get('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL; + const issuerUrl = configManager.get('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL; if (!clientId || !serverUrl) { throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`); } const discoveryUrl = issuerUrl?.trim() || serverUrl; - if (issuerUrl !== undefined && !issuerUrl.trim()) { + if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) { logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery'); } return { clientId, serverUrl, discoveryUrl }; @@ -36,8 +41,9 @@ export async function getMetadata(): Promise { export function buildOAuthParams(base: Record): URLSearchParams { const { clientId } = getRequiredConfig(); const params = new URLSearchParams({ ...base, client_id: clientId }); - if (CLIENT_SECRET) { - params.set('client_secret', CLIENT_SECRET); + const secret = getClientSecret(); + if (secret) { + params.set('client_secret', secret); } return params; } From 4f7c9c332bb2ba86acef7098d70b747966d317ef Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:53:12 +0200 Subject: [PATCH 11/27] feat: enhance OAuth auto-setup with dialog and validation for origin and issuer URLs --- app/admin/auth/page.tsx | 128 ++++++++++++++++++++++++----- app/api/admin/oauth/setup/route.ts | 27 ++++-- 2 files changed, 130 insertions(+), 25 deletions(-) diff --git a/app/admin/auth/page.tsx b/app/admin/auth/page.tsx index 1786aba4..426f9bab 100644 --- a/app/admin/auth/page.tsx +++ b/app/admin/auth/page.tsx @@ -70,16 +70,22 @@ export default function AdminAuthPage() { } const [setupRunning, setSetupRunning] = useState(false); + const [setupOpen, setSetupOpen] = useState(false); + const [setupOrigin, setSetupOrigin] = useState(''); + const [setupIssuer, setSetupIssuer] = useState(''); const [setupOauthOnly, setSetupOauthOnly] = useState(false); - async function handleAutoSetup() { + function openSetupDialog() { if (typeof window === 'undefined') return; - const oauthOnlyText = setupOauthOnly ? '\n\n • Disable password login (OAuth only)' : ''; - const ok = window.confirm( - `Auto-configure OAuth between this webmail and the connected Stalwart server?\n\nThis will:\n • Create or update an OAuth client called "bulwark-webmail" on the Stalwart server\n • Generate a new client secret\n • Register redirect URIs for ${window.location.origin}\n • Save OAuth settings to admin config (survives env changes)${oauthOnlyText}\n\nYour Stalwart user must have admin permissions.` - ); - if (!ok) return; + const origin = window.location.origin; + const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || ''; + setSetupOrigin(origin); + setSetupIssuer(jmapUrl || origin); + setSetupOauthOnly(currentValue('oauthOnly') === true); + setSetupOpen(true); + } + async function handleAutoSetup() { setSetupRunning(true); setMessage(null); try { @@ -87,7 +93,8 @@ export default function AdminAuthPage() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - origin: window.location.origin, + origin: setupOrigin.trim().replace(/\/+$/, ''), + issuerUrl: setupIssuer.trim().replace(/\/+$/, ''), oauthOnly: setupOauthOnly, }), }); @@ -95,9 +102,10 @@ export default function AdminAuthPage() { if (res.ok) { setMessage({ type: 'success', - text: `OAuth client ${data.action} on Stalwart. ${data.redirectUriCount} redirect URI(s) registered. Webmail config updated.`, + text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`, }); setEdits({}); + setSetupOpen(false); await fetchConfig(); } else { const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : ''; @@ -110,6 +118,9 @@ export default function AdminAuthPage() { } } + const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, '')); + const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, '')); + const hasEdits = Object.keys(edits).length > 0; if (loading) { @@ -153,19 +164,9 @@ export default function AdminAuthPage() { Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here. Requires your Stalwart account to have admin permissions.

-
+ {/* Auto-setup dialog */} + {setupOpen && ( +
{ if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }} + > +
+
+

Auto-configure OAuth

+

+ Verify the URLs below before continuing. The webmail and Stalwart can live on different domains. +

+
+
+
+ + setSetupOrigin(e.target.value)} + disabled={setupRunning} + placeholder="https://webmail.example.com" + className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +

+ Used to register redirect URIs (one per locale: {setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback) on Stalwart. +

+ {!setupOriginValid && setupOrigin.length > 0 && ( +

Must be like https://host with no path.

+ )} +
+
+ + setSetupIssuer(e.target.value)} + disabled={setupRunning} + placeholder="https://mail.example.com" + className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +

+ Where Stalwart serves /.well-known/oauth-authorization-server. Saved as OAUTH_ISSUER_URL. Pre-filled from your JMAP server URL. +

+ {!setupIssuerValid && setupIssuer.length > 0 && ( +

Must be like https://host with no path.

+ )} +
+ +
+
+ + +
+
+
+ )} + {/* OAuth */}
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 && (
+ @@ -279,14 +282,14 @@ export function CalendarToolbar({
{!isMobile && ( -
+
{views.map((v) => ( 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 ( +
+
+
+
+

+ {title} +

+ {message && ( +

{message}

+ )} + setValue(e.target.value)} + placeholder={placeholder} + className="mt-4" + /> +
+ +
+ + +
+
+
+
+ ); +} 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: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.5.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.5.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
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 (
- - {/* Subscription context menu on right-click */} - {contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => { - const sub = getSubscriptionForCalendar(cal.id); - if (!sub) return null; - return ( -
- - - - {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')} +

+ )} +
{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")}

+
+ +
+ +
+
+ + 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(); }} + /> +
+ +
+ + +
+
+ +
+ + +
+
+
+ ); +} 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() { )} + {!book.isShared && book.myRights?.mayShare && ( + + )} ); @@ -242,6 +254,24 @@ export function AddressBookManagementSettings() { + + {sharingId && client && (() => { + const book = addressBooks.find((b) => b.id === sharingId); + if (!book) return null; + return ( + { + await shareAddressBook(client, book, principalId, rights as AddressBookRights | null); + }} + onClose={() => setSharingId(null)} + /> + ); + })()} ); } diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 9a5151e4..e28d76d9 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -7,7 +7,9 @@ import { useAuthStore } from '@/stores/auth-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { toast } from '@/stores/toast-store'; import { SettingsSection } from './settings-section'; -import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react'; +import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react'; +import { ShareCollectionDialog } from './share-collection-dialog'; +import type { CalendarRights } from '@/lib/jmap/types'; import { cn, formatDateTime } from '@/lib/utils'; import { ICalImportModal } from '@/components/calendar/ical-import-modal'; import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal'; @@ -83,7 +85,7 @@ function CalendarColorPicker({ ); } -function CalendarEditForm({ +export function CalendarEditForm({ initial, onSave, onCancel, @@ -153,7 +155,7 @@ export { CalendarColorPicker, CALENDAR_COLORS }; export function CalendarManagementSettings() { const t = useTranslations('calendar.management'); const { client, serverUrl, username } = useAuthStore(); - const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore(); + const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore(); const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState>({}); const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState(null); @@ -164,6 +166,7 @@ export function CalendarManagementSettings() { const [clearingId, setClearingId] = useState(null); const [isLoading, setIsLoading] = useState(false); const [colorPickerId, setColorPickerId] = useState(null); + const [sharingId, setSharingId] = useState(null); const [showImportModal, setShowImportModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [editingSubscription, setEditingSubscription] = useState(null); @@ -522,6 +525,16 @@ export function CalendarManagementSettings() { > + {cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && ( + + )} + + +
+

{t("description")}

+ + {sharedEntries.length === 0 && !showAdd && ( +
+ {t("no_shares")} +
+ )} + + {sharedEntries.length > 0 && ( +
    + {sharedEntries.map(([principalId, rights]) => { + const principal = allPrincipalsById.get(principalId); + const preset = kind === "calendar" + ? detectCalendarPreset(rights as CalendarRights) + : detectAddressBookPreset(rights as AddressBookRights); + return ( +
  • +
    +
    + {principal?.name || principal?.email || principalId} +
    + {principal?.description && ( +
    + {principal.description} +
    + )} +
    +
    + + +
    + +
  • + ); + })} +
+ )} + + {!showAdd && ( + + )} + + {showAdd && ( +
+ setSearch(e.target.value)} + placeholder={t("search_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" + autoFocus + /> +
+ {loadingPrincipals && ( +
+ + {t("loading_principals")} +
+ )} + {!loadingPrincipals && filteredPrincipals.length === 0 && ( +
+ {search.trim() ? t("no_match") : t("no_principals")} +
+ )} + {!loadingPrincipals && filteredPrincipals.map((p) => ( + + ))} +
+
+ +
+
+ )} +
+ +
+ +
+ + + ); +} diff --git a/lib/__tests__/calendar-alerts.test.ts b/lib/__tests__/calendar-alerts.test.ts index 8a21cbbe..5a791262 100644 --- a/lib/__tests__/calendar-alerts.test.ts +++ b/lib/__tests__/calendar-alerts.test.ts @@ -82,7 +82,7 @@ function makeCalendar(overrides: Partial = {}): Calendar { mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, - mayAdmin: false, + mayShare: false, mayDelete: false, }, ...overrides, diff --git a/lib/birthday-calendar.ts b/lib/birthday-calendar.ts index dab91079..efbd73ac 100644 --- a/lib/birthday-calendar.ts +++ b/lib/birthday-calendar.ts @@ -30,7 +30,7 @@ export function createBirthdayCalendar(name?: string, color?: string): Calendar mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, - mayAdmin: false, + mayShare: false, mayDelete: false, }, }; diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 83f36370..868196e2 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -78,6 +78,10 @@ export class DemoJMAPClient implements IJMAPClient { supportsCalendars(): boolean { return true; } supportsSieve(): boolean { return true; } supportsFiles(): boolean { return true; } + supportsPrincipals(): boolean { return false; } + async getPrincipals(): Promise { return []; } + async setCalendarShare(): Promise { /* demo: no-op */ } + async setAddressBookShare(): Promise { /* demo: no-op */ } // ── Push / state ────────────────────────────────────────────── @@ -552,6 +556,11 @@ export class DemoJMAPClient implements IJMAPClient { if (book) Object.assign(book, updates); } + async deleteAddressBook(addressBookId: string): Promise { + this.data.addressBooks = this.data.addressBooks.filter(b => b.id !== addressBookId); + this.data.contacts = this.data.contacts.filter(c => !c.addressBookIds?.[addressBookId]); + } + async getContacts(addressBookId?: string): Promise { if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]); return [...this.data.contacts]; @@ -609,7 +618,7 @@ export class DemoJMAPClient implements IJMAPClient { includeInAvailability: 'all', defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, - myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true }, + myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }, ...calendar, } as Calendar; this.data.calendars.push(full); diff --git a/lib/demo/fixtures/calendars.ts b/lib/demo/fixtures/calendars.ts index ec1a9466..86affaf7 100644 --- a/lib/demo/fixtures/calendars.ts +++ b/lib/demo/fixtures/calendars.ts @@ -17,7 +17,7 @@ export function createDemoCalendars(): Calendar[] { defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, - myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false }, + myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: false }, }, { id: 'demo-calendar-work', @@ -33,7 +33,7 @@ export function createDemoCalendars(): Calendar[] { defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, - myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true }, + myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }, }, { id: 'demo-calendar-birthdays', @@ -49,7 +49,7 @@ export function createDemoCalendars(): Calendar[] { defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, - myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true }, + myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }, }, ]; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index a05b19b7..8f37e037 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types"; +import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; /** @@ -190,6 +190,7 @@ export interface IJMAPClient { getAllAddressBooks(): Promise; createAddressBook(name: string): Promise; updateAddressBook(addressBookId: string, updates: Partial, targetAccountId?: string): Promise; + deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise; getContacts(addressBookId?: string): Promise; getAllContacts(): Promise; getContact(contactId: string, accountId?: string): Promise; @@ -227,6 +228,12 @@ export interface IJMAPClient { updateCalendarTask(taskId: string, updates: Partial, targetAccountId?: string): Promise; deleteCalendarTask(taskId: string, targetAccountId?: string): Promise; + // ── Sharing (RFC 9670 Principals) ───────────────────────────── + supportsPrincipals(): boolean; + getPrincipals(targetAccountId?: string): Promise; + setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise; + setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise; + // ── Sieve / Filters ────────────────────────────────────────── getSieveAccountId(): string; getSieveCapabilities(): SieveCapabilities | null; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 9ad44504..776f71f5 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types"; +import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; @@ -2826,6 +2826,10 @@ export class JMAPClient implements IJMAPClient { return this.hasCapability("urn:ietf:params:jmap:sieve"); } + supportsPrincipals(): boolean { + return this.hasCapability("urn:ietf:params:jmap:principals"); + } + getSieveAccountId(): string { const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"]; return sieveAccount || this.accountId; @@ -3198,6 +3202,102 @@ export class JMAPClient implements IJMAPClient { throw new Error("Failed to update address book"); } + async deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getContactsAccountId(); + const response = await this.request([ + ["AddressBook/set", { accountId, destroy: [addressBookId] }, "0"], + ], this.contactUsing()); + + const result = response.methodResponses?.[0]?.[1]; + if (result?.notDestroyed?.[addressBookId]) { + const err = result.notDestroyed[addressBookId]; + throw new Error(err.description || "Failed to delete address book"); + } + } + + // ── Sharing (RFC 9670) ────────────────────────────────────────────────────── + + private principalsUsing(): string[] { + return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"]; + } + + /** + * List all principals visible to the user (RFC 9670). Stalwart returns the + * full directory regardless of `filter`, so we fetch the whole list and let + * callers filter client-side. + */ + async getPrincipals(targetAccountId?: string): Promise { + if (!this.supportsPrincipals()) return []; + const accountId = targetAccountId || this.accountId; + try { + const response = await this.request([ + ["Principal/query", { accountId }, "0"], + ["Principal/get", { + accountId, + "#ids": { resultOf: "0", name: "Principal/query", path: "/ids" }, + }, "1"], + ], this.principalsUsing()); + + const getResp = response.methodResponses?.find((r) => r[0] === "Principal/get"); + if (!getResp) return []; + const list = (getResp[1].list || []) as Principal[]; + return list.map((p) => ({ ...p, accountId })); + } catch (error) { + console.error("Failed to fetch principals:", error); + return []; + } + } + + /** + * Add, update, or remove a principal's rights on a calendar. + * Pass `rights: null` to revoke access. + */ + async setCalendarShare( + calendarId: string, + principalId: string, + rights: CalendarRights | null, + targetAccountId?: string, + ): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); + const response = await this.request([ + ["Calendar/set", { + accountId, + update: { [calendarId]: { [`shareWith/${principalId}`]: rights } }, + }, "0"], + ], this.calendarUsing()); + + const result = response.methodResponses?.[0]?.[1]; + if (result?.notUpdated?.[calendarId]) { + const err = result.notUpdated[calendarId]; + throw new Error(err.description || "Failed to update calendar share"); + } + } + + /** + * Add, update, or remove a principal's rights on an address book. + * Pass `rights: null` to revoke access. + */ + async setAddressBookShare( + addressBookId: string, + principalId: string, + rights: AddressBookRights | null, + targetAccountId?: string, + ): Promise { + const accountId = targetAccountId || this.getContactsAccountId(); + const response = await this.request([ + ["AddressBook/set", { + accountId, + update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } }, + }, "0"], + ], this.contactUsing()); + + const result = response.methodResponses?.[0]?.[1]; + if (result?.notUpdated?.[addressBookId]) { + const err = result.notUpdated[addressBookId]; + throw new Error(err.description || "Failed to update address book share"); + } + } + private async fetchPaginatedContacts( accountId: string, filter?: Record, diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 0c2d1714..0bbbeb7b 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -368,6 +368,7 @@ export interface AddressBook { isDefault?: boolean; isSubscribed?: boolean; myRights?: AddressBookRights; + shareWith?: Record | null; accountId?: string; accountName?: string; isShared?: boolean; @@ -376,10 +377,22 @@ export interface AddressBook { export interface AddressBookRights { mayRead: boolean; mayWrite: boolean; - mayShare: boolean; + mayShare?: boolean; mayDelete: boolean; } +// JMAP Principals (RFC 9670) +export interface Principal { + id: string; + type: 'individual' | 'group' | 'resource' | 'location' | 'other'; + name: string; + description?: string | null; + email?: string | null; + timeZone?: string | null; + capabilities?: Record; + accountId?: string; +} + export interface VacationResponse { id: string; isEnabled: boolean; @@ -442,7 +455,7 @@ export interface CalendarRights { mayWriteOwn: boolean; mayUpdatePrivate: boolean; mayRSVP: boolean; - mayAdmin: boolean; + mayShare: boolean; mayDelete: boolean; } diff --git a/locales/cs/common.json b/locales/cs/common.json index b4059a9c..cca4485c 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -426,7 +426,7 @@ "event_updated": "Aktualizace #{sequence}", "event_status_tentative": "Nezávazně", "event_status_cancelled": "Zrušeno", - "expand": "Zobrazit detaily", + "expand": "Zobrazit detaily", "collapse": "Skrýt detaily" }, "send": "Odeslat", @@ -675,7 +675,7 @@ "encryption": "Šifrování", "sidebar_apps": "Aplikace postranního panelu", "notifications": "Oznámení", - "layout": "Vzhled", + "layout": "Vzhled", "reading": "Čtení", "composing": "Psaní", "content_senders": "Obsah a odesílatelé", @@ -688,7 +688,7 @@ "organization": "Organizace pošty", "apps": "Aplikace", "system": "Systém", - "appearance": "Vzhled", + "appearance": "Vzhled", "mail": "Pošta", "privacy": "Soukromí a zabezpečení", "advanced": "Pokročilé" @@ -1094,7 +1094,7 @@ "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", + "verification_code": "Ověřovací kód", "confirm": "Potvrdit", "disable": "Vypnout", "disable_confirm_prompt": "Pro vypnutí dvoufázového ověření zadejte heslo.", @@ -1327,7 +1327,7 @@ "contacts": { "title": "Kontakty", "description": "Import a export kontaktů", - "group_by_letter_label": "Seskupit podle prvního písmene", + "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)", @@ -1595,7 +1595,7 @@ "items_selected": "{count} vybraných zpráv", "edit_draft": "Upravit koncept" }, - "mailbox_context_menu": { + "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é", @@ -1613,10 +1613,10 @@ "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", + "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á", + "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é", @@ -1820,7 +1820,13 @@ "renamed": "Adresář byl přejmenován", "rename_failed": "Přejmenování adresáře selhalo", "default": "Výchozí", - "manage": "Spravovat adresáře" + "manage": "Spravovat adresáře", + "share": "Sdílet adresář", + "new_contact_in_book": "Nový kontakt v tomto adresáři", + "delete": "Smazat adresář", + "confirm_delete": "Smazat „{name}\"? Všechny kontakty v tomto adresáři budou odstraněny.", + "deleted": "Adresář smazán", + "delete_failed": "Adresář se nepodařilo smazat" }, "detail": { "emails": "E-mailové adresy", @@ -1995,7 +2001,7 @@ "email_error_inline": "Neplatný formát e-mailové adresy", "save_failed": "Uložení kontaktu selhalo", "delete": "Odstranit", - "upload_photo": "Nahrát fotku", + "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)", @@ -2091,7 +2097,7 @@ "has_email": "Má e-mail", "has_phone": "Má telefon", "has_photo": "Má fotku" - } + } }, "calendar": { "title": "Kalendář", @@ -2345,7 +2351,9 @@ "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" + "url_copied": "URL CalDAV zkopírováno do schránky", + "share": "Sdílet kalendář", + "new_event_in_calendar": "Nová událost v tomto kalendáři" }, "subscription": { "title": "Odběr iCal", @@ -2711,5 +2719,28 @@ }, "unified_mailbox": { "search_unavailable": "Vyhledávání není ve sjednoceném zobrazení k dispozici" + }, + "sharing": { + "title": "Sdílet „{name}\"", + "description": "Udělte přístup dalším uživatelům nebo skupinám na tomto serveru. Změny se projeví okamžitě.", + "no_shares": "Zatím nikomu nesdíleno.", + "add_person": "Přidat osobu nebo skupinu", + "search_placeholder": "Hledat podle jména nebo e-mailu…", + "loading_principals": "Načítání uživatelů…", + "no_principals": "Nenalezeni žádní další uživatelé ani skupiny.", + "no_match": "Žádné výsledky.", + "remove": "Odebrat přístup", + "group": "Skupina", + "share_added": "Přístup udělen", + "share_updated": "Přístup aktualizován", + "share_removed": "Přístup odebrán", + "share_failed": "Aktualizace sdílení selhala", + "preset": { + "freeBusy": "Pouze volno/zaneprázdněno", + "read": "Pouze čtení", + "readWrite": "Čtení a zápis", + "manager": "Správce", + "custom": "Vlastní" + } } } diff --git a/locales/de/common.json b/locales/de/common.json index 29d1b622..97228c21 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1815,7 +1815,13 @@ "renamed": "Adressbuch umbenannt", "rename_failed": "Adressbuch konnte nicht umbenannt werden", "default": "Standard", - "manage": "Adressbücher verwalten" + "manage": "Adressbücher verwalten", + "share": "Adressbuch freigeben", + "new_contact_in_book": "Neuer Kontakt in diesem Adressbuch", + "delete": "Adressbuch löschen", + "confirm_delete": "„{name}\" löschen? Alle Kontakte in diesem Adressbuch werden entfernt.", + "deleted": "Adressbuch gelöscht", + "delete_failed": "Adressbuch konnte nicht gelöscht werden" }, "detail": { "emails": "E-Mail-Adressen", @@ -2340,7 +2346,9 @@ "confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.", "clear_events": "Ereignisse löschen", "events_cleared": "{count} Ereignisse gelöscht", - "error_clear": "Kalenderereignisse konnten nicht gelöscht werden" + "error_clear": "Kalenderereignisse konnten nicht gelöscht werden", + "share": "Kalender freigeben", + "new_event_in_calendar": "Neuer Termin in diesem Kalender" }, "subscription": { "title": "iCal-Abonnement", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar" + }, + "sharing": { + "title": "„{name}\" freigeben", + "description": "Anderen Benutzern oder Gruppen auf diesem Server Zugriff gewähren. Änderungen werden sofort wirksam.", + "no_shares": "Noch nicht freigegeben.", + "add_person": "Person oder Gruppe hinzufügen", + "search_placeholder": "Nach Name oder E-Mail suchen…", + "loading_principals": "Benutzer werden geladen…", + "no_principals": "Keine weiteren Benutzer oder Gruppen gefunden.", + "no_match": "Keine Treffer.", + "remove": "Zugriff entfernen", + "group": "Gruppe", + "share_added": "Zugriff erteilt", + "share_updated": "Zugriff aktualisiert", + "share_removed": "Zugriff entfernt", + "share_failed": "Freigabe konnte nicht aktualisiert werden", + "preset": { + "freeBusy": "Nur Frei/Belegt", + "read": "Nur lesen", + "readWrite": "Lesen & schreiben", + "manager": "Verwalten", + "custom": "Benutzerdefiniert" + } } } diff --git a/locales/en/common.json b/locales/en/common.json index 23618207..ff1c2a05 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1819,7 +1819,13 @@ "renamed": "Address book renamed", "rename_failed": "Failed to rename address book", "default": "Default", - "manage": "Manage address books" + "manage": "Manage address books", + "share": "Share address book", + "new_contact_in_book": "New contact in this address book", + "delete": "Delete address book", + "confirm_delete": "Delete \"{name}\"? All contacts in this address book will be removed.", + "deleted": "Address book deleted", + "delete_failed": "Failed to delete address book" }, "detail": { "emails": "Email Addresses", @@ -2344,7 +2350,9 @@ "error_delete": "Failed to delete calendar", "caldav_url": "CalDAV URL", "copy_url": "Copy CalDAV URL", - "url_copied": "CalDAV URL copied to clipboard" + "url_copied": "CalDAV URL copied to clipboard", + "share": "Share calendar", + "new_event_in_calendar": "New event in this calendar" }, "subscription": { "title": "iCal Subscription", @@ -2426,6 +2434,29 @@ "overdue": "Overdue" } }, + "sharing": { + "title": "Share \"{name}\"", + "description": "Grant access to other users or groups on this server. Changes take effect immediately.", + "no_shares": "Not shared with anyone yet.", + "add_person": "Add person or group", + "search_placeholder": "Search by name or email…", + "loading_principals": "Loading users…", + "no_principals": "No other users or groups found.", + "no_match": "No matches.", + "remove": "Remove access", + "group": "Group", + "share_added": "Access granted", + "share_updated": "Access updated", + "share_removed": "Access removed", + "share_failed": "Failed to update sharing", + "preset": { + "freeBusy": "Free/busy only", + "read": "Read only", + "readWrite": "Read & write", + "manager": "Manager", + "custom": "Custom" + } + }, "advanced_search": { "title": "Advanced Search", "from": "From", diff --git a/locales/es/common.json b/locales/es/common.json index 77d94863..50d099d9 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1815,7 +1815,13 @@ "renamed": "Libreta de direcciones renombrada", "rename_failed": "Error al renombrar la libreta de direcciones", "default": "Predeterminada", - "manage": "Administrar libretas de direcciones" + "manage": "Administrar libretas de direcciones", + "share": "Compartir libreta de direcciones", + "new_contact_in_book": "Nuevo contacto en esta libreta", + "delete": "Eliminar libreta de direcciones", + "confirm_delete": "¿Eliminar «{name}»? Todos los contactos de esta libreta se eliminarán.", + "deleted": "Libreta de direcciones eliminada", + "delete_failed": "No se pudo eliminar la libreta de direcciones" }, "detail": { "emails": "Direcciones de correo", @@ -2340,7 +2346,9 @@ "confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.", "clear_events": "Borrar eventos", "events_cleared": "{count} eventos borrados", - "error_clear": "No se pudieron borrar los eventos del calendario" + "error_clear": "No se pudieron borrar los eventos del calendario", + "share": "Compartir calendario", + "new_event_in_calendar": "Nuevo evento en este calendario" }, "subscription": { "title": "Suscripción iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "La búsqueda no está disponible en la vista unificada" + }, + "sharing": { + "title": "Compartir «{name}»", + "description": "Concede acceso a otros usuarios o grupos de este servidor. Los cambios surten efecto inmediatamente.", + "no_shares": "Aún no se ha compartido con nadie.", + "add_person": "Añadir persona o grupo", + "search_placeholder": "Buscar por nombre o correo…", + "loading_principals": "Cargando usuarios…", + "no_principals": "No se han encontrado otros usuarios ni grupos.", + "no_match": "Sin resultados.", + "remove": "Quitar acceso", + "group": "Grupo", + "share_added": "Acceso concedido", + "share_updated": "Acceso actualizado", + "share_removed": "Acceso retirado", + "share_failed": "No se pudo actualizar el uso compartido", + "preset": { + "freeBusy": "Solo disponibilidad", + "read": "Solo lectura", + "readWrite": "Lectura y escritura", + "manager": "Administrador", + "custom": "Personalizado" + } } } diff --git a/locales/fr/common.json b/locales/fr/common.json index 34fc8281..b37618cd 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1815,7 +1815,13 @@ "renamed": "Carnet d'adresses renommé", "rename_failed": "Échec du renommage du carnet d'adresses", "default": "Par défaut", - "manage": "Gérer les carnets d'adresses" + "manage": "Gérer les carnets d'adresses", + "share": "Partager le carnet d'adresses", + "new_contact_in_book": "Nouveau contact dans ce carnet d'adresses", + "delete": "Supprimer le carnet d'adresses", + "confirm_delete": "Supprimer « {name} » ? Tous les contacts de ce carnet d'adresses seront supprimés.", + "deleted": "Carnet d'adresses supprimé", + "delete_failed": "Échec de la suppression du carnet d'adresses" }, "detail": { "emails": "Adresses e-mail", @@ -2340,7 +2346,9 @@ "confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.", "clear_events": "Supprimer les événements", "events_cleared": "{count} événements supprimés", - "error_clear": "Impossible de supprimer les événements du calendrier" + "error_clear": "Impossible de supprimer les événements du calendrier", + "share": "Partager le calendrier", + "new_event_in_calendar": "Nouvel événement dans ce calendrier" }, "subscription": { "title": "Abonnement iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "La recherche n'est pas disponible dans la vue unifiée" + }, + "sharing": { + "title": "Partager « {name} »", + "description": "Accordez l'accès à d'autres utilisateurs ou groupes de ce serveur. Les modifications sont immédiates.", + "no_shares": "Pas encore partagé.", + "add_person": "Ajouter une personne ou un groupe", + "search_placeholder": "Rechercher par nom ou e-mail…", + "loading_principals": "Chargement des utilisateurs…", + "no_principals": "Aucun autre utilisateur ou groupe trouvé.", + "no_match": "Aucun résultat.", + "remove": "Révoquer l'accès", + "group": "Groupe", + "share_added": "Accès accordé", + "share_updated": "Accès mis à jour", + "share_removed": "Accès révoqué", + "share_failed": "Échec de la mise à jour du partage", + "preset": { + "freeBusy": "Disponibilité uniquement", + "read": "Lecture seule", + "readWrite": "Lecture & écriture", + "manager": "Gestionnaire", + "custom": "Personnalisé" + } } } diff --git a/locales/it/common.json b/locales/it/common.json index 85c99029..050fd9f4 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1815,7 +1815,13 @@ "renamed": "Rubrica rinominata", "rename_failed": "Impossibile rinominare la rubrica", "default": "Predefinita", - "manage": "Gestisci rubriche" + "manage": "Gestisci rubriche", + "share": "Condividi rubrica", + "new_contact_in_book": "Nuovo contatto in questa rubrica", + "delete": "Elimina rubrica", + "confirm_delete": "Eliminare \"{name}\"? Tutti i contatti in questa rubrica verranno rimossi.", + "deleted": "Rubrica eliminata", + "delete_failed": "Impossibile eliminare la rubrica" }, "detail": { "emails": "Indirizzi email", @@ -2340,7 +2346,9 @@ "confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.", "clear_events": "Cancella eventi", "events_cleared": "{count} eventi cancellati", - "error_clear": "Impossibile cancellare gli eventi del calendario" + "error_clear": "Impossibile cancellare gli eventi del calendario", + "share": "Condividi calendario", + "new_event_in_calendar": "Nuovo evento in questo calendario" }, "subscription": { "title": "Abbonamento iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "La ricerca non è disponibile nella vista unificata" + }, + "sharing": { + "title": "Condividi \"{name}\"", + "description": "Concedi l'accesso ad altri utenti o gruppi su questo server. Le modifiche hanno effetto immediato.", + "no_shares": "Non ancora condiviso.", + "add_person": "Aggiungi persona o gruppo", + "search_placeholder": "Cerca per nome o email…", + "loading_principals": "Caricamento utenti…", + "no_principals": "Nessun altro utente o gruppo trovato.", + "no_match": "Nessun risultato.", + "remove": "Rimuovi accesso", + "group": "Gruppo", + "share_added": "Accesso concesso", + "share_updated": "Accesso aggiornato", + "share_removed": "Accesso rimosso", + "share_failed": "Impossibile aggiornare la condivisione", + "preset": { + "freeBusy": "Solo libero/occupato", + "read": "Sola lettura", + "readWrite": "Lettura e scrittura", + "manager": "Gestore", + "custom": "Personalizzato" + } } } diff --git a/locales/ja/common.json b/locales/ja/common.json index e7592ab0..00bc89ff 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1815,7 +1815,13 @@ "renamed": "アドレス帳の名前を変更しました", "rename_failed": "アドレス帳の名前変更に失敗しました", "default": "デフォルト", - "manage": "アドレス帳を管理" + "manage": "アドレス帳を管理", + "share": "アドレス帳を共有", + "new_contact_in_book": "このアドレス帳に新規連絡先", + "delete": "アドレス帳を削除", + "confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。", + "deleted": "アドレス帳を削除しました", + "delete_failed": "アドレス帳の削除に失敗しました" }, "detail": { "emails": "メールアドレス", @@ -2340,7 +2346,9 @@ "confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。", "clear_events": "イベントを削除", "events_cleared": "{count}件のイベントを削除しました", - "error_clear": "カレンダーイベントの削除に失敗しました" + "error_clear": "カレンダーイベントの削除に失敗しました", + "share": "カレンダーを共有", + "new_event_in_calendar": "このカレンダーに新規イベント" }, "subscription": { "title": "iCal購読", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "統合ビューでは検索を利用できません" + }, + "sharing": { + "title": "「{name}」を共有", + "description": "このサーバー上の他のユーザーまたはグループにアクセス権を付与します。変更はすぐに反映されます。", + "no_shares": "まだ誰にも共有されていません。", + "add_person": "ユーザーまたはグループを追加", + "search_placeholder": "名前またはメールで検索…", + "loading_principals": "ユーザーを読み込み中…", + "no_principals": "他のユーザーまたはグループは見つかりません。", + "no_match": "一致する項目がありません。", + "remove": "アクセス権を削除", + "group": "グループ", + "share_added": "アクセス権を付与しました", + "share_updated": "アクセス権を更新しました", + "share_removed": "アクセス権を削除しました", + "share_failed": "共有の更新に失敗しました", + "preset": { + "freeBusy": "空き時間情報のみ", + "read": "読み取り専用", + "readWrite": "読み取り・書き込み", + "manager": "管理者", + "custom": "カスタム" + } } } diff --git a/locales/ko/common.json b/locales/ko/common.json index 2aed2cc0..0ae89054 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1815,7 +1815,13 @@ "renamed": "주소록 이름이 변경되었습니다", "rename_failed": "주소록 이름 변경 실패", "default": "기본", - "manage": "주소록 관리" + "manage": "주소록 관리", + "share": "주소록 공유", + "new_contact_in_book": "이 주소록에 새 연락처", + "delete": "주소록 삭제", + "confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.", + "deleted": "주소록이 삭제되었습니다", + "delete_failed": "주소록 삭제에 실패했습니다" }, "detail": { "emails": "이메일", @@ -2340,7 +2346,9 @@ "error_delete": "캘린더를 삭제하지 못했어요", "caldav_url": "CalDAV URL", "copy_url": "CalDAV URL 복사", - "url_copied": "CalDAV URL이 클립보드에 복사되었어요" + "url_copied": "CalDAV URL이 클립보드에 복사되었어요", + "share": "캘린더 공유", + "new_event_in_calendar": "이 캘린더에 새 일정" }, "subscription": { "title": "iCal 구독", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다" + }, + "sharing": { + "title": "\"{name}\" 공유", + "description": "이 서버의 다른 사용자나 그룹에 액세스 권한을 부여합니다. 변경 사항은 즉시 적용됩니다.", + "no_shares": "아직 공유되지 않았습니다.", + "add_person": "사용자 또는 그룹 추가", + "search_placeholder": "이름 또는 이메일로 검색…", + "loading_principals": "사용자 불러오는 중…", + "no_principals": "다른 사용자나 그룹을 찾을 수 없습니다.", + "no_match": "일치하는 항목이 없습니다.", + "remove": "액세스 권한 제거", + "group": "그룹", + "share_added": "액세스 권한이 부여되었습니다", + "share_updated": "액세스 권한이 업데이트되었습니다", + "share_removed": "액세스 권한이 제거되었습니다", + "share_failed": "공유 업데이트에 실패했습니다", + "preset": { + "freeBusy": "한가함/바쁨만", + "read": "읽기 전용", + "readWrite": "읽기 및 쓰기", + "manager": "관리자", + "custom": "사용자 지정" + } } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 44da8b8d..a8830bb1 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1811,7 +1811,13 @@ "renamed": "Adrešu grāmata pārdēvēta", "rename_failed": "Neizdevās pārdēvēt adrešu grāmatu", "default": "Noklusējuma", - "manage": "Pārvaldīt adrešu grāmatas" + "manage": "Pārvaldīt adrešu grāmatas", + "share": "Kopīgot adrešu grāmatu", + "new_contact_in_book": "Jauns kontakts šajā adrešu grāmatā", + "delete": "Dzēst adrešu grāmatu", + "confirm_delete": "Dzēst \"{name}\"? Visi kontakti šajā adrešu grāmatā tiks noņemti.", + "deleted": "Adrešu grāmata dzēsta", + "delete_failed": "Neizdevās dzēst adrešu grāmatu" }, "detail": { "emails": "E-pasta adreses", @@ -2339,7 +2345,9 @@ "error_delete": "Neizdevās izdzēst kalendāru", "caldav_url": "CalDAV URL", "copy_url": "Kopēt CalDAV URL", - "url_copied": "CalDAV URL nokopēts starpliktuvē" + "url_copied": "CalDAV URL nokopēts starpliktuvē", + "share": "Kopīgot kalendāru", + "new_event_in_calendar": "Jauns notikums šajā kalendārā" }, "subscription": { "title": "iCal abonements", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Meklēšana nav pieejama apvienotajā skatā" + }, + "sharing": { + "title": "Kopīgot \"{name}\"", + "description": "Piešķiriet piekļuvi citiem lietotājiem vai grupām šajā serverī. Izmaiņas stājas spēkā nekavējoties.", + "no_shares": "Vēl nav kopīgots.", + "add_person": "Pievienot personu vai grupu", + "search_placeholder": "Meklēt pēc vārda vai e-pasta…", + "loading_principals": "Ielādē lietotājus…", + "no_principals": "Citi lietotāji vai grupas nav atrastas.", + "no_match": "Nav atbilstību.", + "remove": "Noņemt piekļuvi", + "group": "Grupa", + "share_added": "Piekļuve piešķirta", + "share_updated": "Piekļuve atjaunināta", + "share_removed": "Piekļuve noņemta", + "share_failed": "Neizdevās atjaunināt kopīgošanu", + "preset": { + "freeBusy": "Tikai brīvs/aizņemts", + "read": "Tikai lasīšana", + "readWrite": "Lasīšana un rakstīšana", + "manager": "Pārvaldnieks", + "custom": "Pielāgots" + } } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 44dd31af..42b6e9ce 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1815,7 +1815,13 @@ "renamed": "Adresboek hernoemd", "rename_failed": "Adresboek hernoemen mislukt", "default": "Standaard", - "manage": "Adresboeken beheren" + "manage": "Adresboeken beheren", + "share": "Adresboek delen", + "new_contact_in_book": "Nieuw contact in dit adresboek", + "delete": "Adresboek verwijderen", + "confirm_delete": "\"{name}\" verwijderen? Alle contacten in dit adresboek worden verwijderd.", + "deleted": "Adresboek verwijderd", + "delete_failed": "Adresboek kon niet worden verwijderd" }, "detail": { "emails": "E-mailadressen", @@ -2340,7 +2346,9 @@ "confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.", "clear_events": "Afspraken verwijderen", "events_cleared": "{count} afspraken verwijderd", - "error_clear": "Kan agendagebeurtenissen niet verwijderen" + "error_clear": "Kan agendagebeurtenissen niet verwijderen", + "share": "Agenda delen", + "new_event_in_calendar": "Nieuwe afspraak in deze agenda" }, "subscription": { "title": "iCal-abonnement", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave" + }, + "sharing": { + "title": "\"{name}\" delen", + "description": "Geef andere gebruikers of groepen op deze server toegang. Wijzigingen zijn direct van kracht.", + "no_shares": "Nog niet gedeeld.", + "add_person": "Persoon of groep toevoegen", + "search_placeholder": "Zoeken op naam of e-mail…", + "loading_principals": "Gebruikers laden…", + "no_principals": "Geen andere gebruikers of groepen gevonden.", + "no_match": "Geen overeenkomsten.", + "remove": "Toegang intrekken", + "group": "Groep", + "share_added": "Toegang verleend", + "share_updated": "Toegang bijgewerkt", + "share_removed": "Toegang ingetrokken", + "share_failed": "Delen kon niet worden bijgewerkt", + "preset": { + "freeBusy": "Alleen vrij/bezet", + "read": "Alleen lezen", + "readWrite": "Lezen en schrijven", + "manager": "Beheerder", + "custom": "Aangepast" + } } } diff --git a/locales/pl/common.json b/locales/pl/common.json index f010cbe6..e6778fcf 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1815,7 +1815,13 @@ "renamed": "Zmieniono nazwę książki adresowej", "rename_failed": "Nie udało się zmienić nazwy książki adresowej", "default": "Domyślna", - "manage": "Zarządzaj książkami adresowymi" + "manage": "Zarządzaj książkami adresowymi", + "share": "Udostępnij książkę adresową", + "new_contact_in_book": "Nowy kontakt w tej książce adresowej", + "delete": "Usuń książkę adresową", + "confirm_delete": "Usunąć „{name}\"? Wszystkie kontakty w tej książce adresowej zostaną usunięte.", + "deleted": "Książka adresowa usunięta", + "delete_failed": "Nie udało się usunąć książki adresowej" }, "detail": { "emails": "Adresy e-mail", @@ -2340,7 +2346,9 @@ "error_delete": "Nie udało się usunąć kalendarza", "caldav_url": "Adres URL CalDAV", "copy_url": "Kopiuj adres URL CalDAV", - "url_copied": "Adres URL CalDAV skopiowano do schowka" + "url_copied": "Adres URL CalDAV skopiowano do schowka", + "share": "Udostępnij kalendarz", + "new_event_in_calendar": "Nowe wydarzenie w tym kalendarzu" }, "subscription": { "title": "Subskrypcja iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym" + }, + "sharing": { + "title": "Udostępnij „{name}\"", + "description": "Udziel dostępu innym użytkownikom lub grupom na tym serwerze. Zmiany są natychmiastowe.", + "no_shares": "Jeszcze nie udostępniono.", + "add_person": "Dodaj osobę lub grupę", + "search_placeholder": "Szukaj po imieniu lub e-mailu…", + "loading_principals": "Ładowanie użytkowników…", + "no_principals": "Nie znaleziono innych użytkowników ani grup.", + "no_match": "Brak wyników.", + "remove": "Usuń dostęp", + "group": "Grupa", + "share_added": "Dostęp przyznany", + "share_updated": "Dostęp zaktualizowany", + "share_removed": "Dostęp usunięty", + "share_failed": "Nie udało się zaktualizować udostępniania", + "preset": { + "freeBusy": "Tylko dostępność", + "read": "Tylko do odczytu", + "readWrite": "Odczyt i zapis", + "manager": "Menedżer", + "custom": "Niestandardowe" + } } } diff --git a/locales/pt/common.json b/locales/pt/common.json index f903909e..4ffc6c03 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1815,7 +1815,13 @@ "renamed": "Catálogo de endereços renomeado", "rename_failed": "Falha ao renomear o catálogo de endereços", "default": "Padrão", - "manage": "Gerenciar catálogos de endereços" + "manage": "Gerenciar catálogos de endereços", + "share": "Compartilhar lista de contatos", + "new_contact_in_book": "Novo contato nesta lista", + "delete": "Excluir lista de contatos", + "confirm_delete": "Excluir \"{name}\"? Todos os contatos desta lista serão removidos.", + "deleted": "Lista de contatos excluída", + "delete_failed": "Falha ao excluir a lista de contatos" }, "detail": { "emails": "Endereços de e-mail", @@ -2340,7 +2346,9 @@ "confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.", "clear_events": "Limpar eventos", "events_cleared": "{count} eventos removidos", - "error_clear": "Falha ao limpar os eventos do calendário" + "error_clear": "Falha ao limpar os eventos do calendário", + "share": "Compartilhar calendário", + "new_event_in_calendar": "Novo evento neste calendário" }, "subscription": { "title": "Assinatura iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "A pesquisa não está disponível na vista unificada" + }, + "sharing": { + "title": "Compartilhar \"{name}\"", + "description": "Conceda acesso a outros usuários ou grupos neste servidor. As alterações têm efeito imediato.", + "no_shares": "Ainda não compartilhado.", + "add_person": "Adicionar pessoa ou grupo", + "search_placeholder": "Buscar por nome ou e-mail…", + "loading_principals": "Carregando usuários…", + "no_principals": "Nenhum outro usuário ou grupo encontrado.", + "no_match": "Sem resultados.", + "remove": "Remover acesso", + "group": "Grupo", + "share_added": "Acesso concedido", + "share_updated": "Acesso atualizado", + "share_removed": "Acesso removido", + "share_failed": "Falha ao atualizar o compartilhamento", + "preset": { + "freeBusy": "Apenas disponibilidade", + "read": "Somente leitura", + "readWrite": "Leitura e escrita", + "manager": "Gerente", + "custom": "Personalizado" + } } } diff --git a/locales/ru/common.json b/locales/ru/common.json index 0969ec71..d6fcebe0 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1815,7 +1815,13 @@ "renamed": "Адресная книга переименована", "rename_failed": "Не удалось переименовать адресную книгу", "default": "По умолчанию", - "manage": "Управление адресными книгами" + "manage": "Управление адресными книгами", + "share": "Поделиться адресной книгой", + "new_contact_in_book": "Новый контакт в этой адресной книге", + "delete": "Удалить адресную книгу", + "confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.", + "deleted": "Адресная книга удалена", + "delete_failed": "Не удалось удалить адресную книгу" }, "detail": { "emails": "Адреса электронной почты", @@ -2340,7 +2346,9 @@ "error_delete": "Не удалось удалить календарь", "caldav_url": "URL CalDAV", "copy_url": "Скопировать CalDAV URL", - "url_copied": "CalDAV URL скопирован в буфер обмена" + "url_copied": "CalDAV URL скопирован в буфер обмена", + "share": "Поделиться календарём", + "new_event_in_calendar": "Новое событие в этом календаре" }, "subscription": { "title": "Подписка iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Поиск недоступен в объединённом представлении" + }, + "sharing": { + "title": "Поделиться «{name}»", + "description": "Предоставьте доступ другим пользователям или группам на этом сервере. Изменения вступают в силу немедленно.", + "no_shares": "Пока никому не предоставлен доступ.", + "add_person": "Добавить пользователя или группу", + "search_placeholder": "Искать по имени или email…", + "loading_principals": "Загрузка пользователей…", + "no_principals": "Других пользователей или групп не найдено.", + "no_match": "Нет совпадений.", + "remove": "Отозвать доступ", + "group": "Группа", + "share_added": "Доступ предоставлен", + "share_updated": "Доступ обновлён", + "share_removed": "Доступ отозван", + "share_failed": "Не удалось обновить общий доступ", + "preset": { + "freeBusy": "Только занятость", + "read": "Только чтение", + "readWrite": "Чтение и запись", + "manager": "Управляющий", + "custom": "Пользовательский" + } } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 3ea4da5c..e026fcb2 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1815,7 +1815,13 @@ "renamed": "Адресну книгу перейменовано", "rename_failed": "Не вдалося перейменувати адресну книгу", "default": "За замовчуванням", - "manage": "Керуйте адресними книгами" + "manage": "Керуйте адресними книгами", + "share": "Поділитися адресною книгою", + "new_contact_in_book": "Новий контакт у цій адресній книзі", + "delete": "Видалити адресну книгу", + "confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.", + "deleted": "Адресну книгу видалено", + "delete_failed": "Не вдалося видалити адресну книгу" }, "detail": { "emails": "Адреси електронної пошти", @@ -2340,7 +2346,9 @@ "error_delete": "Не вдалося видалити календар", "caldav_url": "URL-адреса CalDAV", "copy_url": "Скопіюйте URL-адресу CalDAV", - "url_copied": "URL-адресу CalDAV скопійовано в буфер обміну" + "url_copied": "URL-адресу CalDAV скопійовано в буфер обміну", + "share": "Поділитися календарем", + "new_event_in_calendar": "Нова подія в цьому календарі" }, "subscription": { "title": "Підписка iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Пошук недоступний в об'єднаному перегляді" + }, + "sharing": { + "title": "Поділитися «{name}»", + "description": "Надайте доступ іншим користувачам або групам на цьому сервері. Зміни набувають чинності негайно.", + "no_shares": "Поки що ні з ким не поділено.", + "add_person": "Додати людину або групу", + "search_placeholder": "Шукати за іменем або email…", + "loading_principals": "Завантаження користувачів…", + "no_principals": "Інших користувачів або груп не знайдено.", + "no_match": "Збігів немає.", + "remove": "Видалити доступ", + "group": "Група", + "share_added": "Доступ надано", + "share_updated": "Доступ оновлено", + "share_removed": "Доступ видалено", + "share_failed": "Не вдалося оновити спільний доступ", + "preset": { + "freeBusy": "Лише зайнятість", + "read": "Лише читання", + "readWrite": "Читання та запис", + "manager": "Керівник", + "custom": "Власне" + } } } diff --git a/locales/zh/common.json b/locales/zh/common.json index f4bb33ea..ce2b639f 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1815,7 +1815,13 @@ "renamed": "地址簿已重命名", "rename_failed": "重命名地址簿失败", "default": "默认", - "manage": "管理地址簿" + "manage": "管理地址簿", + "share": "共享通讯录", + "new_contact_in_book": "在此通讯录中新建联系人", + "delete": "删除通讯录", + "confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。", + "deleted": "通讯录已删除", + "delete_failed": "删除通讯录失败" }, "detail": { "emails": "邮箱地址", @@ -2340,7 +2346,9 @@ "error_delete": "删除日历失败", "caldav_url": "CalDAV URL", "copy_url": "复制 CalDAV URL", - "url_copied": "CalDAV URL 已复制到剪贴板" + "url_copied": "CalDAV URL 已复制到剪贴板", + "share": "共享日历", + "new_event_in_calendar": "在此日历中新建事件" }, "subscription": { "title": "iCal 订阅", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "统一视图中无法使用搜索" + }, + "sharing": { + "title": "共享「{name}」", + "description": "向此服务器上的其他用户或群组授予访问权限。更改会立即生效。", + "no_shares": "尚未共享。", + "add_person": "添加用户或群组", + "search_placeholder": "按姓名或邮箱搜索…", + "loading_principals": "正在加载用户…", + "no_principals": "未找到其他用户或群组。", + "no_match": "无匹配项。", + "remove": "取消访问", + "group": "群组", + "share_added": "已授予访问权限", + "share_updated": "已更新访问权限", + "share_removed": "已取消访问权限", + "share_failed": "更新共享失败", + "preset": { + "freeBusy": "仅显示忙/闲", + "read": "只读", + "readWrite": "读写", + "manager": "管理员", + "custom": "自定义" + } } } diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 7d7125bb..75f199b7 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; +import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from '@/lib/jmap/types'; import { debug } from '@/lib/debug'; import { normalizeAllDayDuration } from '@/lib/calendar-utils'; import { parseDuration } from '@/components/calendar/event-card'; @@ -125,6 +125,7 @@ interface CalendarStore { rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record | null) => Promise; importEvents: (client: IJMAPClient, events: Partial[], calendarId: string) => Promise; updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial) => Promise; + shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise; createCalendar: (client: IJMAPClient, calendar: Partial) => Promise; removeCalendar: (client: IJMAPClient, calendarId: string) => Promise; clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise; @@ -653,6 +654,29 @@ export const useCalendarStore = create()( } }, + shareCalendar: async (client, calendarId, principalId, rights) => { + set({ error: null }); + try { + const cal = get().calendars.find(c => c.id === calendarId); + const realId = cal?.originalId || calendarId; + const targetAccountId = cal?.accountId; + await client.setCalendarShare(realId, principalId, rights, targetAccountId); + set((state) => ({ + calendars: state.calendars.map(c => { + if (c.id !== calendarId) return c; + const next = { ...(c.shareWith ?? {}) }; + if (rights === null) delete next[principalId]; + else next[principalId] = rights; + return { ...c, shareWith: next }; + }), + })); + } catch (error) { + debug.error('Failed to share calendar:', error); + set({ error: 'Failed to share calendar' }); + throw error; + } + }, + createCalendar: async (client, calendar) => { set({ error: null }); try { diff --git a/stores/contact-store.ts b/stores/contact-store.ts index b388d2a9..a376ed7a 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types'; +import type { ContactCard, AddressBook, AddressBookRights, ContactName } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { generateUUID } from '@/lib/utils'; import { debug } from '@/lib/debug'; @@ -101,6 +101,8 @@ interface ContactStore { bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise; moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise; renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise; + removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise; + shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise; renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise; importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise; @@ -655,6 +657,45 @@ export const useContactStore = create()( } }, + removeAddressBook: async (client, addressBook) => { + set({ error: null }); + try { + const originalId = addressBook.originalId || addressBook.id; + const accountId = addressBook.isShared ? addressBook.accountId : undefined; + await client.deleteAddressBook(originalId, accountId); + set((state) => ({ + addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id), + contacts: state.contacts.filter(c => !c.addressBookIds?.[addressBook.id]), + })); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to delete address book'; + set({ error: msg }); + throw error; + } + }, + + shareAddressBook: async (client, addressBook, principalId, rights) => { + set({ error: null }); + try { + const originalId = addressBook.originalId || addressBook.id; + const accountId = addressBook.isShared ? addressBook.accountId : undefined; + await client.setAddressBookShare(originalId, principalId, rights, accountId); + set((state) => ({ + addressBooks: state.addressBooks.map(b => { + if (b.id !== addressBook.id) return b; + const next = { ...(b.shareWith ?? {}) }; + if (rights === null) delete next[principalId]; + else next[principalId] = rights; + return { ...b, shareWith: next }; + }), + })); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to share address book'; + set({ error: msg }); + throw error; + } + }, + renameKeyword: async (client, oldKeyword, newKeyword) => { set({ error: null }); const oldKw = oldKeyword.trim(); From ae517732f7a58603ca3824a1d4ab269055151e8a Mon Sep 17 00:00:00 2001 From: Luis Felipe Marzagao Date: Sun, 26 Apr 2026 22:50:10 +0000 Subject: [PATCH 26/27] i18n: Fix word 'contactos' and its variants. --- locales/pt/common.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/locales/pt/common.json b/locales/pt/common.json index 4ffc6c03..a072b097 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -20,7 +20,7 @@ "generic": "Ocorreu um erro. Por favor, tente novamente.", "totp_required": "É necessário um código de autenticação de dois fatores. Insira seu código abaixo.", "totp_invalid": "Código de autenticação inválido. Verifique seu aplicativo de autenticação.", - "oauth_discovery_failed": "SSO está ativado mas o provedor de identidade não pôde ser contactado. Verifique sua configuração OAuth." + "oauth_discovery_failed": "SSO está ativado mas o provedor de identidade não pôde ser contatado. Verifique sua configuração OAuth." }, "show_password": "Mostrar senha", "hide_password": "Ocultar senha", @@ -669,7 +669,7 @@ "security": "Segurança", "encryption": "Criptografia", "files": "Arquivos", - "contacts": "Contactos", + "contacts": "Contatos", "sidebar_apps": "Apps da barra lateral", "notifications": "Notificações", "layout": "Layout", @@ -1322,12 +1322,12 @@ "manage_description": "Adicionar, editar ou remover apps personalizados da barra lateral" }, "contacts": { - "title": "Contactos", - "description": "Importar e exportar os seus contactos", - "import_label": "Importar contactos", - "import_description": "Importar contactos de um ficheiro vCard (.vcf)", - "export_label": "Exportar contactos", - "export_description": "Exportar todos os contactos como ficheiro vCard (.vcf)", + "title": "Contatos", + "description": "Importar e exportar os seus contatos", + "import_label": "Importar contatos", + "import_description": "Importar contatos de um ficheiro vCard (.vcf)", + "export_label": "Exportar contatos", + "export_description": "Exportar todos os contatos como ficheiro vCard (.vcf)", "manage_title": "Catálogos de endereços", "manage_description": "Renomeie seus catálogos de endereços", "no_address_books": "Nenhum catálogo de endereços encontrado", @@ -1874,7 +1874,7 @@ "cert_already_imported": "Certificado já importado", "cert_imported": "Certificado importado", "cert_import_failed": "Falha ao importar o certificado", - "section_contact": "Contact details", + "section_contact": "Detalhes do contato", "section_work": "Work", "section_personal": "Personal", "email_default_label": "Email", @@ -2243,7 +2243,7 @@ "hover_preview_delay_2s": "Atraso de 2 segundos", "hover_preview_off": "Desativado", "show_birthday_calendar": "Calendário de aniversários", - "show_birthday_calendar_desc": "Mostrar um calendário virtual com os aniversários dos seus contactos" + "show_birthday_calendar_desc": "Mostrar um calendário virtual com os aniversários dos seus contatos" }, "days": { "monday": "Segunda-feira", @@ -2738,4 +2738,4 @@ "custom": "Personalizado" } } -} +} \ No newline at end of file From c0af2dbdd129d91c12bb0951b3b66278252ae240 Mon Sep 17 00:00:00 2001 From: Luis Felipe Marzagao Date: Sun, 26 Apr 2026 23:20:23 +0000 Subject: [PATCH 27/27] i18n: Fix word 'ficheiro' and its variants. Co-authored-by: Copilot --- locales/pt/common.json | 52 +++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/locales/pt/common.json b/locales/pt/common.json index a072b097..8ee68311 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -80,7 +80,7 @@ "calendar": "Calendário", "settings": "Configurações", "admin": "Admin", - "files": "Ficheiros", + "files": "Arquivos", "loading_mailboxes": "Carregando caixas de entrada...", "push_connected": "Atualizações em tempo real ativas", "push_disconnected": "Atualizações em tempo real inativas", @@ -505,7 +505,7 @@ "show_less": "Mostrar menos", "forgot_attachment": { "title": "Esqueceu um anexo?", - "message": "A sua mensagem menciona \"{keyword}\" mas nenhum ficheiro está anexado. Enviar mesmo assim?", + "message": "A sua mensagem menciona \"{keyword}\" mas nenhum arquivo está anexado. Enviar mesmo assim?", "send_anyway": "Enviar mesmo assim", "back": "Voltar à edição" } @@ -1325,9 +1325,9 @@ "title": "Contatos", "description": "Importar e exportar os seus contatos", "import_label": "Importar contatos", - "import_description": "Importar contatos de um ficheiro vCard (.vcf)", + "import_description": "Importar contatos de um arquivo vCard (.vcf)", "export_label": "Exportar contatos", - "export_description": "Exportar todos os contatos como ficheiro vCard (.vcf)", + "export_description": "Exportar todos os contatos como arquivo vCard (.vcf)", "manage_title": "Catálogos de endereços", "manage_description": "Renomeie seus catálogos de endereços", "no_address_books": "Nenhum catálogo de endereços encontrado", @@ -1503,7 +1503,7 @@ }, "folder_layout": { "label": "Navegação de pastas", - "description": "Escolha como as pastas são apresentadas: integradas com os ficheiros ou numa árvore na barra lateral", + "description": "Escolha como as pastas são apresentadas: integradas com os arquivos ou numa árvore na barra lateral", "inline": "Integrado", "sidebar": "Barra lateral" }, @@ -2481,12 +2481,12 @@ "hint": "Clique num e-mail à esquerda para começar, ou inicie o tour." }, "files": { - "title": "Ficheiros", - "search_placeholder": "Pesquisar ficheiros...", - "empty_state_title": "Ainda não há ficheiros", - "empty_state_description": "Carregue ficheiros ou crie pastas para começar", + "title": "Arquivos", + "search_placeholder": "Pesquisar arquivos...", + "empty_state_title": "Ainda não há arquivos", + "empty_state_description": "Carregue arquivos ou crie pastas para começar", "upload": "Carregar", - "upload_files": "Carregar ficheiros", + "upload_files": "Carregar arquivos", "new_folder": "Nova pasta", "new_folder_name": "Nome da pasta", "rename": "Renomear", @@ -2501,13 +2501,13 @@ "modified": "Modificado", "type": "Tipo", "folder": "Pasta", - "file": "Ficheiro", + "file": "Arquivo", "parent_directory": "Diretório superior", "breadcrumb_root": "Início", - "drop_files_here": "Largue ficheiros ou pastas aqui para carregar", + "drop_files_here": "Largue arquivos ou pastas aqui para carregar", "uploading": "A carregar...", - "upload_success": "{count, plural, one {1 ficheiro carregado} other {# ficheiros carregados}}", - "upload_error": "Falha ao carregar o ficheiro", + "upload_success": "{count, plural, one {1 arquivo carregado} other {# arquivos carregados}}", + "upload_error": "Falha ao carregar o arquivo", "create_folder_success": "Pasta criada", "create_folder_error": "Falha ao criar a pasta", "delete_success": "Eliminado com sucesso", @@ -2515,11 +2515,11 @@ "rename_success": "Renomeado com sucesso", "rename_error": "Falha ao renomear", "download_error": "Falha ao transferir", - "not_available": "O armazenamento de ficheiros não está disponível neste servidor", + "not_available": "O armazenamento de arquivos não está disponível neste servidor", "cancel": "Cancelar", "create": "Criar", "save": "Guardar", - "no_results": "Nenhum ficheiro corresponde à sua pesquisa", + "no_results": "Nenhum arquivo corresponde à sua pesquisa", "batch_delete_confirm_message": "Tem a certeza de que deseja eliminar {count, plural, one {1 item} other {# itens}}? Esta ação não pode ser desfeita.", "batch_delete_success": "{count, plural, one {1 item eliminado} other {# itens eliminados}}", "grid_view": "Vista em grelha", @@ -2535,27 +2535,27 @@ "move_error": "Falha ao mover", "paste_success": "Colado com sucesso", "paste_error": "Falha ao colar", - "new_text_file": "Novo ficheiro de texto", - "file_name": "Nome do ficheiro", + "new_text_file": "Novo arquivo de texto", + "file_name": "Nome do arquivo", "retry": "Tentar novamente", "refresh": "Atualizar", "toggle_favorite": "Alternar favorito", "duplicate": "Duplicar", "duplicate_success": "Duplicado com sucesso", "duplicate_error": "Falha ao duplicar", - "create_file_success": "Ficheiro criado", - "create_file_error": "Falha ao criar ficheiro", + "create_file_success": "Arquivo criado", + "create_file_error": "Falha ao criar arquivo", "favorites": "Favoritos", "recent": "Recentes", "properties": "Propriedades", "open_folder": "Abrir pasta", "upload_folder": "Carregar pasta", - "file_too_large": "\"{name}\" excede o tamanho máximo do ficheiro ({max})", + "file_too_large": "\"{name}\" excede o tamanho máximo do arquivo ({max})", "undo": "Desfazer", "undo_success": "Ação desfeita", "undo_error": "Falha ao desfazer", "toolbar": "Ações de arquivo", - "file_list": "Ficheiros e pastas", + "file_list": "Arquivos e pastas", "context_menu": "Ações", "settings_title": "Configurações de arquivos", "settings_display": "Exibição", @@ -2578,7 +2578,7 @@ "settings_show_hidden": "Mostrar arquivos ocultos", "settings_show_hidden_desc": "Exibir arquivos e pastas que começam com um ponto", "settings_folder_layout": "Navegação de pastas", - "settings_folder_layout_desc": "Escolha como as pastas são apresentadas: integradas com os ficheiros ou numa árvore na barra lateral", + "settings_folder_layout_desc": "Escolha como as pastas são apresentadas: integradas com os arquivos ou numa árvore na barra lateral", "settings_folder_layout_inline": "Integrado", "settings_folder_layout_sidebar": "Barra lateral", "disabled_title": "O recurso de arquivos foi desativado pelo seu administrador", @@ -2703,11 +2703,11 @@ "event_modal_desc": "Aqui está o formulário do evento. Preencha o título, escolha uma data e hora, adicione um local ou participantes. Clique em salvar quando terminar - ou feche e siga em frente.", "contacts_list_title": "Seus contatos", "contacts_list_desc": "Aqui estão seus contatos. Clique em qualquer contato para ver seus detalhes à direita. Você também pode criar novos contatos, importar vCards ou organizar contatos em grupos.", - "files_title": "Armazenamento de ficheiros", + "files_title": "Armazenamento de arquivos", "settings_tabs_title": "Menu de configurações", "settings_tabs_desc": "Aqui estão todas as categorias de configurações. Personalize a aparência, gerencie identidades, configure filtros de e-mail, ajuste o calendário e muito mais.", - "files_desc": "O navegador de ficheiros permite carregar, organizar e partilhar ficheiros - como uma nuvem pessoal integrada no seu e-mail.", - "demo_banner_title": "Controlos de demonstração", + "files_desc": "O navegador de arquivos permite carregar, organizar e compartilhar arquivos - como uma nuvem pessoal integrada no seu e-mail.", + "demo_banner_title": "Controles de demonstração", "demo_banner_desc": "Está no modo de demonstração - tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.", "quota_title": "Utilização do armazenamento", "quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."