Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0913dbd3e4 | ||
|
|
29197ea355 | ||
|
|
4788e8a91a | ||
|
|
b80678b00f | ||
|
|
4f7c9c332b | ||
|
|
da103ff06f | ||
|
|
2111c77870 | ||
|
|
e5083ec1df | ||
|
|
df8d04e233 | ||
|
|
9a11a18a44 | ||
|
|
ce9f7af330 | ||
|
|
081e8a0310 | ||
|
|
6c3529b368 | ||
|
|
077a4f03a7 | ||
|
|
b04dfaf252 |
@@ -1,5 +1,24 @@
|
|||||||
# Changelog
|
# 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)
|
## 1.5.0 (2026-04-22)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +62,7 @@ Bulwark is a full webmail suite – not just an inbox. It bundles the four apps
|
|||||||
- **Contacts** – multiple address books, groups, vCard import/export
|
- **Contacts** – multiple address books, groups, vCard import/export
|
||||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||||
|
|
||||||
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a Stalwart admin dashboard.
|
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
|
||||||
|
|
||||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||||
|
|
||||||
|
|||||||
+217
-11
@@ -14,6 +14,7 @@ import { useAccountStore } from "@/stores/account-store";
|
|||||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
@@ -23,6 +24,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
|||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
|
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
|
||||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
ComposerErrorFallback,
|
ComposerErrorFallback,
|
||||||
} from "@/components/error";
|
} from "@/components/error";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||||
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||||
@@ -67,6 +70,7 @@ export default function Home() {
|
|||||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
|
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
@@ -162,6 +166,11 @@ export default function Home() {
|
|||||||
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||||
refreshUnifiedCounts,
|
refreshUnifiedCounts,
|
||||||
exitUnifiedView,
|
exitUnifiedView,
|
||||||
|
emptyMailbox,
|
||||||
|
markMailboxAsRead,
|
||||||
|
createMailbox,
|
||||||
|
renameMailbox,
|
||||||
|
deleteMailbox,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||||
@@ -1097,6 +1106,197 @@ 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 = 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, 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 = 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);
|
||||||
|
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 = 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);
|
||||||
|
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 handleLogout = logout;
|
||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
@@ -1295,15 +1495,11 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle back navigation from viewer on mobile.
|
// Handle back navigation from viewer on mobile.
|
||||||
// Delegate to the browser history stack so this button is equivalent to
|
// Reset to list state directly. We can't just call window.history.back()
|
||||||
// the OS back button / mouse back button - popstate then restores the
|
// because the nav hook pushes a new entry for every email the user opens,
|
||||||
// previous snapshot via handleNavRestore. The viewer is only reachable
|
// so history.back() would pop to the previous email rather than the list.
|
||||||
// from a state that pushed history, so back() always lands on an app entry.
|
// The OS / hardware back button is still wired through popstate → handleNavRestore.
|
||||||
const handleMobileBack = () => {
|
const handleMobileBack = () => {
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.history.back();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (conversationThread) {
|
if (conversationThread) {
|
||||||
setConversationThread(null);
|
setConversationThread(null);
|
||||||
setConversationEmails([]);
|
setConversationEmails([]);
|
||||||
@@ -1458,6 +1654,15 @@ export default function Home() {
|
|||||||
onMailboxSelect={handleMailboxSelect}
|
onMailboxSelect={handleMailboxSelect}
|
||||||
onTagSelect={handleTagSelect}
|
onTagSelect={handleTagSelect}
|
||||||
onUnreadFilterClick={handleUnreadFilterClick}
|
onUnreadFilterClick={handleUnreadFilterClick}
|
||||||
|
onMarkFolderRead={handleMarkFolderRead}
|
||||||
|
onMarkFolderTreeRead={handleMarkFolderTreeRead}
|
||||||
|
onMarkAllFoldersRead={handleMarkAllFoldersRead}
|
||||||
|
onEmptyFolder={handleEmptyFolderFromContextMenu}
|
||||||
|
onCreateSubfolder={handleCreateSubfolderFromContextMenu}
|
||||||
|
onCreateFolder={handleCreateFolderFromContextMenu}
|
||||||
|
onRenameFolder={handleRenameFolderFromContextMenu}
|
||||||
|
onDeleteFolder={handleDeleteFolderFromContextMenu}
|
||||||
|
onRefreshMailboxes={handleRefreshMailboxes}
|
||||||
onCompose={() => {
|
onCompose={() => {
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -1934,12 +2139,12 @@ export default function Home() {
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
onDelete={handleDelete}
|
onDelete={() => handleDelete()}
|
||||||
onArchive={() => handleArchive()}
|
onArchive={() => handleArchive()}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onSetColorTag={handleSetColorTag}
|
onSetColorTag={handleSetColorTag}
|
||||||
onMarkAsSpam={handleMarkAsSpam}
|
onMarkAsSpam={() => handleMarkAsSpam()}
|
||||||
onUndoSpam={handleUndoSpam}
|
onUndoSpam={() => handleUndoSpam()}
|
||||||
onMarkAsRead={async (emailId, read) => {
|
onMarkAsRead={async (emailId, read) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
await markAsRead(client, emailId, read);
|
await markAsRead(client, emailId, read);
|
||||||
@@ -2009,6 +2214,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
<PromptDialog {...promptDialogProps} />
|
||||||
<TotpReauthDialog />
|
<TotpReauthDialog />
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
|
|||||||
+166
-1
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
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';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
@@ -69,6 +69,58 @@ 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);
|
||||||
|
|
||||||
|
function openSetupDialog() {
|
||||||
|
if (typeof window === 'undefined') 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 {
|
||||||
|
const res = await apiFetch('/api/admin/oauth/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
origin: setupOrigin.trim().replace(/\/+$/, ''),
|
||||||
|
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
|
||||||
|
oauthOnly: setupOauthOnly,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
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)})` : '';
|
||||||
|
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 setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
|
||||||
|
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
|
||||||
|
|
||||||
const hasEdits = Object.keys(edits).length > 0;
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -100,6 +152,119 @@ export default function AdminAuthPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auto-setup */}
|
||||||
|
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openSetupDialog}
|
||||||
|
disabled={setupRunning}
|
||||||
|
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-setup dialog */}
|
||||||
|
{setupOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="oauth-setup-title"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
||||||
|
<div className="px-5 py-4 border-b border-border">
|
||||||
|
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Webmail origin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-origin"
|
||||||
|
type="url"
|
||||||
|
value={setupOrigin}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
||||||
|
</p>
|
||||||
|
{!setupOriginValid && setupOrigin.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Stalwart issuer URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-issuer"
|
||||||
|
type="url"
|
||||||
|
value={setupIssuer}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
||||||
|
</p>
|
||||||
|
{!setupIssuerValid && setupIssuer.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setupOauthOnly}
|
||||||
|
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-input"
|
||||||
|
disabled={setupRunning}
|
||||||
|
/>
|
||||||
|
Also enable “OAuth only” (hide password login)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setSetupOpen(false)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoSetup}
|
||||||
|
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{setupRunning ? 'Configuring…' : 'Configure'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* OAuth */}
|
{/* OAuth */}
|
||||||
<Section title="OAuth / OpenID Connect">
|
<Section title="OAuth / OpenID Connect">
|
||||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
|||||||
+57
-48
@@ -65,6 +65,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
@@ -73,54 +74,59 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname !== '/admin/login') {
|
if (pathname === '/admin/login') return;
|
||||||
checkAuth();
|
let cancelled = false;
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
function getJmapHeaders(): Record<string, string> {
|
async function checkAuth() {
|
||||||
return getActiveAccountSlotHeaders();
|
try {
|
||||||
}
|
const jmapHeaders = getActiveAccountSlotHeaders();
|
||||||
|
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||||
|
const data = await res.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
async function checkAuth() {
|
const stalwartAdmin = data.stalwartAdmin === true;
|
||||||
try {
|
setIsStalwartAdmin(stalwartAdmin);
|
||||||
const jmapHeaders = getJmapHeaders();
|
|
||||||
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
const stalwartAdmin = data.stalwartAdmin === true;
|
// If neither password-based admin nor Stalwart admin, redirect away
|
||||||
setIsStalwartAdmin(stalwartAdmin);
|
if (!data.enabled && !stalwartAdmin) {
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If neither password-based admin nor Stalwart admin, redirect away
|
if (data.authenticated) {
|
||||||
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) {
|
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
router.replace('/admin/login');
|
// If Stalwart admin but not yet authenticated, auto-login
|
||||||
} catch {
|
if (stalwartAdmin) {
|
||||||
router.replace('/admin/login');
|
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() {
|
async function handleLogout() {
|
||||||
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||||
@@ -132,14 +138,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authenticated === null) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
||||||
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Slim webmail nav rail */}
|
{/* Slim webmail nav rail */}
|
||||||
@@ -269,7 +267,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
{children}
|
{authError ? (
|
||||||
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
<p className="font-medium">Admin authentication failed</p>
|
||||||
|
<p className="mt-1 text-destructive/80">{authError}</p>
|
||||||
|
</div>
|
||||||
|
) : authenticated === null ? (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
|
||||||
|
Loading admin panel…
|
||||||
|
</div>
|
||||||
|
) : authenticated ? (
|
||||||
|
children
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,253 @@
|
|||||||
|
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, unknown>, string]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JmapMethodResponse {
|
||||||
|
methodResponses?: Array<[string, Record<string, unknown>, string]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
|
||||||
|
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<JmapMethodResponse> {
|
||||||
|
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<JmapMethodResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStalwartAccountId(
|
||||||
|
serverUrl: string,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
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<string, string> };
|
||||||
|
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<string, true> {
|
||||||
|
const out: Record<string, true> = {};
|
||||||
|
for (const loc of localeList) {
|
||||||
|
out[`${origin}/${loc}/auth/callback`] = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
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 (!isValidOriginUrl(origin)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
notUpdated?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
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<string, unknown> = {
|
||||||
|
oauthEnabled: true,
|
||||||
|
oauthClientId: CLIENT_ID,
|
||||||
|
oauthClientSecret: secret,
|
||||||
|
oauthIssuerUrl: issuerUrl,
|
||||||
|
};
|
||||||
|
if (oauthOnly) updates.oauthOnly = true;
|
||||||
|
await configManager.setAdminConfig(updates);
|
||||||
|
|
||||||
|
await auditLog('admin.oauth_setup', {
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuer: issuerUrl,
|
||||||
|
redirectUriCount: localeList.length,
|
||||||
|
oauthOnly,
|
||||||
|
}, ip);
|
||||||
|
|
||||||
|
logger.info('Admin OAuth setup', {
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuer: issuerUrl,
|
||||||
|
locales: localeList.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuerUrl,
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
clearStalwartAuthContextInStore,
|
clearStalwartAuthContextInStore,
|
||||||
setStalwartAuthContextInStore,
|
setStalwartAuthContextInStore,
|
||||||
} from '@/lib/stalwart/auth-context';
|
} from '@/lib/stalwart/auth-context';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -25,7 +26,9 @@ function getSlot(request: NextRequest): number {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||||
|
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
|
||||||
|
if (oauthEnabled && oauthOnly) {
|
||||||
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
|
|||||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
@@ -113,8 +114,8 @@ async function attemptAllStrategies(
|
|||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
const clientSecret = configManager.get<string>('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ export function CalendarToolbar({
|
|||||||
{/* ── DESKTOP TOOLBAR ── */}
|
{/* ── DESKTOP TOOLBAR ── */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
|
||||||
|
{t("views.today")}
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -279,14 +282,14 @@ export function CalendarToolbar({
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex border border-border rounded-md overflow-hidden">
|
<div className="flex h-8 border border-border rounded-md overflow-hidden">
|
||||||
{views.map((v) => (
|
{views.map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => onViewModeChange(v)}
|
onClick={() => onViewModeChange(v)}
|
||||||
title={t(`views.${v}_hint`)}
|
title={t(`views.${v}_hint`)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
"inline-flex items-center px-3 text-xs font-medium transition-colors",
|
||||||
v === viewMode
|
v === viewMode
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "hover:bg-muted text-muted-foreground"
|
: "hover:bg-muted text-muted-foreground"
|
||||||
@@ -300,7 +303,7 @@ export function CalendarToolbar({
|
|||||||
|
|
||||||
{(onImport || onSubscribe) && !isMobile && (
|
{(onImport || onSubscribe) && !isMobile && (
|
||||||
<div className="relative" ref={importDropdownRef}>
|
<div className="relative" ref={importDropdownRef}>
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
|
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
|
||||||
<Upload className="w-4 h-4 mr-1" />
|
<Upload className="w-4 h-4 mr-1" />
|
||||||
{t("import.title")}
|
{t("import.title")}
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
@@ -331,7 +334,7 @@ export function CalendarToolbar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
|
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
{t("events.create")}
|
{t("events.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
@@ -49,6 +50,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
// 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}
|
email={sender?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+153
-121
@@ -3436,70 +3436,172 @@ export function EmailViewer({
|
|||||||
moreMenuOpen ? "translate-x-0" : "translate-x-full"
|
moreMenuOpen ? "translate-x-0" : "translate-x-full"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
|
{moreMenuSub ? (
|
||||||
<Button variant="ghost" size="icon" onClick={() => setMoreMenuOpen(false)} className="h-9 w-9">
|
<button
|
||||||
|
onClick={() => setMoreMenuSub(null)}
|
||||||
|
className="flex items-center gap-1 -ml-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
{moreMenuSub === 'move' ? t('move_to') : t('tag')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => { setMoreMenuOpen(false); setMoreMenuSub(null); }} className="h-9 w-9">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
<button
|
{moreMenuSub === null && (
|
||||||
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Archive className="w-5 h-5" />
|
|
||||||
{t('archive')}
|
|
||||||
</button>
|
|
||||||
{/* Move to folder */}
|
|
||||||
{moveTree.length > 0 && onMoveToMailbox && (
|
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Archive className="w-5 h-5" />
|
||||||
|
{t('archive')}
|
||||||
|
</button>
|
||||||
|
{/* Move to folder (opens sub-view) */}
|
||||||
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMoreMenuSub('move')}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<FolderInput className="w-5 h-5" />
|
||||||
|
<span className="flex-1">{t('move_to')}</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Tag (opens sub-view) */}
|
||||||
|
{colorOptions.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMoreMenuSub('tag')}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Tag className="w-5 h-5" />
|
||||||
|
<span className="flex-1">{t('tag')}</span>
|
||||||
|
{currentColors.length > 0 && (
|
||||||
|
<div className="flex -space-x-1 mr-1">
|
||||||
|
{currentColors.slice(0, 3).map((c) => {
|
||||||
|
const opt = colorOptions.find((o) => o.value === c);
|
||||||
|
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Spam */}
|
||||||
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isInJunkFolder ? (
|
||||||
|
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Toggle read state */}
|
||||||
|
<button
|
||||||
|
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
|
||||||
|
{isUnread ? t('mark_read') : t('mark_unread')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Printer className="w-5 h-5" />
|
||||||
|
{t('print')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Code className="w-5 h-5" />
|
||||||
|
{t('view_source')}
|
||||||
|
</button>
|
||||||
|
{effectiveEmailContent.isHtml && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
{isDark ? 'View in light mode' : 'View in dark mode'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
|
<button
|
||||||
{(() => {
|
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
||||||
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
return nodes.map((node) => {
|
>
|
||||||
const Icon = getMoveMailboxIcon(node.role);
|
<Download className="w-5 h-5" />
|
||||||
const isTarget = moveTargetIds.has(node.id);
|
{t('export_email')}
|
||||||
return (
|
</button>
|
||||||
<div key={node.id}>
|
<button
|
||||||
{isTarget ? (
|
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
|
||||||
<button
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); }}
|
>
|
||||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
|
<Upload className="w-5 h-5" />
|
||||||
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
{t('import_email')}
|
||||||
>
|
</button>
|
||||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
{onShowShortcuts && (
|
||||||
<span className="truncate">{node.name}</span>
|
<button
|
||||||
</button>
|
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
|
||||||
) : (
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
<div
|
>
|
||||||
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
|
<Keyboard className="w-5 h-5" />
|
||||||
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
{t('keyboard_shortcuts')}
|
||||||
>
|
</button>
|
||||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
)}
|
||||||
<span>{node.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return renderMobileNodes(moveTree);
|
|
||||||
})()}
|
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Tags */}
|
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
|
||||||
{colorOptions.length > 0 && (
|
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const Icon = getMoveMailboxIcon(node.role);
|
||||||
|
const isTarget = moveTargetIds.has(node.id);
|
||||||
|
return (
|
||||||
|
<div key={node.id}>
|
||||||
|
{isTarget ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
|
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
|
||||||
|
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
|
||||||
|
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{node.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return renderMobileNodes(moveTree);
|
||||||
|
})()}
|
||||||
|
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
|
||||||
{colorOptions.map((option) => {
|
{colorOptions.map((option) => {
|
||||||
const isActive = currentColors.includes(option.value);
|
const isActive = currentColors.includes(option.value);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
||||||
isActive && "bg-accent font-medium"
|
isActive && "bg-accent font-medium"
|
||||||
@@ -3513,85 +3615,15 @@ export function EmailViewer({
|
|||||||
})}
|
})}
|
||||||
{currentColors.length > 0 && (
|
{currentColors.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 flex-shrink-0" />
|
<X className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>{t('remove_color')}</span>
|
<span>{t('remove_color')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Spam */}
|
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
|
||||||
<button
|
|
||||||
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isInJunkFolder ? (
|
|
||||||
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
|
||||||
) : (
|
|
||||||
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
|
|
||||||
)}
|
|
||||||
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Toggle read state */}
|
|
||||||
<button
|
|
||||||
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
|
|
||||||
{isUnread ? t('mark_read') : t('mark_unread')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Printer className="w-5 h-5" />
|
|
||||||
{t('print')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Code className="w-5 h-5" />
|
|
||||||
{t('view_source')}
|
|
||||||
</button>
|
|
||||||
{effectiveEmailContent.isHtml && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
|
||||||
{isDark ? 'View in light mode' : 'View in dark mode'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
<button
|
|
||||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
{t('export_email')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Upload className="w-5 h-5" />
|
|
||||||
{t('import_email')}
|
|
||||||
</button>
|
|
||||||
{onShowShortcuts && (
|
|
||||||
<button
|
|
||||||
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Keyboard className="w-5 h-5" />
|
|
||||||
{t('keyboard_shortcuts')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||||
@@ -182,6 +184,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -359,6 +362,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
@@ -376,6 +380,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)).slice(0, 4)
|
)).slice(0, 4)
|
||||||
: participantNames;
|
: participantNames;
|
||||||
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
|
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
|
||||||
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
|
|
||||||
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
||||||
email: latestEmail,
|
email: latestEmail,
|
||||||
@@ -563,6 +568,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
email={avatarPerson?.email}
|
email={avatarPerson?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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<HTMLDivElement | null>;
|
||||||
|
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 (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={CheckCheck}
|
||||||
|
label={t("mark_all_folders_read")}
|
||||||
|
onClick={() => handleAction(onMarkAllFoldersRead!)}
|
||||||
|
disabled={!onMarkAllFoldersRead}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={FolderPlus}
|
||||||
|
label={t("new_folder")}
|
||||||
|
onClick={() => handleAction(onCreateFolder!)}
|
||||||
|
disabled={!onCreateFolder}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={RefreshCw}
|
||||||
|
label={t("refresh")}
|
||||||
|
onClick={() => handleAction(onRefresh!)}
|
||||||
|
disabled={!onRefresh}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuHeader>{mailbox.name}</ContextMenuHeader>
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={MailOpen}
|
||||||
|
label={t("mark_folder_read")}
|
||||||
|
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
|
||||||
|
disabled={!onMarkFolderRead || !canSetSeen}
|
||||||
|
/>
|
||||||
|
{target.hasChildren && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Mails}
|
||||||
|
label={t("mark_folder_tree_read")}
|
||||||
|
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
|
||||||
|
disabled={!onMarkFolderTreeRead || !canSetSeen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={FolderPlus}
|
||||||
|
label={t("new_subfolder")}
|
||||||
|
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
|
||||||
|
disabled={!onCreateSubfolder || !canCreateChild}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("rename")}
|
||||||
|
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
||||||
|
disabled={!onRenameFolder || !canRename}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={FolderX}
|
||||||
|
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
|
||||||
|
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
|
||||||
|
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("delete_folder")}
|
||||||
|
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
|
||||||
|
disabled={!onDeleteFolder || !canDelete}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={RefreshCw}
|
||||||
|
label={t("refresh")}
|
||||||
|
onClick={() => handleAction(onRefresh!)}
|
||||||
|
disabled={!onRefresh}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
|||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
||||||
import NextLink from "next/link";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useEmailStore } from "@/stores/email-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 && (
|
{isStalwartAdmin && (
|
||||||
<NextLink
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||||
@@ -348,7 +347,7 @@ export function NavigationRail({
|
|||||||
>
|
>
|
||||||
<Shield className="w-5 h-5" />
|
<Shield className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
||||||
</NextLink>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
@@ -514,13 +513,13 @@ export function NavigationRail({
|
|||||||
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
|
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
|
||||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||||
{isStalwartAdmin && (
|
{isStalwartAdmin && (
|
||||||
<NextLink
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
title={t("admin") || "Admin"}
|
title={t("admin") || "Admin"}
|
||||||
>
|
>
|
||||||
<Shield className="w-[18px] h-[18px]" />
|
<Shield className="w-[18px] h-[18px]" />
|
||||||
</NextLink>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
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 { useAccountStore } from '@/stores/account-store';
|
||||||
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
|
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
|
||||||
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
|
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
|
||||||
@@ -56,6 +58,15 @@ interface SidebarProps {
|
|||||||
onCompose?: () => void;
|
onCompose?: () => void;
|
||||||
onSidebarClose?: () => void;
|
onSidebarClose?: () => void;
|
||||||
onUnreadFilterClick?: (mailboxId: string) => 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;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +198,7 @@ interface SidebarRowProps {
|
|||||||
dropHandlers?: Record<string, unknown>;
|
dropHandlers?: Record<string, unknown>;
|
||||||
isValidDropTarget?: boolean;
|
isValidDropTarget?: boolean;
|
||||||
isInvalidDropTarget?: boolean;
|
isInvalidDropTarget?: boolean;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarRow({
|
function SidebarRow({
|
||||||
@@ -206,6 +218,7 @@ function SidebarRow({
|
|||||||
dropHandlers,
|
dropHandlers,
|
||||||
isValidDropTarget,
|
isValidDropTarget,
|
||||||
isInvalidDropTarget,
|
isInvalidDropTarget,
|
||||||
|
onContextMenu,
|
||||||
}: SidebarRowProps) {
|
}: SidebarRowProps) {
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
||||||
@@ -213,6 +226,7 @@ function SidebarRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...(dropHandlers || {})}
|
{...(dropHandlers || {})}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
|
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
|
||||||
@@ -365,6 +379,7 @@ function MailboxTreeItem({
|
|||||||
isCollapsed,
|
isCollapsed,
|
||||||
onUnreadFilterClick,
|
onUnreadFilterClick,
|
||||||
colorful,
|
colorful,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
node: MailboxNode;
|
node: MailboxNode;
|
||||||
selectedMailbox: string;
|
selectedMailbox: string;
|
||||||
@@ -374,6 +389,7 @@ function MailboxTreeItem({
|
|||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onUnreadFilterClick?: (mailboxId: string) => void;
|
onUnreadFilterClick?: (mailboxId: string) => void;
|
||||||
colorful: boolean;
|
colorful: boolean;
|
||||||
|
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
|
||||||
}) {
|
}) {
|
||||||
const tNotifications = useTranslations('notifications');
|
const tNotifications = useTranslations('notifications');
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = node.children.length > 0;
|
||||||
@@ -423,6 +439,7 @@ function MailboxTreeItem({
|
|||||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||||
isValidDropTarget={isValidDropTarget}
|
isValidDropTarget={isValidDropTarget}
|
||||||
isInvalidDropTarget={isInvalidDropTarget}
|
isInvalidDropTarget={isInvalidDropTarget}
|
||||||
|
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
||||||
@@ -436,6 +453,7 @@ function MailboxTreeItem({
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
colorful={colorful}
|
colorful={colorful}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -610,6 +628,15 @@ export function Sidebar({
|
|||||||
onCompose: _onCompose,
|
onCompose: _onCompose,
|
||||||
onSidebarClose,
|
onSidebarClose,
|
||||||
onUnreadFilterClick,
|
onUnreadFilterClick,
|
||||||
|
onMarkFolderRead,
|
||||||
|
onMarkFolderTreeRead,
|
||||||
|
onMarkAllFoldersRead,
|
||||||
|
onEmptyFolder,
|
||||||
|
onCreateSubfolder,
|
||||||
|
onCreateFolder,
|
||||||
|
onRenameFolder,
|
||||||
|
onDeleteFolder,
|
||||||
|
onRefreshMailboxes,
|
||||||
className,
|
className,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -790,6 +817,23 @@ export function Sidebar({
|
|||||||
router.push('/settings');
|
router.push('/settings');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
contextMenu: mailboxContextMenu,
|
||||||
|
openContextMenu: openMailboxContextMenu,
|
||||||
|
closeContextMenu: closeMailboxContextMenu,
|
||||||
|
menuRef: mailboxMenuRef,
|
||||||
|
} = useContextMenu<MailboxContextTarget>();
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -866,7 +910,7 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div onContextMenu={handleFoldersHeaderContextMenu}>
|
||||||
<SidebarSectionHeader
|
<SidebarSectionHeader
|
||||||
label={t("folders")}
|
label={t("folders")}
|
||||||
expanded={foldersExpanded}
|
expanded={foldersExpanded}
|
||||||
@@ -894,6 +938,7 @@ export function Sidebar({
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
colorful={colorfulSidebarIcons}
|
colorful={colorfulSidebarIcons}
|
||||||
|
onContextMenu={handleMailboxContextMenu}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -934,6 +979,7 @@ export function Sidebar({
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
colorful={colorfulSidebarIcons}
|
colorful={colorfulSidebarIcons}
|
||||||
|
onContextMenu={handleMailboxContextMenu}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -975,6 +1021,23 @@ export function Sidebar({
|
|||||||
|
|
||||||
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MailboxContextMenu
|
||||||
|
target={mailboxContextMenu.data}
|
||||||
|
position={mailboxContextMenu.position}
|
||||||
|
isOpen={mailboxContextMenu.isOpen}
|
||||||
|
onClose={closeMailboxContextMenu}
|
||||||
|
menuRef={mailboxMenuRef}
|
||||||
|
onMarkFolderRead={onMarkFolderRead}
|
||||||
|
onMarkFolderTreeRead={onMarkFolderTreeRead}
|
||||||
|
onMarkAllFoldersRead={onMarkAllFoldersRead}
|
||||||
|
onEmptyFolder={onEmptyFolder}
|
||||||
|
onCreateSubfolder={onCreateSubfolder}
|
||||||
|
onCreateFolder={onCreateFolder}
|
||||||
|
onRenameFolder={onRenameFolder}
|
||||||
|
onDeleteFolder={onDeleteFolder}
|
||||||
|
onRefresh={onRefreshMailboxes}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
|||||||
const tAdvanced = useTranslations('settings.advanced');
|
const tAdvanced = useTranslations('settings.advanced');
|
||||||
const tTour = useTranslations('tour');
|
const tTour = useTranslations('tour');
|
||||||
const { theme, setTheme } = useThemeStore();
|
const { theme, setTheme } = useThemeStore();
|
||||||
const { fontSize, density, animationsEnabled, senderFavicons, updateSetting } = useSettingsStore();
|
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
|
||||||
const { startTour, resetTourCompletion } = useTour();
|
const { startTour, resetTourCompletion } = useTour();
|
||||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||||
|
|
||||||
@@ -130,6 +130,10 @@ export function AppearanceSettings() {
|
|||||||
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
|
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem label={tAdvanced('show_avatars_in_junk.label')} description={tAdvanced('show_avatars_in_junk.description')}>
|
||||||
|
<ToggleSwitch checked={showAvatarsInJunk} onChange={(checked) => updateSetting('showAvatarsInJunk', checked)} />
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -140,9 +140,11 @@ interface AvatarProps {
|
|||||||
contactPhotoUri?: string;
|
contactPhotoUri?: string;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
|
||||||
|
disableImages?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
|
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) {
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
||||||
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
||||||
@@ -222,9 +224,11 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
|
|||||||
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
|
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
|
||||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||||
const imgSrc = !imgError && !domainFailed
|
const imgSrc = disableImages
|
||||||
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
? null
|
||||||
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
|
: !imgError && !domainFailed
|
||||||
|
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
||||||
|
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
|
||||||
|
|
||||||
const handleImgError = useCallback(() => {
|
const handleImgError = useCallback(() => {
|
||||||
// If the plugin avatar just failed, mark it and fall through to the next source
|
// If the plugin avatar just failed, mark it and fall through to the next source
|
||||||
|
|||||||
@@ -45,9 +45,22 @@ export function FlagKR(props: FlagProps) {
|
|||||||
return (
|
return (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||||
<rect width="3" height="2" fill="#fff" />
|
<rect width="3" height="2" fill="#fff" />
|
||||||
<circle cx="1.5" cy="1" r="0.55" fill="#CD2E3A" />
|
<path d="M1.5 0.5 a0.45 0.45 0 1 1 0 0.9 a0.45 0.45 0 1 0 0 -0.9" fill="#CD2E3A" />
|
||||||
<path d="M1.5,1 a0.275,0.275 0 0,1 0,0.55 a0.275,0.275 0 0,0 0,-0.55" fill="#0047A0" />
|
<path d="M1.5 1.5 a0.45 0.45 0 1 1 0 -0.9 a0.45 0.45 0 1 0 0 0.9" fill="#0047A0" />
|
||||||
<path d="M1.5,1 a0.275,0.275 0 0,0 0,-0.55 a0.275,0.275 0 0,1 0,0.55" fill="#0047A0" />
|
<circle cx="1.5" cy="0.8" r="0.225" fill="#0047A0" />
|
||||||
|
<circle cx="1.5" cy="1.2" r="0.225" fill="#CD2E3A" />
|
||||||
|
<g stroke="#000" strokeWidth="0.06" strokeLinecap="round">
|
||||||
|
<line x1="0.42" y1="0.35" x2="0.78" y2="0.35" />
|
||||||
|
<line x1="0.42" y1="0.46" x2="0.78" y2="0.46" />
|
||||||
|
<line x1="0.42" y1="0.57" x2="0.78" y2="0.57" />
|
||||||
|
<line x1="2.22" y1="0.35" x2="2.58" y2="0.35" />
|
||||||
|
<line x1="2.22" y1="0.57" x2="2.58" y2="0.57" />
|
||||||
|
<line x1="0.42" y1="1.43" x2="0.78" y2="1.43" />
|
||||||
|
<line x1="0.42" y1="1.65" x2="0.78" y2="1.65" />
|
||||||
|
<line x1="2.22" y1="1.43" x2="2.58" y2="1.43" />
|
||||||
|
<line x1="2.22" y1="1.54" x2="2.58" y2="1.54" />
|
||||||
|
<line x1="2.22" y1="1.65" x2="2.58" y2="1.65" />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<HTMLInputElement>(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 (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={`${id}-title`}
|
||||||
|
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="p-6">
|
||||||
|
<h2
|
||||||
|
id={`${id}-title`}
|
||||||
|
className="text-lg font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{message && (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="mt-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
{resolvedCancelText}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="default" disabled={!canSubmit}>
|
||||||
|
{resolvedConfirmText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ interface UseMailboxDropReturn {
|
|||||||
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
|
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
|
||||||
const [isOver, setIsOver] = useState(false);
|
const [isOver, setIsOver] = useState(false);
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, mailboxes } = useEmailStore();
|
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
|
||||||
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
|
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
|
||||||
|
|
||||||
// Determine if this is a valid drop target
|
// Determine if this is a valid drop target
|
||||||
@@ -115,8 +115,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the current mailbox view
|
// Refresh the current mailbox view (honors active search/filters)
|
||||||
await fetchEmails(client, selectedMailbox);
|
await refreshCurrentMailbox(client);
|
||||||
|
|
||||||
const mailboxPath = getMailboxPath(mailbox, mailboxes);
|
const mailboxPath = getMailboxPath(mailbox, mailboxes);
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
|||||||
} finally {
|
} finally {
|
||||||
endDrag();
|
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();
|
const valid = isValidTarget();
|
||||||
|
|
||||||
|
|||||||
@@ -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<PromptDialogState>(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<string | null> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -312,6 +312,33 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async markMailboxAsRead(mailboxId: string): Promise<number> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
async markAsSpam(emailId: string): Promise<void> {
|
||||||
const email = this.data.emails.find(e => e.id === emailId);
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
|
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export interface IJMAPClient {
|
|||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||||
emptyMailbox(mailboxId: string): Promise<number>;
|
emptyMailbox(mailboxId: string): Promise<number>;
|
||||||
|
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
|
||||||
|
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
|
||||||
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
||||||
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
|
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -1361,6 +1361,99 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return totalDestroyed;
|
return totalDestroyed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number> {
|
||||||
|
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<number> {
|
||||||
|
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<string, boolean> }> = 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<void> {
|
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,23 @@ import { logger } from '@/lib/logger';
|
|||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
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<string>('oauthClientSecret', '');
|
||||||
|
if (adminSecret) return adminSecret;
|
||||||
|
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
||||||
|
}
|
||||||
|
|
||||||
export function getRequiredConfig() {
|
export function getRequiredConfig() {
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
const serverUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||||
const issuerUrl = process.env.OAUTH_ISSUER_URL;
|
const issuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
|
||||||
if (!clientId || !serverUrl) {
|
if (!clientId || !serverUrl) {
|
||||||
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
||||||
}
|
}
|
||||||
const discoveryUrl = issuerUrl?.trim() || serverUrl;
|
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');
|
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
||||||
}
|
}
|
||||||
return { clientId, serverUrl, discoveryUrl };
|
return { clientId, serverUrl, discoveryUrl };
|
||||||
@@ -36,8 +41,9 @@ export async function getMetadata(): Promise<OAuthMetadata | null> {
|
|||||||
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
||||||
const { clientId } = getRequiredConfig();
|
const { clientId } = getRequiredConfig();
|
||||||
const params = new URLSearchParams({ ...base, client_id: clientId });
|
const params = new URLSearchParams({ ...base, client_id: clientId });
|
||||||
if (CLIENT_SECRET) {
|
const secret = getClientSecret();
|
||||||
params.set('client_secret', CLIENT_SECRET);
|
if (secret) {
|
||||||
|
params.set('client_secret', secret);
|
||||||
}
|
}
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "Absender-Favicons (Experimentell)",
|
"label": "Absender-Favicons (Experimentell)",
|
||||||
"description": "Website-Symbole als Profilbilder für geschäftliche Absender anzeigen"
|
"description": "Website-Symbole als Profilbilder für geschäftliche Absender anzeigen"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "Avatare im Spam-Ordner anzeigen",
|
||||||
|
"description": "Profilbilder und Favicons für Absender im Spam-Ordner anzeigen. Standardmäßig deaktiviert, damit Phishing-Versuche nicht durch vertraute Logos legitim wirken."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Tastaturkürzel",
|
"label": "Tastaturkürzel",
|
||||||
"description": "Verfügbare Tastaturkürzel anzeigen",
|
"description": "Verfügbare Tastaturkürzel anzeigen",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "{count} E-Mails ausgewählt",
|
"items_selected": "{count} E-Mails ausgewählt",
|
||||||
"edit_draft": "Entwurf bearbeiten"
|
"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": "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",
|
||||||
|
"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": {
|
"shortcuts": {
|
||||||
"title": "Tastaturkürzel",
|
"title": "Tastaturkürzel",
|
||||||
"tip": "Drücken Sie ? jederzeit, um diese Hilfe anzuzeigen",
|
"tip": "Drücken Sie ? jederzeit, um diese Hilfe anzuzeigen",
|
||||||
|
|||||||
@@ -1287,6 +1287,10 @@
|
|||||||
"label": "Sender Favicons (Experimental)",
|
"label": "Sender Favicons (Experimental)",
|
||||||
"description": "Show website icons as profile pictures for business senders"
|
"description": "Show website icons as profile pictures for business senders"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "Show Avatars in Junk Folder",
|
||||||
|
"description": "Show profile images and favicons for senders in the junk folder. Disabled by default to avoid lending visual legitimacy to phishing attempts."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Keyboard Shortcuts",
|
"label": "Keyboard Shortcuts",
|
||||||
"description": "View available keyboard shortcuts",
|
"description": "View available keyboard shortcuts",
|
||||||
@@ -1590,6 +1594,43 @@
|
|||||||
"items_selected": "{count} emails selected",
|
"items_selected": "{count} emails selected",
|
||||||
"edit_draft": "Edit Draft"
|
"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": "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",
|
||||||
|
"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": {
|
"shortcuts": {
|
||||||
"title": "Keyboard Shortcuts",
|
"title": "Keyboard Shortcuts",
|
||||||
"tip": "Press ? anytime to show this help",
|
"tip": "Press ? anytime to show this help",
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "Favicons de remitente (Experimental)",
|
"label": "Favicons de remitente (Experimental)",
|
||||||
"description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales"
|
"description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "Mostrar avatares en la carpeta de spam",
|
||||||
|
"description": "Mostrar imágenes de perfil y favicons de remitentes en la carpeta de spam. Desactivado por defecto para no dar apariencia legítima a los intentos de phishing."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Atajos de Teclado",
|
"label": "Atajos de Teclado",
|
||||||
"description": "Ver atajos de teclado disponibles",
|
"description": "Ver atajos de teclado disponibles",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "{count} correos seleccionados",
|
"items_selected": "{count} correos seleccionados",
|
||||||
"edit_draft": "Editar borrador"
|
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Atajos de Teclado",
|
"title": "Atajos de Teclado",
|
||||||
"tip": "Presione ? en cualquier momento para mostrar esta ayuda",
|
"tip": "Presione ? en cualquier momento para mostrar esta ayuda",
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "Favicons des expéditeurs (Expérimental)",
|
"label": "Favicons des expéditeurs (Expérimental)",
|
||||||
"description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels"
|
"description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "Afficher les avatars dans le dossier indésirable",
|
||||||
|
"description": "Afficher les photos de profil et favicons des expéditeurs dans le dossier indésirable. Désactivé par défaut pour éviter de donner une apparence légitime aux tentatives d'hameçonnage."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Raccourcis clavier",
|
"label": "Raccourcis clavier",
|
||||||
"description": "Voir les raccourcis clavier disponibles",
|
"description": "Voir les raccourcis clavier disponibles",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "{count} emails sélectionnés",
|
"items_selected": "{count} emails sélectionnés",
|
||||||
"edit_draft": "Modifier le brouillon"
|
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Raccourcis clavier",
|
"title": "Raccourcis clavier",
|
||||||
"tip": "Appuyez sur ? à tout moment pour afficher cette aide",
|
"tip": "Appuyez sur ? à tout moment pour afficher cette aide",
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "Favicon dei mittenti (Sperimentale)",
|
"label": "Favicon dei mittenti (Sperimentale)",
|
||||||
"description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali"
|
"description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "Mostra avatar nella cartella spam",
|
||||||
|
"description": "Mostra immagini profilo e favicon dei mittenti nella cartella spam. Disattivato per impostazione predefinita per non dare apparenza legittima ai tentativi di phishing."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Scorciatoie da tastiera",
|
"label": "Scorciatoie da tastiera",
|
||||||
"description": "Visualizza le scorciatoie da tastiera disponibili",
|
"description": "Visualizza le scorciatoie da tastiera disponibili",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "{count} messaggi selezionati",
|
"items_selected": "{count} messaggi selezionati",
|
||||||
"edit_draft": "Modifica bozza"
|
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Scorciatoie da tastiera",
|
"title": "Scorciatoie da tastiera",
|
||||||
"tip": "Premi ? in qualsiasi momento per mostrare questo aiuto",
|
"tip": "Premi ? in qualsiasi momento per mostrare questo aiuto",
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "送信者ファビコン(実験的)",
|
"label": "送信者ファビコン(実験的)",
|
||||||
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
|
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "迷惑メールフォルダでアバターを表示",
|
||||||
|
"description": "迷惑メールフォルダの送信者にプロフィール画像とファビコンを表示します。フィッシング詐欺に正規のような見た目を与えないため、既定では無効です。"
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "キーボードショートカット",
|
"label": "キーボードショートカット",
|
||||||
"description": "利用可能なキーボードショートカットを表示",
|
"description": "利用可能なキーボードショートカットを表示",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "{count}件のメールを選択",
|
"items_selected": "{count}件のメールを選択",
|
||||||
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "キーボードショートカット",
|
"title": "キーボードショートカット",
|
||||||
"tip": "? キーを押すといつでもこのヘルプを表示できます",
|
"tip": "? キーを押すといつでもこのヘルプを表示できます",
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "보낸 사람 파비콘 (실험적 기능)",
|
"label": "보낸 사람 파비콘 (실험적 기능)",
|
||||||
"description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요"
|
"description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "스팸함에서 아바타 표시",
|
||||||
|
"description": "스팸함의 보낸 사람에 대한 프로필 이미지와 파비콘을 표시해요. 피싱 메일이 정상적인 메일처럼 보이지 않도록 기본적으로 꺼져 있어요."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "단축키",
|
"label": "단축키",
|
||||||
"description": "사용 가능한 키보드 단축키를 확인해 보세요",
|
"description": "사용 가능한 키보드 단축키를 확인해 보세요",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "{count}개의 메일 선택됨",
|
"items_selected": "{count}개의 메일 선택됨",
|
||||||
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "단축키",
|
"title": "단축키",
|
||||||
"tip": "언제든 ? 키를 누르면 이 도움말을 볼 수 있어요",
|
"tip": "언제든 ? 키를 누르면 이 도움말을 볼 수 있어요",
|
||||||
|
|||||||
@@ -1266,6 +1266,10 @@
|
|||||||
"label": "Sūtītāju ikonas (Eksperimentāli)",
|
"label": "Sūtītāju ikonas (Eksperimentāli)",
|
||||||
"description": "Rādīt vietņu ikonas kā sūtītāju avatarus"
|
"description": "Rādīt vietņu ikonas kā sūtītāju avatarus"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "Rādīt avatarus mēstuļu mapē",
|
||||||
|
"description": "Rādīt sūtītāju profila attēlus un ikonas mēstuļu mapē. Pēc noklusējuma izslēgts, lai pikšķerēšanas mēģinājumi neizskatītos uzticami."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Īsinājumtaustiņi",
|
"label": "Īsinājumtaustiņi",
|
||||||
"description": "Skatīt pieejamos tastatūras īsinājumtaustiņus",
|
"description": "Skatīt pieejamos tastatūras īsinājumtaustiņus",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "{count} vēstules atlasītas",
|
"items_selected": "{count} vēstules atlasītas",
|
||||||
"edit_draft": "Rediģēt melnrakstu"
|
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Īsinājumtaustiņi",
|
"title": "Īsinājumtaustiņi",
|
||||||
"tip": "Nospiediet ? jebkurā laikā, lai skatītu palīdzību",
|
"tip": "Nospiediet ? jebkurā laikā, lai skatītu palīdzību",
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "Afzender-favicons (Experimenteel)",
|
"label": "Afzender-favicons (Experimenteel)",
|
||||||
"description": "Toon websitepictogrammen als profielfoto's voor zakelijke afzenders"
|
"description": "Toon websitepictogrammen als profielfoto's voor zakelijke afzenders"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "Avatars tonen in de map ongewenst",
|
||||||
|
"description": "Toon profielfoto's en favicons van afzenders in de map ongewenst. Standaard uitgeschakeld zodat phishingpogingen geen vertrouwd uiterlijk krijgen."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Sneltoetsen",
|
"label": "Sneltoetsen",
|
||||||
"description": "Bekijk beschikbare sneltoetsen",
|
"description": "Bekijk beschikbare sneltoetsen",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "{count} e-mails geselecteerd",
|
"items_selected": "{count} e-mails geselecteerd",
|
||||||
"edit_draft": "Concept bewerken"
|
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Sneltoetsen",
|
"title": "Sneltoetsen",
|
||||||
"tip": "Druk op ? om deze hulp te tonen",
|
"tip": "Druk op ? om deze hulp te tonen",
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "Favikony nadawców (eksperymentalne)",
|
"label": "Favikony nadawców (eksperymentalne)",
|
||||||
"description": "Pokazuj ikony stron internetowych jako zdjęcia profilowe nadawców firmowych"
|
"description": "Pokazuj ikony stron internetowych jako zdjęcia profilowe nadawców firmowych"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "Pokazuj awatary w folderze spam",
|
||||||
|
"description": "Pokazuj zdjęcia profilowe i favikony nadawców w folderze spam. Domyślnie wyłączone, aby próby phishingu nie wyglądały na wiarygodne."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Skróty klawiszowe",
|
"label": "Skróty klawiszowe",
|
||||||
"description": "Wyświetl dostępne skróty klawiszowe",
|
"description": "Wyświetl dostępne skróty klawiszowe",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "{count} zaznaczonych wiadomości",
|
"items_selected": "{count} zaznaczonych wiadomości",
|
||||||
"edit_draft": "Edytuj szkic"
|
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Skróty klawiszowe",
|
"title": "Skróty klawiszowe",
|
||||||
"tip": "Naciśnij ? w dowolnym momencie, aby wyświetlić tę pomoc",
|
"tip": "Naciśnij ? w dowolnym momencie, aby wyświetlić tę pomoc",
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "Favicons de remetente (Experimental)",
|
"label": "Favicons de remetente (Experimental)",
|
||||||
"description": "Exibir ícones de sites como fotos de perfil para remetentes empresariais"
|
"description": "Exibir ícones de sites como fotos de perfil para remetentes empresariais"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "Mostrar avatares na pasta de spam",
|
||||||
|
"description": "Exibir imagens de perfil e favicons dos remetentes na pasta de spam. Desativado por padrão para não dar aparência legítima a tentativas de phishing."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Atalhos de Teclado",
|
"label": "Atalhos de Teclado",
|
||||||
"description": "Visualizar atalhos de teclado disponíveis",
|
"description": "Visualizar atalhos de teclado disponíveis",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "{count} e-mails selecionados",
|
"items_selected": "{count} e-mails selecionados",
|
||||||
"edit_draft": "Editar rascunho"
|
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Atalhos de Teclado",
|
"title": "Atalhos de Teclado",
|
||||||
"tip": "Pressione ? a qualquer momento para mostrar esta ajuda",
|
"tip": "Pressione ? a qualquer momento para mostrar esta ajuda",
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "Фавиконы отправителей (Экспериментально)",
|
"label": "Фавиконы отправителей (Экспериментально)",
|
||||||
"description": "Показывать иконки сайтов как аватары для корпоративных отправителей"
|
"description": "Показывать иконки сайтов как аватары для корпоративных отправителей"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "Показывать аватары в папке «Спам»",
|
||||||
|
"description": "Показывать аватары и фавиконы отправителей в папке «Спам». По умолчанию отключено, чтобы фишинговые письма не выглядели правдоподобно."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Сочетания клавиш",
|
"label": "Сочетания клавиш",
|
||||||
"description": "Просмотреть доступные сочетания клавиш",
|
"description": "Просмотреть доступные сочетания клавиш",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "{count} писем выбрано",
|
"items_selected": "{count} писем выбрано",
|
||||||
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Сочетания клавиш",
|
"title": "Сочетания клавиш",
|
||||||
"tip": "Нажмите ? в любое время для отображения справки",
|
"tip": "Нажмите ? в любое время для отображения справки",
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "Favicons відправника (експериментальний)",
|
"label": "Favicons відправника (експериментальний)",
|
||||||
"description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників"
|
"description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "Показувати аватари в папці «Спам»",
|
||||||
|
"description": "Показувати зображення профілю та фавікони відправників у папці «Спам». Типово вимкнено, щоб фішингові листи не виглядали достовірно."
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Комбінації клавіш",
|
"label": "Комбінації клавіш",
|
||||||
"description": "Переглянути доступні комбінації клавіш",
|
"description": "Переглянути доступні комбінації клавіш",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "Вибрано електронних листів: {count}",
|
"items_selected": "Вибрано електронних листів: {count}",
|
||||||
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Комбінації клавіш",
|
"title": "Комбінації клавіш",
|
||||||
"tip": "Натисніть ? у будь-який час, щоб показати цю допомогу",
|
"tip": "Натисніть ? у будь-який час, щоб показати цю допомогу",
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@
|
|||||||
"label": "发件人图标(实验性)",
|
"label": "发件人图标(实验性)",
|
||||||
"description": "使用企业发件人的网站图标作为头像"
|
"description": "使用企业发件人的网站图标作为头像"
|
||||||
},
|
},
|
||||||
|
"show_avatars_in_junk": {
|
||||||
|
"label": "在垃圾邮件文件夹中显示头像",
|
||||||
|
"description": "在垃圾邮件文件夹中显示发件人的头像和网站图标。默认关闭,避免钓鱼邮件因熟悉的图标看起来更可信。"
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "键盘快捷键",
|
"label": "键盘快捷键",
|
||||||
"description": "查看可用快捷键",
|
"description": "查看可用快捷键",
|
||||||
@@ -1586,6 +1590,43 @@
|
|||||||
"items_selected": "已选择 {count} 封邮件",
|
"items_selected": "已选择 {count} 封邮件",
|
||||||
"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": "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",
|
||||||
|
"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.",
|
||||||
|
"placeholder_folder_name": "Folder name",
|
||||||
|
"create": "Create",
|
||||||
|
"rename_confirm": "Rename"
|
||||||
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "键盘快捷键",
|
"title": "键盘快捷键",
|
||||||
"tip": "按?随时显示此帮助",
|
"tip": "按?随时显示此帮助",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
+50
-9
@@ -118,6 +118,7 @@ interface EmailStore {
|
|||||||
deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
||||||
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
|
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
|
||||||
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
||||||
|
markMailboxAsRead: (client: IJMAPClient, mailboxId: string) => Promise<number>;
|
||||||
|
|
||||||
// Unified mailbox operations
|
// Unified mailbox operations
|
||||||
fetchUnifiedEmails: (accounts: UnifiedAccountClient[], role: UnifiedMailboxRole) => Promise<void>;
|
fetchUnifiedEmails: (accounts: UnifiedAccountClient[], role: UnifiedMailboxRole) => Promise<void>;
|
||||||
@@ -289,7 +290,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
// JMAP operations
|
// JMAP operations
|
||||||
fetchMailboxes: async (client) => {
|
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 {
|
try {
|
||||||
const mailboxes = await client.getAllMailboxes();
|
const mailboxes = await client.getAllMailboxes();
|
||||||
|
|
||||||
@@ -297,21 +303,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// doesn't exist in the fetched list (e.g. after an account switch)
|
// doesn't exist in the fetched list (e.g. after an account switch)
|
||||||
const currentSelectedMailbox = get().selectedMailbox;
|
const currentSelectedMailbox = get().selectedMailbox;
|
||||||
const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox);
|
const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox);
|
||||||
|
const loadingPatch = isInitialLoad ? { isLoading: false } : {};
|
||||||
if (!selectionValid) {
|
if (!selectionValid) {
|
||||||
// Find inbox from PRIMARY account (not shared accounts)
|
// Find inbox from PRIMARY account (not shared accounts)
|
||||||
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
|
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
|
||||||
if (inboxMailbox) {
|
if (inboxMailbox) {
|
||||||
set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false });
|
set({ mailboxes, selectedMailbox: inboxMailbox.id, ...loadingPatch });
|
||||||
} else {
|
} else {
|
||||||
set({ mailboxes, selectedMailbox: '', isLoading: false });
|
set({ mailboxes, selectedMailbox: '', ...loadingPatch });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
set({ mailboxes, isLoading: false });
|
set({ mailboxes, ...loadingPatch });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : "Failed to fetch mailboxes",
|
error: error instanceof Error ? error.message : "Failed to fetch mailboxes",
|
||||||
isLoading: false
|
...(isInitialLoad ? { isLoading: false } : {})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1254,9 +1261,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
isLoading: false
|
isLoading: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh emails to get updated list
|
// Refresh emails to get updated list (honors active search/filters)
|
||||||
if (!get().isUnifiedView) {
|
if (!get().isUnifiedView) {
|
||||||
await get().fetchEmails(client, get().selectedMailbox);
|
await get().refreshCurrentMailbox(client);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
@@ -1267,7 +1274,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
batchArchive: async (client) => {
|
batchArchive: async (client) => {
|
||||||
const { selectedEmailIds, emails, mailboxes, fetchMailboxes, fetchEmails, selectedMailbox } = get();
|
const { selectedEmailIds, emails, mailboxes, fetchMailboxes } = get();
|
||||||
if (selectedEmailIds.size === 0) return;
|
if (selectedEmailIds.size === 0) return;
|
||||||
|
|
||||||
const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive');
|
const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive');
|
||||||
@@ -1293,7 +1300,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false });
|
set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false });
|
||||||
|
|
||||||
await fetchMailboxes(client);
|
await fetchMailboxes(client);
|
||||||
await fetchEmails(client, selectedMailbox);
|
// Refresh the current mailbox view (honors active search/filters)
|
||||||
|
await get().refreshCurrentMailbox(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : 'Failed to archive emails',
|
error: error instanceof Error ? error.message : 'Failed to archive emails',
|
||||||
@@ -1743,6 +1751,39 @@ export const useEmailStore = create<EmailStore>((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
|
// Unified mailbox operations
|
||||||
fetchUnifiedEmails: async (accounts, role) => {
|
fetchUnifiedEmails: async (accounts, role) => {
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ interface SettingsState {
|
|||||||
|
|
||||||
// Experimental
|
// Experimental
|
||||||
senderFavicons: boolean;
|
senderFavicons: boolean;
|
||||||
|
showAvatarsInJunk: boolean; // Show profile images/favicons in the junk folder
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
|
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
|
||||||
@@ -334,6 +335,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
|
|
||||||
// Experimental
|
// Experimental
|
||||||
senderFavicons: true,
|
senderFavicons: true,
|
||||||
|
showAvatarsInJunk: false,
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
colorfulSidebarIcons: true,
|
colorfulSidebarIcons: true,
|
||||||
@@ -475,6 +477,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
showRailAccountList: state.showRailAccountList,
|
showRailAccountList: state.showRailAccountList,
|
||||||
enableUnifiedMailbox: state.enableUnifiedMailbox,
|
enableUnifiedMailbox: state.enableUnifiedMailbox,
|
||||||
senderFavicons: state.senderFavicons,
|
senderFavicons: state.senderFavicons,
|
||||||
|
showAvatarsInJunk: state.showAvatarsInJunk,
|
||||||
colorfulSidebarIcons: state.colorfulSidebarIcons,
|
colorfulSidebarIcons: state.colorfulSidebarIcons,
|
||||||
folderIcons: state.folderIcons,
|
folderIcons: state.folderIcons,
|
||||||
emailKeywords: state.emailKeywords,
|
emailKeywords: state.emailKeywords,
|
||||||
|
|||||||
Reference in New Issue
Block a user