Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.2 (2026-04-25)
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission — plugins can now render a panel on the left side of the New Message dialog. See `repos/subway-surfers` for an example
|
||||
- **Plugins**: Manifests can declare `frameOrigins` — a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
|
||||
|
||||
## 1.5.1 (2026-04-25)
|
||||
|
||||
### Features
|
||||
|
||||
- **Stalwart**: OAuth auto-setup with dialog and validation for origin and issuer URLs
|
||||
- **Mail**: Right-click context menu on the folders sidebar
|
||||
- **Mail**: Replace folder `prompt()` calls with a proper modal dialog
|
||||
- **Calendar**: Add 'Today' button to the desktop calendar toolbar
|
||||
- **Junk**: Setting to show avatars in the Junk folder (off by default)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Admin**: Restore admin panel after Stalwart v0.16 REST API removal
|
||||
- **Viewer**: Restore broken viewer toolbar actions and improve the mobile menu (#220)
|
||||
- **Folders**: Stop flicker on background folder refresh
|
||||
- **Email**: Preserve search/filter on batch move and archive
|
||||
- **Email**: Preserve search/filter when moving emails via drag-drop
|
||||
- **i18n**: Improve Korean flag
|
||||
|
||||
## 1.5.0 (2026-04-22)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</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
|
||||
- **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)**.
|
||||
|
||||
|
||||
@@ -47,7 +47,11 @@ import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -72,7 +76,8 @@ export default function CalendarPage() {
|
||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||
removeCalendar, clearCalendarEvents,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
@@ -91,6 +96,11 @@ export default function CalendarPage() {
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
|
||||
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
|
||||
const [showCreateCalendar, setShowCreateCalendar] = useState(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||
@@ -1102,6 +1112,42 @@ export default function CalendarPage() {
|
||||
}
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : undefined}
|
||||
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||
onCreateEvent={(cal: Calendar) => {
|
||||
setDefaultCalendarIdForCreate(cal.id);
|
||||
openCreateModal();
|
||||
}}
|
||||
onClearCalendar={client ? async (cal: Calendar) => {
|
||||
const ok = await confirmAction({
|
||||
title: tMgmt("clear_events"),
|
||||
message: tMgmt("confirm_clear", { name: cal.name }),
|
||||
variant: "destructive",
|
||||
confirmText: tMgmt("clear_events"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const count = await clearCalendarEvents(client, cal.id);
|
||||
toast.success(tMgmt("events_cleared", { count }));
|
||||
} catch {
|
||||
toast.error(tMgmt("error_clear"));
|
||||
}
|
||||
} : undefined}
|
||||
onDeleteCalendar={client ? async (cal: Calendar) => {
|
||||
const ok = await confirmAction({
|
||||
title: tMgmt("delete"),
|
||||
message: tMgmt("confirm_delete", { name: cal.name }),
|
||||
variant: "destructive",
|
||||
confirmText: tMgmt("delete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await removeCalendar(client, cal.id);
|
||||
toast.success(tMgmt("calendar_deleted"));
|
||||
} catch {
|
||||
toast.error(tMgmt("error_delete"));
|
||||
}
|
||||
} : undefined}
|
||||
onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||
client={client}
|
||||
@@ -1165,11 +1211,12 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||
onPreviewChange={setPendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={false}
|
||||
@@ -1261,11 +1308,12 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={true}
|
||||
/>
|
||||
@@ -1305,6 +1353,33 @@ export default function CalendarPage() {
|
||||
onSelect={handleScopeSelect}
|
||||
onClose={() => setPendingScopeAction(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
|
||||
{showCreateCalendar && client && (
|
||||
<CreateCalendarModal
|
||||
client={client}
|
||||
onClose={() => setShowCreateCalendar(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharingCalendarId && client && (() => {
|
||||
const cal = allCalendars.find((c) => c.id === sharingCalendarId);
|
||||
if (!cal) return null;
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="calendar"
|
||||
collectionName={cal.name}
|
||||
shareWith={cal.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
|
||||
}}
|
||||
onClose={() => setSharingCalendarId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -75,6 +76,8 @@ export default function ContactsPage() {
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
renameAddressBook,
|
||||
removeAddressBook,
|
||||
shareAddressBook,
|
||||
renameKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
@@ -83,6 +86,8 @@ export default function ContactsPage() {
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
@@ -329,6 +334,7 @@ export default function ContactsPage() {
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
setView("list");
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
|
||||
@@ -346,6 +352,7 @@ export default function ContactsPage() {
|
||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
if (view === "group-create" || view === "group-edit") {
|
||||
setView(selectedGroup ? "group-detail" : "list");
|
||||
} else if (view === "bulk-add-to-group") {
|
||||
@@ -517,7 +524,7 @@ export default function ContactsPage() {
|
||||
const renderRightPanel = () => {
|
||||
switch (view) {
|
||||
case "create":
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
|
||||
case "edit":
|
||||
if (!selectedContact) return null;
|
||||
@@ -690,6 +697,26 @@ export default function ContactsPage() {
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
|
||||
onCreateContactInBook={(book) => {
|
||||
setDefaultBookIdForCreate(book.id);
|
||||
handleCreateNew();
|
||||
}}
|
||||
onDeleteAddressBook={client ? async (book) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("address_books.delete"),
|
||||
message: t("address_books.confirm_delete", { name: book.name }),
|
||||
variant: "destructive",
|
||||
confirmText: t("address_books.delete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await removeAddressBook(client, book);
|
||||
toast.success(t("address_books.deleted"));
|
||||
} catch {
|
||||
toast.error(t("address_books.delete_failed"));
|
||||
}
|
||||
} : undefined}
|
||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||
/>
|
||||
</div>
|
||||
@@ -838,6 +865,23 @@ export default function ContactsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sharingAddressBookId && client && (() => {
|
||||
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
|
||||
if (!book) return null;
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="addressBook"
|
||||
collectionName={book.name}
|
||||
shareWith={book.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||
}}
|
||||
onClose={() => setSharingAddressBookId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+217
-11
@@ -14,6 +14,7 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
@@ -23,6 +24,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
|
||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
ComposerErrorFallback,
|
||||
} from "@/components/error";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||
@@ -67,6 +70,7 @@ export default function Home() {
|
||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
@@ -162,6 +166,11 @@ export default function Home() {
|
||||
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||
refreshUnifiedCounts,
|
||||
exitUnifiedView,
|
||||
emptyMailbox,
|
||||
markMailboxAsRead,
|
||||
createMailbox,
|
||||
renameMailbox,
|
||||
deleteMailbox,
|
||||
} = useEmailStore();
|
||||
|
||||
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 handleSearch = async (query: string) => {
|
||||
@@ -1295,15 +1495,11 @@ export default function Home() {
|
||||
};
|
||||
|
||||
// Handle back navigation from viewer on mobile.
|
||||
// Delegate to the browser history stack so this button is equivalent to
|
||||
// the OS back button / mouse back button - popstate then restores the
|
||||
// previous snapshot via handleNavRestore. The viewer is only reachable
|
||||
// from a state that pushed history, so back() always lands on an app entry.
|
||||
// Reset to list state directly. We can't just call window.history.back()
|
||||
// because the nav hook pushes a new entry for every email the user opens,
|
||||
// so history.back() would pop to the previous email rather than the list.
|
||||
// The OS / hardware back button is still wired through popstate → handleNavRestore.
|
||||
const handleMobileBack = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
if (conversationThread) {
|
||||
setConversationThread(null);
|
||||
setConversationEmails([]);
|
||||
@@ -1458,6 +1654,15 @@ export default function Home() {
|
||||
onMailboxSelect={handleMailboxSelect}
|
||||
onTagSelect={handleTagSelect}
|
||||
onUnreadFilterClick={handleUnreadFilterClick}
|
||||
onMarkFolderRead={handleMarkFolderRead}
|
||||
onMarkFolderTreeRead={handleMarkFolderTreeRead}
|
||||
onMarkAllFoldersRead={handleMarkAllFoldersRead}
|
||||
onEmptyFolder={handleEmptyFolderFromContextMenu}
|
||||
onCreateSubfolder={handleCreateSubfolderFromContextMenu}
|
||||
onCreateFolder={handleCreateFolderFromContextMenu}
|
||||
onRenameFolder={handleRenameFolderFromContextMenu}
|
||||
onDeleteFolder={handleDeleteFolderFromContextMenu}
|
||||
onRefreshMailboxes={handleRefreshMailboxes}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
@@ -1934,12 +2139,12 @@ export default function Home() {
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onDelete={() => handleDelete()}
|
||||
onArchive={() => handleArchive()}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onMarkAsSpam={handleMarkAsSpam}
|
||||
onUndoSpam={handleUndoSpam}
|
||||
onMarkAsSpam={() => handleMarkAsSpam()}
|
||||
onUndoSpam={() => handleUndoSpam()}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, emailId, read);
|
||||
@@ -2009,6 +2214,7 @@ export default function Home() {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<PromptDialog {...promptDialogProps} />
|
||||
<TotpReauthDialog />
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
|
||||
+166
-1
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
@@ -69,6 +69,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;
|
||||
|
||||
if (loading) {
|
||||
@@ -100,6 +152,119 @@ export default function AdminAuthPage() {
|
||||
</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 */}
|
||||
<Section title="OAuth / OpenID Connect">
|
||||
<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 pathname = usePathname();
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
@@ -73,54 +74,59 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname !== '/admin/login') {
|
||||
checkAuth();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
if (pathname === '/admin/login') return;
|
||||
let cancelled = false;
|
||||
|
||||
function getJmapHeaders(): Record<string, string> {
|
||||
return getActiveAccountSlotHeaders();
|
||||
}
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const jmapHeaders = getActiveAccountSlotHeaders();
|
||||
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const jmapHeaders = getJmapHeaders();
|
||||
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||
const data = await res.json();
|
||||
const stalwartAdmin = data.stalwartAdmin === true;
|
||||
setIsStalwartAdmin(stalwartAdmin);
|
||||
|
||||
const stalwartAdmin = data.stalwartAdmin === true;
|
||||
setIsStalwartAdmin(stalwartAdmin);
|
||||
// If neither password-based admin nor Stalwart admin, redirect away
|
||||
if (!data.enabled && !stalwartAdmin) {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
// If neither password-based admin nor Stalwart admin, redirect away
|
||||
if (!data.enabled && !stalwartAdmin) {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.authenticated) {
|
||||
setAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If Stalwart admin but not yet authenticated, auto-login
|
||||
if (stalwartAdmin) {
|
||||
const loginRes = await apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||
body: JSON.stringify({ stalwartAuth: true }),
|
||||
});
|
||||
if (loginRes.ok) {
|
||||
if (data.authenticated) {
|
||||
setAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
router.replace('/admin/login');
|
||||
} catch {
|
||||
router.replace('/admin/login');
|
||||
// If Stalwart admin but not yet authenticated, auto-login
|
||||
if (stalwartAdmin) {
|
||||
const loginRes = await apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||
body: JSON.stringify({ stalwartAuth: true }),
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (loginRes.ok) {
|
||||
setAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
const body = await loginRes.json().catch(() => ({}));
|
||||
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
|
||||
setAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace('/admin/login');
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
|
||||
setAuthenticated(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth();
|
||||
return () => { cancelled = true; };
|
||||
}, [pathname, router]);
|
||||
|
||||
async function handleLogout() {
|
||||
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||
@@ -132,14 +138,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (authenticated === null) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Slim webmail nav rail */}
|
||||
@@ -269,7 +267,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
@@ -10,6 +10,10 @@ import {
|
||||
type ServerPlugin,
|
||||
type ServerTheme,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
@@ -226,6 +230,22 @@ export async function POST(request: NextRequest) {
|
||||
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
||||
}
|
||||
|
||||
// Plugins may declare iframe origins they need for embedded content.
|
||||
// Anything that doesn't pass strict origin validation is silently
|
||||
// dropped — the plugin still installs, but those origins are not
|
||||
// added to the host CSP.
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
||||
? (manifest.frameOrigins as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedFrameOrigins.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const plugin: ServerPlugin = {
|
||||
id: (manifest.id as string) || slug,
|
||||
name: (manifest.name as string) || slug,
|
||||
@@ -238,10 +258,14 @@ export async function POST(request: NextRequest) {
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
|
||||
|
||||
return NextResponse.json({ success: true, plugin, warnings });
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
deletePlugin as removePlugin,
|
||||
type ServerPlugin,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
|
||||
// Server-side extraction using the same validation logic
|
||||
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
||||
@@ -152,6 +156,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const plugin: ServerPlugin = {
|
||||
id: manifest.id as string,
|
||||
@@ -166,12 +172,16 @@ export async function POST(request: NextRequest) {
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
|
||||
|
||||
return NextResponse.json({ plugin });
|
||||
} catch (error) {
|
||||
@@ -209,6 +219,11 @@ export async function PATCH(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Enable/disable changes the set of plugins contributing frame origins.
|
||||
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
|
||||
invalidateFrameOriginsCache();
|
||||
}
|
||||
|
||||
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||
return NextResponse.json({ plugin: updated });
|
||||
} catch (error) {
|
||||
@@ -238,6 +253,7 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.delete', { id }, ip);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
clearStalwartAuthContextInStore,
|
||||
setStalwartAuthContextInStore,
|
||||
} from '@/lib/stalwart/auth-context';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
...getCookieOptions(),
|
||||
@@ -25,7 +26,9 @@ function getSlot(request: NextRequest): number {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
||||
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
|
||||
if (oauthEnabled && oauthOnly) {
|
||||
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 { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
/**
|
||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||
@@ -113,8 +114,8 @@ async function attemptAllStrategies(
|
||||
): Promise<NextResponse> {
|
||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||
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 attempts: Array<{ strategy: string; error: string }> = [];
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
|
||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
@@ -11,6 +11,8 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
interface CalendarSidebarPanelProps {
|
||||
@@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps {
|
||||
selectedCalendarIds: string[];
|
||||
onToggleVisibility: (id: string) => void;
|
||||
onColorChange?: (calendarId: string, color: string) => void;
|
||||
onShareCalendar?: (calendar: Calendar) => void;
|
||||
onCreateEvent?: (calendar: Calendar) => void;
|
||||
onClearCalendar?: (calendar: Calendar) => void;
|
||||
onDeleteCalendar?: (calendar: Calendar) => void;
|
||||
onCreateCalendar?: () => void;
|
||||
onSubscribe?: () => void;
|
||||
onEditSubscription?: (subscriptionId: string) => void;
|
||||
client?: IJMAPClient | null;
|
||||
@@ -28,12 +35,18 @@ export function CalendarSidebarPanel({
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
onColorChange,
|
||||
onShareCalendar,
|
||||
onCreateEvent,
|
||||
onClearCalendar,
|
||||
onDeleteCalendar,
|
||||
onCreateCalendar,
|
||||
onSubscribe,
|
||||
onEditSubscription,
|
||||
client,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tSub = useTranslations("calendar.subscription");
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
@@ -49,11 +62,8 @@ export function CalendarSidebarPanel({
|
||||
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
||||
}, [tasks]);
|
||||
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||
const sharedAccountGroups = useMemo(() => {
|
||||
@@ -69,30 +79,6 @@ export function CalendarSidebarPanel({
|
||||
return Array.from(groups.values());
|
||||
}, [calendars]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorPickerId && !contextMenuCalId) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
|
||||
setColorPickerId(null);
|
||||
}
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenuCalId(null);
|
||||
}
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setColorPickerId(null);
|
||||
setContextMenuCalId(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [colorPickerId, contextMenuCalId]);
|
||||
|
||||
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||
};
|
||||
@@ -100,7 +86,6 @@ export function CalendarSidebarPanel({
|
||||
const handleRefreshSubscription = async (subId: string) => {
|
||||
if (!client) return;
|
||||
setRefreshingSubId(subId);
|
||||
setContextMenuCalId(null);
|
||||
try {
|
||||
await refreshICalSubscription(client, subId);
|
||||
toast.success(tSub('refresh_success'));
|
||||
@@ -113,7 +98,6 @@ export function CalendarSidebarPanel({
|
||||
|
||||
const handleUnsubscribe = async (subId: string) => {
|
||||
if (!client) return;
|
||||
setContextMenuCalId(null);
|
||||
try {
|
||||
await removeICalSubscription(client, subId);
|
||||
toast.success(tSub('deleted'));
|
||||
@@ -127,21 +111,13 @@ export function CalendarSidebarPanel({
|
||||
const renderCalendarItem = (cal: Calendar) => {
|
||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||
const color = cal.color || "#3b82f6";
|
||||
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||
|
||||
return (
|
||||
<div key={cal.id} className="relative">
|
||||
<button
|
||||
onClick={() => onToggleVisibility(cal.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (isSubscriptionCalendar(cal.id) && client) {
|
||||
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
|
||||
setColorPickerId(null);
|
||||
} else if (onColorChange) {
|
||||
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
|
||||
setContextMenuCalId(null);
|
||||
}
|
||||
}}
|
||||
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||
"hover:bg-muted"
|
||||
@@ -169,70 +145,101 @@ export function CalendarSidebarPanel({
|
||||
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Subscription context menu on right-click */}
|
||||
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
|
||||
const sub = getSubscriptionForCalendar(cal.id);
|
||||
if (!sub) return null;
|
||||
return (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenuCalId(null);
|
||||
onEditSubscription?.(sub.id);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
{tSub('edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRefreshSubscription(sub.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{tSub('refresh')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUnsubscribe(sub.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{tSub('unsubscribe')}
|
||||
</button>
|
||||
{sub.lastRefreshed && (
|
||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Color picker popover on right-click */}
|
||||
{colorPickerId === cal.id && onColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
|
||||
<CalendarColorPicker
|
||||
value={color}
|
||||
onChange={(c) => {
|
||||
onColorChange(cal.id, c);
|
||||
setColorPickerId(null);
|
||||
}}
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCalendarMenu = () => {
|
||||
const cal = contextMenu.data;
|
||||
if (!cal) return null;
|
||||
|
||||
if (isSubscriptionCalendar(cal.id)) {
|
||||
const sub = getSubscriptionForCalendar(cal.id);
|
||||
if (!sub || !client) return null;
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={tSub('edit')}
|
||||
onClick={() => { closeContextMenu(); onEditSubscription?.(sub.id); }}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={RefreshCw}
|
||||
label={tSub('refresh')}
|
||||
onClick={() => { closeContextMenu(); handleRefreshSubscription(sub.id); }}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={tSub('unsubscribe')}
|
||||
onClick={() => { closeContextMenu(); handleUnsubscribe(sub.id); }}
|
||||
destructive
|
||||
/>
|
||||
{sub.lastRefreshed && (
|
||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||
</div>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||
const canChangeColor = !!onColorChange;
|
||||
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
||||
const color = cal.color || "#3b82f6";
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||
{canCreate && (
|
||||
<ContextMenuItem
|
||||
icon={Plus}
|
||||
label={tMgmt('new_event_in_calendar')}
|
||||
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
|
||||
/>
|
||||
)}
|
||||
{canShare && (
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={tMgmt('share')}
|
||||
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
||||
/>
|
||||
)}
|
||||
{canChangeColor && (
|
||||
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
||||
<div className="px-2 py-1.5 w-[200px]">
|
||||
<CalendarColorPicker
|
||||
value={color}
|
||||
onChange={(c) => { onColorChange(cal.id, c); closeContextMenu(); }}
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
{showSeparator && <ContextMenuSeparator />}
|
||||
{canClear && (
|
||||
<ContextMenuItem
|
||||
icon={Eraser}
|
||||
label={tMgmt('clear_events')}
|
||||
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={tMgmt('delete')}
|
||||
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{enableCalendarTasks && (
|
||||
@@ -250,9 +257,22 @@ export function CalendarSidebarPanel({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||
{t("my_calendars")}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||
{onCreateCalendar ? (
|
||||
<button
|
||||
onClick={onCreateCalendar}
|
||||
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||
title={tMgmt('add_calendar')}
|
||||
>
|
||||
{t('my_calendars')}
|
||||
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
) : (
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t('my_calendars')}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{personalCalendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{renderCalendarMenu()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,6 +260,9 @@ export function CalendarToolbar({
|
||||
{/* ── DESKTOP TOOLBAR ── */}
|
||||
{!isMobile && (
|
||||
<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")}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -279,14 +282,14 @@ export function CalendarToolbar({
|
||||
<div className="flex-1" />
|
||||
|
||||
{!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) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onViewModeChange(v)}
|
||||
title={t(`views.${v}_hint`)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
"inline-flex items-center px-3 text-xs font-medium transition-colors",
|
||||
v === viewMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground"
|
||||
@@ -300,7 +303,7 @@ export function CalendarToolbar({
|
||||
|
||||
{(onImport || onSubscribe) && !isMobile && (
|
||||
<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" />
|
||||
{t("import.title")}
|
||||
<ChevronDown className="w-3 h-3 ml-1" />
|
||||
@@ -331,7 +334,7 @@ export function CalendarToolbar({
|
||||
)}
|
||||
|
||||
{!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" />
|
||||
{t("events.create")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface CreateCalendarModalProps {
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) {
|
||||
const t = useTranslations("calendar.management");
|
||||
const tCommon = useTranslations("common");
|
||||
const createCalendar = useCalendarStore((s) => s.createCalendar);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState("#3b82f6");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const created = await createCalendar(client, { name: trimmed, color });
|
||||
if (created) {
|
||||
toast.success(t("calendar_created"));
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(t("error_create"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error_create"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [name, color, client, createCalendar, onClose, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !isSubmitting) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose, isSubmitting]);
|
||||
|
||||
useEffect(() => {
|
||||
const modal = modalRef.current;
|
||||
if (!modal) return;
|
||||
const focusableEls = modal.querySelectorAll<HTMLElement>(
|
||||
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstEl = focusableEls[0];
|
||||
const lastEl = focusableEls[focusableEls.length - 1];
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
if (e.shiftKey && document.activeElement === firstEl) {
|
||||
e.preventDefault();
|
||||
lastEl?.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl?.focus();
|
||||
}
|
||||
};
|
||||
modal.addEventListener("keydown", handler);
|
||||
firstEl?.focus();
|
||||
return () => modal.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
|
||||
onClick={() => !isSubmitting && onClose()}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("add_calendar")}
|
||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
aria-label={tCommon("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("name")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("name_placeholder")}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={isSubmitting}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("color")}
|
||||
</label>
|
||||
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
{tCommon("loading")}
|
||||
</>
|
||||
) : (
|
||||
t("create")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ interface EventModalProps {
|
||||
calendars: Calendar[];
|
||||
defaultDate?: Date;
|
||||
defaultEndDate?: Date;
|
||||
defaultCalendarId?: string;
|
||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||
@@ -113,6 +114,7 @@ export function EventModal({
|
||||
calendars,
|
||||
defaultDate,
|
||||
defaultEndDate,
|
||||
defaultCalendarId,
|
||||
onSave,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
@@ -200,6 +202,7 @@ export function EventModal({
|
||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
|
||||
const defaultCal = calendars.find(c => c.isDefault);
|
||||
return defaultCal?.id || calendars[0]?.id || "";
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ interface ContactFormProps {
|
||||
contact?: ContactCard | null;
|
||||
addressBooks?: AddressBook[];
|
||||
allKeywords?: string[];
|
||||
defaultAddressBookId?: string;
|
||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -143,11 +144,13 @@ function Select({ value, onChange, children, className }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
|
||||
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
|
||||
const t = useTranslations("contacts.form");
|
||||
const isEditing = !!contact;
|
||||
|
||||
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
|
||||
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
|
||||
const findComponent = (...kinds: string[]) =>
|
||||
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
|
||||
|
||||
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
||||
function anniversaryDateToString(date: AnniversaryDate): string {
|
||||
@@ -210,11 +213,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
};
|
||||
}
|
||||
|
||||
const [prefix, setPrefix] = useState(findComponent("prefix"));
|
||||
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
|
||||
const [givenName, setGivenName] = useState(findComponent("given"));
|
||||
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
||||
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
|
||||
const [surname, setSurname] = useState(findComponent("surname"));
|
||||
const [suffix, setSuffix] = useState(findComponent("suffix"));
|
||||
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
|
||||
|
||||
const [nickname, setNickname] = useState(
|
||||
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
|
||||
@@ -328,8 +331,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
return ids[0];
|
||||
}
|
||||
}
|
||||
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
|
||||
return defaultAddressBookId;
|
||||
}
|
||||
return "";
|
||||
}, [contact]);
|
||||
}, [contact, defaultAddressBookId, addressBooks]);
|
||||
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||
|
||||
const initialPhotoEntry = useMemo(() => {
|
||||
@@ -436,12 +442,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
phonesMap[`p${i}`] = obj;
|
||||
});
|
||||
|
||||
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
|
||||
const nameComponents = [];
|
||||
if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() });
|
||||
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
||||
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
||||
if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() });
|
||||
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
|
||||
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
||||
if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() });
|
||||
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
|
||||
|
||||
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
||||
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
||||
|
||||
@@ -27,6 +27,9 @@ interface ContactsSidebarProps {
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||
onShareAddressBook?: (addressBook: AddressBook) => void;
|
||||
onCreateContactInBook?: (addressBook: AddressBook) => void;
|
||||
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||
onRenameKeyword?: (keyword: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -62,6 +65,9 @@ export function ContactsSidebar({
|
||||
onDropContacts,
|
||||
onDropContactsToCategory,
|
||||
onRenameAddressBook,
|
||||
onShareAddressBook,
|
||||
onCreateContactInBook,
|
||||
onDeleteAddressBook,
|
||||
onRenameKeyword,
|
||||
className,
|
||||
}: ContactsSidebarProps) {
|
||||
@@ -288,7 +294,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -430,7 +436,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -438,24 +444,65 @@ export function ContactsSidebar({
|
||||
</div>
|
||||
|
||||
{/* Address book context menu */}
|
||||
{bookContextMenu.data && onRenameAddressBook && (
|
||||
<ContextMenu
|
||||
ref={bookMenuRef}
|
||||
isOpen={bookContextMenu.isOpen}
|
||||
position={bookContextMenu.position}
|
||||
onClose={closeBookContextMenu}
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("address_books.rename")}
|
||||
onClick={() => {
|
||||
const book = bookContextMenu.data!;
|
||||
closeBookContextMenu();
|
||||
onRenameAddressBook(book);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
|
||||
const book = bookContextMenu.data;
|
||||
const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
|
||||
const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
|
||||
const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
|
||||
const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
|
||||
const showSeparator = (canCreate || canRename || canShare) && canDelete;
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={bookMenuRef}
|
||||
isOpen={bookContextMenu.isOpen}
|
||||
position={bookContextMenu.position}
|
||||
onClose={closeBookContextMenu}
|
||||
>
|
||||
{canCreate && (
|
||||
<ContextMenuItem
|
||||
icon={UserPlus}
|
||||
label={t("address_books.new_contact_in_book")}
|
||||
onClick={() => {
|
||||
closeBookContextMenu();
|
||||
onCreateContactInBook(book);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canRename && (
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("address_books.rename")}
|
||||
onClick={() => {
|
||||
closeBookContextMenu();
|
||||
onRenameAddressBook(book);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canShare && (
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("address_books.share")}
|
||||
onClick={() => {
|
||||
closeBookContextMenu();
|
||||
onShareAddressBook(book);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showSeparator && <ContextMenuSeparator />}
|
||||
{canDelete && (
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("address_books.delete")}
|
||||
onClick={() => {
|
||||
closeBookContextMenu();
|
||||
onDeleteAddressBook(book);
|
||||
}}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Keyword (category) context menu */}
|
||||
{keywordContextMenu.data && onRenameKeyword && (
|
||||
|
||||
@@ -1100,8 +1100,14 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full bg-background", className)}>
|
||||
<PluginSlot
|
||||
name="composer-sidebar"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
|
||||
/>
|
||||
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
|
||||
<div
|
||||
className={cn("flex flex-col h-full bg-background relative", className)}
|
||||
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
|
||||
data-tour="composer"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1674,6 +1680,11 @@ export function EmailComposer({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PluginSlot
|
||||
name="composer-sidebar-right"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-l border-border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const { identities } = useAuthStore();
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
@@ -49,6 +50,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
|
||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||
@@ -164,6 +166,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -285,7 +288,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||
{showPreview && density !== 'extra-compact' && (
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
|
||||
+153
-121
@@ -3436,70 +3436,172 @@ export function EmailViewer({
|
||||
moreMenuOpen ? "translate-x-0" : "translate-x-full"
|
||||
)}>
|
||||
<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>
|
||||
<Button variant="ghost" size="icon" onClick={() => setMoreMenuOpen(false)} className="h-9 w-9">
|
||||
{moreMenuSub ? (
|
||||
<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" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<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 */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
{moreMenuSub === null && (
|
||||
<>
|
||||
<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="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
|
||||
{(() => {
|
||||
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); }}
|
||||
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);
|
||||
})()}
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Tags */}
|
||||
{colorOptions.length > 0 && (
|
||||
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
|
||||
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
|
||||
return nodes.map((node) => {
|
||||
const Icon = getMoveMailboxIcon(node.role);
|
||||
const isTarget = moveTargetIds.has(node.id);
|
||||
return (
|
||||
<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) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
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(
|
||||
"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"
|
||||
@@ -3513,85 +3615,15 @@ export function EmailViewer({
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<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"
|
||||
>
|
||||
<X className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -64,6 +64,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
@@ -182,6 +184,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -306,7 +309,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && (
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
@@ -359,6 +362,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
@@ -376,6 +380,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)).slice(0, 4)
|
||||
: participantNames;
|
||||
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
|
||||
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
||||
email: latestEmail,
|
||||
@@ -563,6 +568,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
email={avatarPerson?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -709,7 +715,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && (
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
hasUnread
|
||||
|
||||
@@ -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 { useThemeStore } from "@/stores/theme-store";
|
||||
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
||||
import NextLink from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -336,9 +335,9 @@ export function NavigationRail({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Admin (Stalwart admins) */}
|
||||
{/* Admin (Stalwart admins) — hard nav because /admin lives outside the [locale] tree */}
|
||||
{isStalwartAdmin && (
|
||||
<NextLink
|
||||
<a
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"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" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
||||
</NextLink>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
@@ -514,13 +513,13 @@ export function NavigationRail({
|
||||
{/* 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">
|
||||
{isStalwartAdmin && (
|
||||
<NextLink
|
||||
<a
|
||||
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"
|
||||
title={t("admin") || "Admin"}
|
||||
>
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
</NextLink>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<Link
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
|
||||
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
|
||||
@@ -56,6 +58,15 @@ interface SidebarProps {
|
||||
onCompose?: () => void;
|
||||
onSidebarClose?: () => void;
|
||||
onUnreadFilterClick?: (mailboxId: string) => void;
|
||||
onMarkFolderRead?: (mailboxId: string) => void;
|
||||
onMarkFolderTreeRead?: (mailboxId: string) => void;
|
||||
onMarkAllFoldersRead?: () => void;
|
||||
onEmptyFolder?: (mailboxId: string) => void;
|
||||
onCreateSubfolder?: (parentId: string) => void;
|
||||
onCreateFolder?: () => void;
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -187,6 +198,7 @@ interface SidebarRowProps {
|
||||
dropHandlers?: Record<string, unknown>;
|
||||
isValidDropTarget?: boolean;
|
||||
isInvalidDropTarget?: boolean;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function SidebarRow({
|
||||
@@ -206,6 +218,7 @@ function SidebarRow({
|
||||
dropHandlers,
|
||||
isValidDropTarget,
|
||||
isInvalidDropTarget,
|
||||
onContextMenu,
|
||||
}: SidebarRowProps) {
|
||||
const t = useTranslations('sidebar');
|
||||
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
||||
@@ -213,6 +226,7 @@ function SidebarRow({
|
||||
return (
|
||||
<div
|
||||
{...(dropHandlers || {})}
|
||||
onContextMenu={onContextMenu}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
className={cn(
|
||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
|
||||
@@ -365,6 +379,7 @@ function MailboxTreeItem({
|
||||
isCollapsed,
|
||||
onUnreadFilterClick,
|
||||
colorful,
|
||||
onContextMenu,
|
||||
}: {
|
||||
node: MailboxNode;
|
||||
selectedMailbox: string;
|
||||
@@ -374,6 +389,7 @@ function MailboxTreeItem({
|
||||
isCollapsed: boolean;
|
||||
onUnreadFilterClick?: (mailboxId: string) => void;
|
||||
colorful: boolean;
|
||||
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
|
||||
}) {
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const hasChildren = node.children.length > 0;
|
||||
@@ -423,6 +439,7 @@ function MailboxTreeItem({
|
||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||
isValidDropTarget={isValidDropTarget}
|
||||
isInvalidDropTarget={isInvalidDropTarget}
|
||||
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
|
||||
/>
|
||||
|
||||
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
||||
@@ -436,6 +453,7 @@ function MailboxTreeItem({
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorful}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -610,6 +628,15 @@ export function Sidebar({
|
||||
onCompose: _onCompose,
|
||||
onSidebarClose,
|
||||
onUnreadFilterClick,
|
||||
onMarkFolderRead,
|
||||
onMarkFolderTreeRead,
|
||||
onMarkAllFoldersRead,
|
||||
onEmptyFolder,
|
||||
onCreateSubfolder,
|
||||
onCreateFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onRefreshMailboxes,
|
||||
className,
|
||||
}: SidebarProps) {
|
||||
const router = useRouter();
|
||||
@@ -790,6 +817,23 @@ export function Sidebar({
|
||||
router.push('/settings');
|
||||
};
|
||||
|
||||
const {
|
||||
contextMenu: mailboxContextMenu,
|
||||
openContextMenu: openMailboxContextMenu,
|
||||
closeContextMenu: closeMailboxContextMenu,
|
||||
menuRef: mailboxMenuRef,
|
||||
} = useContextMenu<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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -866,7 +910,7 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div onContextMenu={handleFoldersHeaderContextMenu}>
|
||||
<SidebarSectionHeader
|
||||
label={t("folders")}
|
||||
expanded={foldersExpanded}
|
||||
@@ -894,6 +938,7 @@ export function Sidebar({
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -934,6 +979,7 @@ export function Sidebar({
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -975,6 +1021,23 @@ export function Sidebar({
|
||||
|
||||
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import csMessages from '@/locales/cs/common.json';
|
||||
import enMessages from '@/locales/en/common.json';
|
||||
import frMessages from '@/locales/fr/common.json';
|
||||
import jaMessages from '@/locales/ja/common.json';
|
||||
@@ -20,6 +21,7 @@ import zhMessages from '@/locales/zh/common.json';
|
||||
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
const ALL_MESSAGES = {
|
||||
cs: csMessages,
|
||||
en: enMessages,
|
||||
fr: frMessages,
|
||||
ja: jaMessages,
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Book, Pencil, Share2, Tag } from "lucide-react";
|
||||
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AddressBook } from "@/lib/jmap/types";
|
||||
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "./share-collection-dialog";
|
||||
|
||||
function AddressBookEditRow({
|
||||
initial,
|
||||
@@ -70,9 +71,10 @@ export function AddressBookManagementSettings() {
|
||||
const tContacts = useTranslations("contacts");
|
||||
const tSettings = useTranslations("settings.contacts");
|
||||
const { client } = useAuthStore();
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,6 +150,16 @@ export function AddressBookManagementSettings() {
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{!book.isShared && book.myRights?.mayShare && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingId(book.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("share")}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -242,6 +254,24 @@ export function AddressBookManagementSettings() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
{sharingId && client && (() => {
|
||||
const book = addressBooks.find((b) => b.id === sharingId);
|
||||
if (!book) return null;
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="addressBook"
|
||||
collectionName={book.name}
|
||||
shareWith={book.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||
}}
|
||||
onClose={() => setSharingId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
||||
const tAdvanced = useTranslations('settings.advanced');
|
||||
const tTour = useTranslations('tour');
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { fontSize, density, animationsEnabled, senderFavicons, updateSetting } = useSettingsStore();
|
||||
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
|
||||
@@ -130,6 +130,10 @@ export function AppearanceSettings() {
|
||||
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
|
||||
</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')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react';
|
||||
import { ShareCollectionDialog } from './share-collection-dialog';
|
||||
import type { CalendarRights } from '@/lib/jmap/types';
|
||||
import { cn, formatDateTime } from '@/lib/utils';
|
||||
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||
@@ -83,7 +85,7 @@ function CalendarColorPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEditForm({
|
||||
export function CalendarEditForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
@@ -153,7 +155,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
|
||||
export function CalendarManagementSettings() {
|
||||
const t = useTranslations('calendar.management');
|
||||
const { client, serverUrl, username } = useAuthStore();
|
||||
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||
|
||||
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
|
||||
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
|
||||
@@ -164,6 +166,7 @@ export function CalendarManagementSettings() {
|
||||
const [clearingId, setClearingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
||||
@@ -522,6 +525,16 @@ export function CalendarManagementSettings() {
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingId(cal.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('share')}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClearingId(cal.id)}
|
||||
@@ -689,6 +702,24 @@ export function CalendarManagementSettings() {
|
||||
onClose={() => setEditingSubscription(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharingId && client && (() => {
|
||||
const cal = calendars.find((c) => c.id === sharingId);
|
||||
if (!cal) return null;
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="calendar"
|
||||
collectionName={cal.name}
|
||||
shareWith={cal.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
|
||||
}}
|
||||
onClose={() => setSharingId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
type ShareKind = "calendar" | "addressBook";
|
||||
type AnyRights = CalendarRights | AddressBookRights;
|
||||
|
||||
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
|
||||
|
||||
const CALENDAR_PRESETS: Record<Exclude<RolePreset, "custom">, CalendarRights> = {
|
||||
freeBusy: {
|
||||
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
|
||||
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
|
||||
},
|
||||
read: {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false,
|
||||
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
|
||||
},
|
||||
manager: {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights> = {
|
||||
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
|
||||
readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false },
|
||||
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
|
||||
};
|
||||
|
||||
function detectCalendarPreset(r: CalendarRights): RolePreset {
|
||||
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
|
||||
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function detectAddressBookPreset(r: AddressBookRights): RolePreset {
|
||||
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights][]) {
|
||||
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
|
||||
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
interface ShareCollectionDialogProps {
|
||||
client: IJMAPClient;
|
||||
kind: ShareKind;
|
||||
collectionName: string;
|
||||
shareWith: Record<string, AnyRights> | null | undefined;
|
||||
ownAccountId: string;
|
||||
onShare: (principalId: string, rights: AnyRights | null) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ShareCollectionDialog({
|
||||
client,
|
||||
kind,
|
||||
collectionName,
|
||||
shareWith,
|
||||
ownAccountId,
|
||||
onShare,
|
||||
onClose,
|
||||
}: ShareCollectionDialogProps) {
|
||||
const t = useTranslations("sharing");
|
||||
const tCommon = useTranslations("common");
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [principals, setPrincipals] = useState<Principal[]>([]);
|
||||
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
// Load principals on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingPrincipals(true);
|
||||
client.getPrincipals().then((list) => {
|
||||
if (cancelled) return;
|
||||
// Exclude the user themselves and any principal that already has a share
|
||||
const existing = new Set(Object.keys(shareWith || {}));
|
||||
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
||||
setPrincipals(filtered);
|
||||
setLoadingPrincipals(false);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setLoadingPrincipals(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [client, ownAccountId, shareWith]);
|
||||
|
||||
// Map principal id -> Principal for displayed shares
|
||||
const allPrincipalsById = useMemo(() => {
|
||||
const map = new Map<string, Principal>();
|
||||
for (const p of principals) map.set(p.id, p);
|
||||
return map;
|
||||
}, [principals]);
|
||||
|
||||
// Close on Escape, focus trap, click outside
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSetRights = async (principalId: string, preset: RolePreset) => {
|
||||
if (preset === "custom") return; // custom is read-only here
|
||||
const rights = kind === "calendar"
|
||||
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
|
||||
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
|
||||
if (!rights) return;
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await onShare(principalId, rights);
|
||||
toast.success(t("share_updated"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (principalId: string) => {
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await onShare(principalId, null);
|
||||
toast.success(t("share_removed"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (principal: Principal) => {
|
||||
const defaultPreset: RolePreset = "read";
|
||||
const rights = kind === "calendar"
|
||||
? CALENDAR_PRESETS[defaultPreset]
|
||||
: ADDRESS_BOOK_PRESETS[defaultPreset];
|
||||
setSavingId(principal.id);
|
||||
try {
|
||||
await onShare(principal.id, rights);
|
||||
// Move principal out of the "to add" list
|
||||
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
|
||||
setShowAdd(false);
|
||||
setSearch("");
|
||||
toast.success(t("share_added"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPrincipals = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return principals;
|
||||
return principals.filter((p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.email?.toLowerCase().includes(q) ||
|
||||
p.description?.toLowerCase().includes(q)
|
||||
);
|
||||
}, [principals, search]);
|
||||
|
||||
const sharedEntries = useMemo(() => {
|
||||
return Object.entries(shareWith || {}) as [string, AnyRights][];
|
||||
}, [shareWith]);
|
||||
|
||||
const presetOptions = kind === "calendar"
|
||||
? ["freeBusy", "read", "readWrite", "manager"] as const
|
||||
: ["read", "readWrite", "manager"] as const;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("title", { name: collectionName })}
|
||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
aria-label={tCommon("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
||||
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||
|
||||
{sharedEntries.length === 0 && !showAdd && (
|
||||
<div className="text-sm text-muted-foreground italic py-4 text-center">
|
||||
{t("no_shares")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharedEntries.length > 0 && (
|
||||
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
|
||||
{sharedEntries.map(([principalId, rights]) => {
|
||||
const principal = allPrincipalsById.get(principalId);
|
||||
const preset = kind === "calendar"
|
||||
? detectCalendarPreset(rights as CalendarRights)
|
||||
: detectAddressBookPreset(rights as AddressBookRights);
|
||||
return (
|
||||
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{principal?.name || principal?.email || principalId}
|
||||
</div>
|
||||
{principal?.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{principal.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
|
||||
disabled={savingId === principalId}
|
||||
className="appearance-none rounded-md border border-input bg-background pl-3 pr-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||
>
|
||||
{presetOptions.map((p) => (
|
||||
<option key={p} value={p}>{t(`preset.${p}`)}</option>
|
||||
))}
|
||||
{preset === "custom" && (
|
||||
<option value="custom">{t("preset.custom")}</option>
|
||||
)}
|
||||
</select>
|
||||
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(principalId)}
|
||||
disabled={savingId === principalId}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
aria-label={t("remove")}
|
||||
title={t("remove")}
|
||||
>
|
||||
{savingId === principalId
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Trash2 className="w-4 h-4" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!showAdd && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
{t("add_person")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<div className="space-y-2 border border-border rounded-md p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("search_placeholder")}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto -mx-1">
|
||||
{loadingPrincipals && (
|
||||
<div className="flex items-center justify-center py-4 text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
{t("loading_principals")}
|
||||
</div>
|
||||
)}
|
||||
{!loadingPrincipals && filteredPrincipals.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-center py-3">
|
||||
{search.trim() ? t("no_match") : t("no_principals")}
|
||||
</div>
|
||||
)}
|
||||
{!loadingPrincipals && filteredPrincipals.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleAdd(p)}
|
||||
disabled={savingId === p.id}
|
||||
className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate flex items-center gap-2">
|
||||
{p.name}
|
||||
{p.type === "group" && (
|
||||
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
|
||||
{t("group")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.email && p.email !== p.name && (
|
||||
<div className="text-xs text-muted-foreground truncate">{p.email}</div>
|
||||
)}
|
||||
</div>
|
||||
{savingId === p.id && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setSearch(""); }}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button onClick={onClose}>{tCommon("close")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,9 +140,11 @@ interface AvatarProps {
|
||||
contactPhotoUri?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
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 [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
||||
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
|
||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||
const imgSrc = !imgError && !domainFailed
|
||||
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
||||
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
|
||||
const imgSrc = disableImages
|
||||
? null
|
||||
: !imgError && !domainFailed
|
||||
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
||||
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
|
||||
|
||||
const handleImgError = useCallback(() => {
|
||||
// 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 (
|
||||
<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" />
|
||||
<circle cx="1.5" cy="1" r="0.55" 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 a0.275,0.275 0 0,0 0,-0.55 a0.275,0.275 0 0,1 0,0.55" fill="#0047A0" />
|
||||
<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.5 a0.45 0.45 0 1 1 0 -0.9 a0.45 0.45 0 1 0 0 0.9" 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
||||
import { flagComponents } from './flag-icons';
|
||||
|
||||
const languages = [
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
|
||||
@@ -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 {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const { client } = useAuthStore();
|
||||
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, mailboxes } = useEmailStore();
|
||||
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
|
||||
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
|
||||
|
||||
// Determine if this is a valid drop target
|
||||
@@ -115,8 +115,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
// Refresh the current mailbox view
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
// Refresh the current mailbox view (honors active search/filters)
|
||||
await refreshCurrentMailbox(client);
|
||||
|
||||
const mailboxPath = getMailboxPath(mailbox, mailboxes);
|
||||
|
||||
@@ -144,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
} finally {
|
||||
endDrag();
|
||||
}
|
||||
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
||||
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
||||
|
||||
const valid = isValidTarget();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
// Use static imports for better compatibility
|
||||
let messages;
|
||||
switch (locale) {
|
||||
case 'cs':
|
||||
messages = (await import('../locales/cs/common.json')).default;
|
||||
break;
|
||||
case 'fr':
|
||||
messages = (await import('../locales/fr/common.json')).default;
|
||||
break;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'as-needed';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
|
||||
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayAdmin: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
...overrides,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isValidFrameOrigin,
|
||||
sanitizeFrameOrigins,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
|
||||
describe('isValidFrameOrigin', () => {
|
||||
it('accepts plain https origins', () => {
|
||||
expect(isValidFrameOrigin('https://www.youtube-nocookie.com')).toBe(true);
|
||||
expect(isValidFrameOrigin('https://meet.example.com')).toBe(true);
|
||||
expect(isValidFrameOrigin('https://a.b.c.example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a wildcard subdomain', () => {
|
||||
expect(isValidFrameOrigin('https://*.example.com')).toBe(true);
|
||||
expect(isValidFrameOrigin('https://*.youtube.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an explicit port', () => {
|
||||
expect(isValidFrameOrigin('https://meet.example.com:8443')).toBe(true);
|
||||
expect(isValidFrameOrigin('https://*.example.com:443')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-https schemes', () => {
|
||||
expect(isValidFrameOrigin('http://example.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('ftp://example.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('data:text/html,foo')).toBe(false);
|
||||
expect(isValidFrameOrigin('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects bare schemes and wildcard hosts', () => {
|
||||
expect(isValidFrameOrigin('https://')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://*')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://*.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://localhost')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects paths, queries, and fragments', () => {
|
||||
expect(isValidFrameOrigin('https://example.com/embed')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com/')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com?x=1')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com#x')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects userinfo, IPs, and IPv6', () => {
|
||||
expect(isValidFrameOrigin('https://user:pass@example.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://1.2.3.4')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://[::1]')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects values that try to break out of the directive', () => {
|
||||
expect(isValidFrameOrigin("https://example.com'; script-src 'unsafe-eval")).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com" data:')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com data:')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com\nhttps://evil.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com;https://evil.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://exa,mple.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-strings and obvious garbage', () => {
|
||||
expect(isValidFrameOrigin(undefined)).toBe(false);
|
||||
expect(isValidFrameOrigin(null)).toBe(false);
|
||||
expect(isValidFrameOrigin(42)).toBe(false);
|
||||
expect(isValidFrameOrigin('')).toBe(false);
|
||||
expect(isValidFrameOrigin('not-a-url')).toBe(false);
|
||||
expect(isValidFrameOrigin('a'.repeat(300))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFrameOrigins', () => {
|
||||
it('returns empty for non-array input', () => {
|
||||
expect(sanitizeFrameOrigins(undefined)).toEqual([]);
|
||||
expect(sanitizeFrameOrigins(null)).toEqual([]);
|
||||
expect(sanitizeFrameOrigins('https://example.com')).toEqual([]);
|
||||
expect(sanitizeFrameOrigins({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps valid entries and drops invalid ones silently', () => {
|
||||
expect(
|
||||
sanitizeFrameOrigins([
|
||||
'https://www.youtube-nocookie.com',
|
||||
'http://insecure.com',
|
||||
'https://meet.example.com:8443',
|
||||
'https://example.com/path',
|
||||
42,
|
||||
'https://*.vimeo.com',
|
||||
]),
|
||||
).toEqual([
|
||||
'https://www.youtube-nocookie.com',
|
||||
'https://meet.example.com:8443',
|
||||
'https://*.vimeo.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('dedupes case-insensitively', () => {
|
||||
expect(
|
||||
sanitizeFrameOrigins([
|
||||
'https://Example.com',
|
||||
'https://example.com',
|
||||
'https://EXAMPLE.com',
|
||||
]),
|
||||
).toEqual(['https://Example.com']);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,8 @@ function resetStore() {
|
||||
'email-banner': [],
|
||||
'email-footer': [],
|
||||
'composer-toolbar': [],
|
||||
'composer-sidebar': [],
|
||||
'composer-sidebar-right': [],
|
||||
'sidebar-widget': [],
|
||||
'email-detail-sidebar': [],
|
||||
'settings-section': [],
|
||||
|
||||
@@ -34,11 +34,11 @@ describe("parseVCard", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
const components = result[0].name?.components || [];
|
||||
expect(components).toEqual([
|
||||
{ kind: "prefix", value: "Mr." },
|
||||
{ kind: "title", value: "Mr." },
|
||||
{ kind: "given", value: "John" },
|
||||
{ kind: "additional", value: "Michael" },
|
||||
{ kind: "given2", value: "Michael" },
|
||||
{ kind: "surname", value: "Doe" },
|
||||
{ kind: "suffix", value: "Jr." },
|
||||
{ kind: "generation", value: "Jr." },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -52,6 +52,21 @@ describe("parseVCard", () => {
|
||||
expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe");
|
||||
});
|
||||
|
||||
it("maps prefix and middle name to RFC 9553 standard kinds (issue #224)", () => {
|
||||
// N: family;given;additional;prefix;suffix (RFC 6350 order)
|
||||
const withPrefix = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;;Mr.;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
|
||||
const c1 = withPrefix[0].name?.components || [];
|
||||
expect(c1.find((c) => c.kind === "surname")?.value).toBe("Smith");
|
||||
expect(c1.find((c) => c.kind === "given")?.value).toBe("John");
|
||||
expect(c1.find((c) => c.kind === "title")?.value).toBe("Mr.");
|
||||
|
||||
const withMiddle = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;Mike;;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
|
||||
const c2 = withMiddle[0].name?.components || [];
|
||||
expect(c2.find((c) => c.kind === "surname")?.value).toBe("Smith");
|
||||
expect(c2.find((c) => c.kind === "given")?.value).toBe("John");
|
||||
expect(c2.find((c) => c.kind === "given2")?.value).toBe("Mike");
|
||||
});
|
||||
|
||||
it("parses vCard with phone, org, and address", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
@@ -204,6 +219,60 @@ describe("parseVCard", () => {
|
||||
expect(result[0].kind).toBe("group");
|
||||
});
|
||||
|
||||
it("decodes ENCODING=QUOTED-PRINTABLE values with UTF-8 charset", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:2.1",
|
||||
"N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;Hans;;;",
|
||||
"FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans M=C3=BCller",
|
||||
"NOTE;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Caf=C3=A9 stra=C3=9Fe",
|
||||
"EMAIL:hans@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result).toHaveLength(1);
|
||||
const card = result[0];
|
||||
|
||||
const components = card.name?.components || [];
|
||||
expect(components.find((c) => c.kind === "given")?.value).toBe("Hans");
|
||||
expect(components.find((c) => c.kind === "surname")?.value).toBe("Müller");
|
||||
expect(card.notes?.n0?.note).toBe("Café straße");
|
||||
});
|
||||
|
||||
it("joins QUOTED-PRINTABLE soft line breaks (= at end of line)", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:2.1",
|
||||
"FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans=20J=",
|
||||
"=C3=BCrgen=20M=C3=BCller",
|
||||
"EMAIL:hj@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result).toHaveLength(1);
|
||||
const components = result[0].name?.components || [];
|
||||
const given = components.find((c) => c.kind === "given")?.value;
|
||||
const surname = components.find((c) => c.kind === "surname")?.value;
|
||||
expect(given).toBe("Hans");
|
||||
expect(surname).toBe("Jürgen Müller");
|
||||
});
|
||||
|
||||
it("recognizes bare QUOTED-PRINTABLE encoding parameter (vCard 2.1 style)", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:2.1",
|
||||
"FN;QUOTED-PRINTABLE;CHARSET=UTF-8:Caf=C3=A9",
|
||||
"EMAIL:c@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
const components = result[0].name?.components || [];
|
||||
expect(components.find((c) => c.kind === "given")?.value).toBe("Café");
|
||||
});
|
||||
|
||||
it("parses GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, SOURCE", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
||||
|
||||
function getAdminDir(): string {
|
||||
@@ -64,6 +65,12 @@ class ConfigManager {
|
||||
if (envVal !== undefined) {
|
||||
return parseEnvValue(envVal, mapping.type) as T;
|
||||
}
|
||||
if (mapping.fileEnvVar) {
|
||||
const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
|
||||
if (fileVal !== null) {
|
||||
return parseEnvValue(fileVal, mapping.type) as T;
|
||||
}
|
||||
}
|
||||
if (defaultValue !== undefined) return defaultValue;
|
||||
return mapping.defaultValue as T;
|
||||
}
|
||||
@@ -94,9 +101,16 @@ class ConfigManager {
|
||||
const envVal = process.env[mapping.envVar];
|
||||
if (envVal !== undefined) {
|
||||
result[key] = { value: parseEnvValue(envVal, mapping.type), source: 'env' };
|
||||
} else {
|
||||
result[key] = { value: mapping.defaultValue, source: 'default' };
|
||||
continue;
|
||||
}
|
||||
if (mapping.fileEnvVar) {
|
||||
const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
|
||||
if (fileVal !== null) {
|
||||
result[key] = { value: parseEnvValue(fileVal, mapping.type), source: 'env' };
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result[key] = { value: mapping.defaultValue, source: 'default' };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Computes the union of CSP `frame-src` origins declared by installed and
|
||||
* enabled plugins. The proxy reads this on each request so that plugins can
|
||||
* embed external content (YouTube, Vimeo, Jitsi, …) without us hard-coding
|
||||
* domains in the host CSP.
|
||||
*
|
||||
* Origins are validated at install time and re-validated here as defense in
|
||||
* depth — any malformed value is dropped so a corrupted registry can never
|
||||
* inject arbitrary CSP fragments.
|
||||
*/
|
||||
|
||||
import { getPluginRegistry } from './plugin-registry';
|
||||
|
||||
// `https://host`, `https://host:port`, or `https://*.host[:port]`
|
||||
//
|
||||
// Each label is alphanumeric with optional inner dashes; the final TLD label
|
||||
// MUST start with a letter so we reject raw IPv4 literals.
|
||||
//
|
||||
// Disallowed by the regex (intentionally):
|
||||
// - any scheme other than https
|
||||
// - paths, queries, fragments
|
||||
// - userinfo, IPv4 literals, IPv6 literals (`[::1]`)
|
||||
// - bare wildcards (`https://*`)
|
||||
const FRAME_ORIGIN_RE =
|
||||
/^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i;
|
||||
|
||||
export function isValidFrameOrigin(origin: unknown): origin is string {
|
||||
if (typeof origin !== 'string') return false;
|
||||
if (origin.length > 200) return false;
|
||||
if (!FRAME_ORIGIN_RE.test(origin)) return false;
|
||||
// Reject control characters / whitespace as a final safeguard against
|
||||
// anything that would let an attacker break out of the directive.
|
||||
if (/[\s'"`;,()]/.test(origin)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises a list of candidate origins from a manifest. Drops invalid
|
||||
* entries silently and dedupes (case-insensitive on the host).
|
||||
*/
|
||||
export function sanitizeFrameOrigins(input: unknown): string[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const value of input) {
|
||||
if (!isValidFrameOrigin(value)) continue;
|
||||
const key = value.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// In-memory cache. The proxy fires on every page navigation; reading the
|
||||
// registry JSON every time is fine but cheap to skip when nothing has
|
||||
// changed. Five seconds is short enough to make plugin install/uninstall
|
||||
// feel snappy without measurable overhead.
|
||||
let cachedAt = 0;
|
||||
let cachedOrigins: string[] = [];
|
||||
const CACHE_TTL_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Returns the union of frame origins declared by every enabled plugin in
|
||||
* the server-side registry, deduped and validated.
|
||||
*
|
||||
* Returns an empty array on any failure (missing file, parse error, …) so
|
||||
* a broken registry only ever shrinks the CSP — never widens it.
|
||||
*/
|
||||
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
|
||||
|
||||
try {
|
||||
const registry = await getPluginRegistry();
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const plugin of registry.plugins) {
|
||||
if (!plugin.enabled) continue;
|
||||
const origins = sanitizeFrameOrigins(plugin.frameOrigins);
|
||||
for (const o of origins) {
|
||||
const key = o.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(o);
|
||||
}
|
||||
}
|
||||
cachedOrigins = out;
|
||||
cachedAt = now;
|
||||
return out;
|
||||
} catch {
|
||||
cachedOrigins = [];
|
||||
cachedAt = now;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Force the next call to re-read the registry. Used by install/uninstall. */
|
||||
export function invalidateFrameOriginsCache(): void {
|
||||
cachedAt = 0;
|
||||
cachedOrigins = [];
|
||||
}
|
||||
@@ -41,6 +41,11 @@ export interface ServerPlugin {
|
||||
configSchema?: Record<string, PluginConfigField>;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
/**
|
||||
* Validated CSP origins (https-only, single-origin form) the plugin may
|
||||
* embed. Merged into the host frame-src by the proxy.
|
||||
*/
|
||||
frameOrigins?: string[];
|
||||
}
|
||||
|
||||
export interface ServerTheme {
|
||||
|
||||
+3
-3
@@ -106,7 +106,7 @@ export interface AuditEntry {
|
||||
}
|
||||
|
||||
/** Config keys that map to environment variables */
|
||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
||||
@@ -124,7 +124,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
|
||||
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
|
||||
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
|
||||
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
|
||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||
@@ -134,7 +134,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
|
||||
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
|
||||
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
|
||||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||||
sessionSecret: { envVar: 'SESSION_SECRET', type: 'string', defaultValue: '' },
|
||||
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
};
|
||||
|
||||
/** Keys that should never be exposed to the client config endpoint */
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createBirthdayCalendar(name?: string, color?: string): Calendar
|
||||
mayWriteOwn: false,
|
||||
mayUpdatePrivate: false,
|
||||
mayRSVP: false,
|
||||
mayAdmin: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
};
|
||||
|
||||
+37
-1
@@ -78,6 +78,10 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
supportsCalendars(): boolean { return true; }
|
||||
supportsSieve(): boolean { return true; }
|
||||
supportsFiles(): boolean { return true; }
|
||||
supportsPrincipals(): boolean { return false; }
|
||||
async getPrincipals(): Promise<never[]> { return []; }
|
||||
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
|
||||
async setAddressBookShare(): Promise<void> { /* demo: no-op */ }
|
||||
|
||||
// ── Push / state ──────────────────────────────────────────────
|
||||
|
||||
@@ -312,6 +316,33 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
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> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
|
||||
@@ -525,6 +556,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
if (book) Object.assign(book, updates);
|
||||
}
|
||||
|
||||
async deleteAddressBook(addressBookId: string): Promise<void> {
|
||||
this.data.addressBooks = this.data.addressBooks.filter(b => b.id !== addressBookId);
|
||||
this.data.contacts = this.data.contacts.filter(c => !c.addressBookIds?.[addressBookId]);
|
||||
}
|
||||
|
||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||
return [...this.data.contacts];
|
||||
@@ -582,7 +618,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
includeInAvailability: 'all',
|
||||
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
|
||||
timeZone: null, shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||
...calendar,
|
||||
} as Calendar;
|
||||
this.data.calendars.push(full);
|
||||
|
||||
@@ -17,7 +17,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: false },
|
||||
},
|
||||
{
|
||||
id: 'demo-calendar-work',
|
||||
@@ -33,7 +33,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||
},
|
||||
{
|
||||
id: 'demo-calendar-birthdays',
|
||||
@@ -49,7 +49,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
/**
|
||||
@@ -94,6 +94,8 @@ export interface IJMAPClient {
|
||||
): Promise<void>;
|
||||
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||
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>;
|
||||
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
|
||||
|
||||
@@ -188,6 +190,7 @@ export interface IJMAPClient {
|
||||
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||
createAddressBook(name: string): Promise<AddressBook>;
|
||||
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
||||
deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void>;
|
||||
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||
getAllContacts(): Promise<ContactCard[]>;
|
||||
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
||||
@@ -225,6 +228,12 @@ export interface IJMAPClient {
|
||||
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
|
||||
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
|
||||
|
||||
// ── Sharing (RFC 9670 Principals) ─────────────────────────────
|
||||
supportsPrincipals(): boolean;
|
||||
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
|
||||
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
||||
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||
|
||||
// ── Sieve / Filters ──────────────────────────────────────────
|
||||
getSieveAccountId(): string;
|
||||
getSieveCapabilities(): SieveCapabilities | null;
|
||||
|
||||
+194
-1
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -1361,6 +1361,99 @@ export class JMAPClient implements IJMAPClient {
|
||||
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> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
@@ -2733,6 +2826,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
return this.hasCapability("urn:ietf:params:jmap:sieve");
|
||||
}
|
||||
|
||||
supportsPrincipals(): boolean {
|
||||
return this.hasCapability("urn:ietf:params:jmap:principals");
|
||||
}
|
||||
|
||||
getSieveAccountId(): string {
|
||||
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
|
||||
return sieveAccount || this.accountId;
|
||||
@@ -3105,6 +3202,102 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error("Failed to update address book");
|
||||
}
|
||||
|
||||
async deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void> {
|
||||
const accountId = targetAccountId || this.getContactsAccountId();
|
||||
const response = await this.request([
|
||||
["AddressBook/set", { accountId, destroy: [addressBookId] }, "0"],
|
||||
], this.contactUsing());
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notDestroyed?.[addressBookId]) {
|
||||
const err = result.notDestroyed[addressBookId];
|
||||
throw new Error(err.description || "Failed to delete address book");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sharing (RFC 9670) ──────────────────────────────────────────────────────
|
||||
|
||||
private principalsUsing(): string[] {
|
||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"];
|
||||
}
|
||||
|
||||
/**
|
||||
* List all principals visible to the user (RFC 9670). Stalwart returns the
|
||||
* full directory regardless of `filter`, so we fetch the whole list and let
|
||||
* callers filter client-side.
|
||||
*/
|
||||
async getPrincipals(targetAccountId?: string): Promise<Principal[]> {
|
||||
if (!this.supportsPrincipals()) return [];
|
||||
const accountId = targetAccountId || this.accountId;
|
||||
try {
|
||||
const response = await this.request([
|
||||
["Principal/query", { accountId }, "0"],
|
||||
["Principal/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "Principal/query", path: "/ids" },
|
||||
}, "1"],
|
||||
], this.principalsUsing());
|
||||
|
||||
const getResp = response.methodResponses?.find((r) => r[0] === "Principal/get");
|
||||
if (!getResp) return [];
|
||||
const list = (getResp[1].list || []) as Principal[];
|
||||
return list.map((p) => ({ ...p, accountId }));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch principals:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add, update, or remove a principal's rights on a calendar.
|
||||
* Pass `rights: null` to revoke access.
|
||||
*/
|
||||
async setCalendarShare(
|
||||
calendarId: string,
|
||||
principalId: string,
|
||||
rights: CalendarRights | null,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
const response = await this.request([
|
||||
["Calendar/set", {
|
||||
accountId,
|
||||
update: { [calendarId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, "0"],
|
||||
], this.calendarUsing());
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notUpdated?.[calendarId]) {
|
||||
const err = result.notUpdated[calendarId];
|
||||
throw new Error(err.description || "Failed to update calendar share");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add, update, or remove a principal's rights on an address book.
|
||||
* Pass `rights: null` to revoke access.
|
||||
*/
|
||||
async setAddressBookShare(
|
||||
addressBookId: string,
|
||||
principalId: string,
|
||||
rights: AddressBookRights | null,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || this.getContactsAccountId();
|
||||
const response = await this.request([
|
||||
["AddressBook/set", {
|
||||
accountId,
|
||||
update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, "0"],
|
||||
], this.contactUsing());
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notUpdated?.[addressBookId]) {
|
||||
const err = result.notUpdated[addressBookId];
|
||||
throw new Error(err.description || "Failed to update address book share");
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPaginatedContacts(
|
||||
accountId: string,
|
||||
filter?: Record<string, unknown>,
|
||||
|
||||
+15
-2
@@ -368,6 +368,7 @@ export interface AddressBook {
|
||||
isDefault?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
myRights?: AddressBookRights;
|
||||
shareWith?: Record<string, AddressBookRights> | null;
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
@@ -376,10 +377,22 @@ export interface AddressBook {
|
||||
export interface AddressBookRights {
|
||||
mayRead: boolean;
|
||||
mayWrite: boolean;
|
||||
mayShare: boolean;
|
||||
mayShare?: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
// JMAP Principals (RFC 9670)
|
||||
export interface Principal {
|
||||
id: string;
|
||||
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
email?: string | null;
|
||||
timeZone?: string | null;
|
||||
capabilities?: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export interface VacationResponse {
|
||||
id: string;
|
||||
isEnabled: boolean;
|
||||
@@ -442,7 +455,7 @@ export interface CalendarRights {
|
||||
mayWriteOwn: boolean;
|
||||
mayUpdatePrivate: boolean;
|
||||
mayRSVP: boolean;
|
||||
mayAdmin: boolean;
|
||||
mayShare: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,23 @@ import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
||||
function getClientSecret(): string {
|
||||
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
||||
if (adminSecret) return adminSecret;
|
||||
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
||||
}
|
||||
|
||||
export function getRequiredConfig() {
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||
const issuerUrl = process.env.OAUTH_ISSUER_URL;
|
||||
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||
const serverUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||
const issuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
|
||||
if (!clientId || !serverUrl) {
|
||||
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
||||
}
|
||||
const discoveryUrl = issuerUrl?.trim() || serverUrl;
|
||||
if (issuerUrl !== undefined && !issuerUrl.trim()) {
|
||||
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
|
||||
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
||||
}
|
||||
return { clientId, serverUrl, discoveryUrl };
|
||||
@@ -36,8 +41,9 @@ export async function getMetadata(): Promise<OAuthMetadata | null> {
|
||||
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
||||
const { clientId } = getRequiredConfig();
|
||||
const params = new URLSearchParams({ ...base, client_id: clientId });
|
||||
if (CLIENT_SECRET) {
|
||||
params.set('client_secret', CLIENT_SECRET);
|
||||
const secret = getClientSecret();
|
||||
if (secret) {
|
||||
params.set('client_secret', secret);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ export interface PluginAPI {
|
||||
registerSettingsSection: (section: SettingsSection) => Disposable;
|
||||
registerComposerAction: (action: ComposerAction) => Disposable;
|
||||
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
|
||||
registerComposerSidebar: (widget: SidebarWidget) => Disposable;
|
||||
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
|
||||
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
|
||||
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
|
||||
@@ -609,6 +610,12 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
||||
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||
},
|
||||
|
||||
registerComposerSidebar: (widget: SidebarWidget) => {
|
||||
requirePermission(plugin, 'ui:composer-sidebar');
|
||||
const slot = widget.side === 'right' ? 'composer-sidebar-right' : 'composer-sidebar';
|
||||
return registerSlot(plugin.id, slot, widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||
},
|
||||
|
||||
registerDetailSidebar: (widget: SidebarWidget) => {
|
||||
requirePermission(plugin, 'ui:sidebar-widget');
|
||||
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||
|
||||
+18
-1
@@ -41,6 +41,14 @@ export interface PluginManifest {
|
||||
* so plugins can use api.i18n.t() without calling addTranslations() first.
|
||||
*/
|
||||
locales?: Record<string, Record<string, string>>;
|
||||
/**
|
||||
* External origins this plugin may embed in iframes (e.g. for YouTube,
|
||||
* Vimeo, Jitsi). Each entry is a single CSP origin like
|
||||
* "https://www.youtube-nocookie.com"
|
||||
* "https://*.example.com:8443"
|
||||
* Validated at install time and merged into the host CSP `frame-src`.
|
||||
*/
|
||||
frameOrigins?: string[];
|
||||
}
|
||||
|
||||
export interface SettingFieldSchema {
|
||||
@@ -101,6 +109,8 @@ export type SlotName =
|
||||
| 'email-banner'
|
||||
| 'email-footer'
|
||||
| 'composer-toolbar'
|
||||
| 'composer-sidebar'
|
||||
| 'composer-sidebar-right'
|
||||
| 'sidebar-widget'
|
||||
| 'email-detail-sidebar'
|
||||
| 'settings-section'
|
||||
@@ -150,6 +160,12 @@ export interface SidebarWidget {
|
||||
label: string;
|
||||
render: React.ComponentType;
|
||||
order?: number;
|
||||
/**
|
||||
* For composer sidebars, choose which side of the New Message dialog the
|
||||
* panel renders on. Defaults to `'left'` for backwards compatibility.
|
||||
* Ignored by other sidebar slots.
|
||||
*/
|
||||
side?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
@@ -495,7 +511,8 @@ export const ALL_PERMISSIONS = [
|
||||
'auth:observe',
|
||||
'http:post',
|
||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
||||
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
||||
'ui:composer-toolbar', 'ui:composer-sidebar',
|
||||
'ui:sidebar-widget', 'ui:settings-section',
|
||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||
'ui:calendar-action', 'ui:admin-page',
|
||||
'admin:config',
|
||||
|
||||
+72
-11
@@ -51,6 +51,53 @@ function unfoldLines(vcf: string): string {
|
||||
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
// vCard 2.1 quoted-printable soft line breaks: a line ending in `=` continues
|
||||
// onto the next line. This is distinct from RFC 5545/6350 line folding (which
|
||||
// uses leading whitespace and is already handled in unfoldLines). Only merge
|
||||
// when the originating line declares ENCODING=QUOTED-PRINTABLE so we don't
|
||||
// accidentally splice unrelated lines.
|
||||
function joinQpSoftBreaks(lines: string[]): string[] {
|
||||
const result: string[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
let line = lines[i];
|
||||
if (/;ENCODING=QUOTED-PRINTABLE/i.test(line)) {
|
||||
while (line.endsWith("=") && i + 1 < lines.length) {
|
||||
i++;
|
||||
line = line.slice(0, -1) + lines[i];
|
||||
}
|
||||
}
|
||||
result.push(line);
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeQuotedPrintable(input: string, charset?: string): string {
|
||||
const cleaned = input.replace(/=\r?\n/g, "");
|
||||
const bytes: number[] = [];
|
||||
let i = 0;
|
||||
while (i < cleaned.length) {
|
||||
const ch = cleaned[i];
|
||||
if (ch === "=" && i + 2 < cleaned.length) {
|
||||
const hex = cleaned.substring(i + 1, i + 3);
|
||||
if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
|
||||
bytes.push(parseInt(hex, 16));
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
bytes.push(cleaned.charCodeAt(i) & 0xff);
|
||||
i += 1;
|
||||
}
|
||||
const label = (charset || "utf-8").toLowerCase();
|
||||
try {
|
||||
return new TextDecoder(label).decode(new Uint8Array(bytes));
|
||||
} catch {
|
||||
return new TextDecoder("utf-8").decode(new Uint8Array(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
function decodeValue(raw: string): string {
|
||||
return raw
|
||||
.replace(/\\n/gi, "\n")
|
||||
@@ -77,7 +124,9 @@ function parseParams(paramStr: string): Record<string, string> {
|
||||
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
|
||||
} else {
|
||||
const upper = part.toUpperCase();
|
||||
if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
|
||||
if (upper === "QUOTED-PRINTABLE" || upper === "BASE64") {
|
||||
params.ENCODING = upper;
|
||||
} else if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
|
||||
params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper;
|
||||
}
|
||||
}
|
||||
@@ -118,7 +167,7 @@ function contextToType(contexts: Record<string, boolean> | undefined): string {
|
||||
|
||||
export function parseVCard(vcfString: string): ContactCard[] {
|
||||
const text = unfoldLines(vcfString);
|
||||
const lines = text.split("\n");
|
||||
const lines = joinQpSoftBreaks(text.split("\n"));
|
||||
const contacts: ContactCard[] = [];
|
||||
let current: Record<string, string[]> | null = null;
|
||||
|
||||
@@ -163,8 +212,13 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : "";
|
||||
const params = parseParams(paramStr);
|
||||
|
||||
const isQuotedPrintable = params.ENCODING?.toUpperCase() === "QUOTED-PRINTABLE";
|
||||
|
||||
for (const rawValue of values) {
|
||||
const val = decodeValue(rawValue);
|
||||
const decoded = isQuotedPrintable
|
||||
? decodeQuotedPrintable(rawValue, params.CHARSET)
|
||||
: rawValue;
|
||||
const val = decodeValue(decoded);
|
||||
|
||||
switch (propName) {
|
||||
case "FN":
|
||||
@@ -182,13 +236,17 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
break;
|
||||
|
||||
case "N": {
|
||||
// vCard N: family;given;additional;prefix;suffix (RFC 6350 §6.2.2)
|
||||
// Mapped to JSContact-standard kinds (RFC 9553 §2.2.1):
|
||||
// prefix→title, additional→given2, suffix→generation.
|
||||
// Pushed in natural display order so `isOrdered: true` renders correctly.
|
||||
const nParts = val.split(";");
|
||||
const components: NameComponent[] = [];
|
||||
if (nParts[3]) components.push({ kind: "prefix", value: nParts[3] });
|
||||
if (nParts[3]) components.push({ kind: "title", value: nParts[3] });
|
||||
if (nParts[1]) components.push({ kind: "given", value: nParts[1] });
|
||||
if (nParts[2]) components.push({ kind: "additional", value: nParts[2] });
|
||||
if (nParts[2]) components.push({ kind: "given2", value: nParts[2] });
|
||||
if (nParts[0]) components.push({ kind: "surname", value: nParts[0] });
|
||||
if (nParts[4]) components.push({ kind: "suffix", value: nParts[4] });
|
||||
if (nParts[4]) components.push({ kind: "generation", value: nParts[4] });
|
||||
if (components.length > 0) {
|
||||
card.name = { components, isOrdered: true };
|
||||
}
|
||||
@@ -558,11 +616,14 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
}
|
||||
|
||||
const components = contact.name?.components || [];
|
||||
const given = components.find(c => c.kind === "given")?.value || "";
|
||||
const surname = components.find(c => c.kind === "surname")?.value || "";
|
||||
const prefix = components.find(c => c.kind === "prefix")?.value || "";
|
||||
const suffix = components.find(c => c.kind === "suffix")?.value || "";
|
||||
const additional = components.find(c => c.kind === "additional")?.value || "";
|
||||
const findKind = (...kinds: string[]) =>
|
||||
components.find(c => kinds.includes(c.kind))?.value || "";
|
||||
const given = findKind("given");
|
||||
const surname = findKind("surname");
|
||||
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
|
||||
const prefix = findKind("title", "prefix");
|
||||
const suffix = findKind("generation", "suffix");
|
||||
const additional = findKind("given2", "additional", "middle");
|
||||
|
||||
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
|
||||
if (fn) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+74
-2
@@ -1285,6 +1285,10 @@
|
||||
"label": "Absender-Favicons (Experimentell)",
|
||||
"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": {
|
||||
"label": "Tastaturkürzel",
|
||||
"description": "Verfügbare Tastaturkürzel anzeigen",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "{count} E-Mails ausgewählt",
|
||||
"edit_draft": "Entwurf bearbeiten"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Ordner als gelesen markieren",
|
||||
"mark_folder_tree_read": "Ordner & Unterordner als gelesen markieren",
|
||||
"mark_all_folders_read": "Alle Ordner als gelesen markieren",
|
||||
"new_subfolder": "Neuer Unterordner...",
|
||||
"new_folder": "Neuer Ordner...",
|
||||
"rename": "Umbenennen...",
|
||||
"empty_folder": "Ordner leeren",
|
||||
"empty_folder_generic": "Ordner leeren",
|
||||
"delete_folder": "Ordner löschen",
|
||||
"refresh": "Aktualisieren",
|
||||
"mark_all_confirm_title": "Alle Ordner als gelesen markieren",
|
||||
"mark_all_confirm_message": "Jede ungelesene Nachricht in deinem persönlichen Konto als gelesen markieren?",
|
||||
"delete_confirm_title": "Ordner löschen",
|
||||
"delete_confirm_message": "Den Ordner \"{name}\" dauerhaft löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "Tastaturkürzel",
|
||||
"tip": "Drücken Sie ? jederzeit, um diese Hilfe anzuzeigen",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "Adressbuch umbenannt",
|
||||
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
|
||||
"default": "Standard",
|
||||
"manage": "Adressbücher verwalten"
|
||||
"manage": "Adressbücher verwalten",
|
||||
"share": "Adressbuch freigeben",
|
||||
"new_contact_in_book": "Neuer Kontakt in diesem Adressbuch",
|
||||
"delete": "Adressbuch löschen",
|
||||
"confirm_delete": "„{name}\" löschen? Alle Kontakte in diesem Adressbuch werden entfernt.",
|
||||
"deleted": "Adressbuch gelöscht",
|
||||
"delete_failed": "Adressbuch konnte nicht gelöscht werden"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-Mail-Adressen",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"clear_events": "Ereignisse löschen",
|
||||
"events_cleared": "{count} Ereignisse gelöscht",
|
||||
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden"
|
||||
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden",
|
||||
"share": "Kalender freigeben",
|
||||
"new_event_in_calendar": "Neuer Termin in diesem Kalender"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal-Abonnement",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "„{name}\" freigeben",
|
||||
"description": "Anderen Benutzern oder Gruppen auf diesem Server Zugriff gewähren. Änderungen werden sofort wirksam.",
|
||||
"no_shares": "Noch nicht freigegeben.",
|
||||
"add_person": "Person oder Gruppe hinzufügen",
|
||||
"search_placeholder": "Nach Name oder E-Mail suchen…",
|
||||
"loading_principals": "Benutzer werden geladen…",
|
||||
"no_principals": "Keine weiteren Benutzer oder Gruppen gefunden.",
|
||||
"no_match": "Keine Treffer.",
|
||||
"remove": "Zugriff entfernen",
|
||||
"group": "Gruppe",
|
||||
"share_added": "Zugriff erteilt",
|
||||
"share_updated": "Zugriff aktualisiert",
|
||||
"share_removed": "Zugriff entfernt",
|
||||
"share_failed": "Freigabe konnte nicht aktualisiert werden",
|
||||
"preset": {
|
||||
"freeBusy": "Nur Frei/Belegt",
|
||||
"read": "Nur lesen",
|
||||
"readWrite": "Lesen & schreiben",
|
||||
"manager": "Verwalten",
|
||||
"custom": "Benutzerdefiniert"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-2
@@ -1287,6 +1287,10 @@
|
||||
"label": "Sender Favicons (Experimental)",
|
||||
"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": {
|
||||
"label": "Keyboard Shortcuts",
|
||||
"description": "View available keyboard shortcuts",
|
||||
@@ -1590,6 +1594,43 @@
|
||||
"items_selected": "{count} emails selected",
|
||||
"edit_draft": "Edit Draft"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"tip": "Press ? anytime to show this help",
|
||||
@@ -1778,7 +1819,13 @@
|
||||
"renamed": "Address book renamed",
|
||||
"rename_failed": "Failed to rename address book",
|
||||
"default": "Default",
|
||||
"manage": "Manage address books"
|
||||
"manage": "Manage address books",
|
||||
"share": "Share address book",
|
||||
"new_contact_in_book": "New contact in this address book",
|
||||
"delete": "Delete address book",
|
||||
"confirm_delete": "Delete \"{name}\"? All contacts in this address book will be removed.",
|
||||
"deleted": "Address book deleted",
|
||||
"delete_failed": "Failed to delete address book"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Email Addresses",
|
||||
@@ -2303,7 +2350,9 @@
|
||||
"error_delete": "Failed to delete calendar",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "Copy CalDAV URL",
|
||||
"url_copied": "CalDAV URL copied to clipboard"
|
||||
"url_copied": "CalDAV URL copied to clipboard",
|
||||
"share": "Share calendar",
|
||||
"new_event_in_calendar": "New event in this calendar"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal Subscription",
|
||||
@@ -2385,6 +2434,29 @@
|
||||
"overdue": "Overdue"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Share \"{name}\"",
|
||||
"description": "Grant access to other users or groups on this server. Changes take effect immediately.",
|
||||
"no_shares": "Not shared with anyone yet.",
|
||||
"add_person": "Add person or group",
|
||||
"search_placeholder": "Search by name or email…",
|
||||
"loading_principals": "Loading users…",
|
||||
"no_principals": "No other users or groups found.",
|
||||
"no_match": "No matches.",
|
||||
"remove": "Remove access",
|
||||
"group": "Group",
|
||||
"share_added": "Access granted",
|
||||
"share_updated": "Access updated",
|
||||
"share_removed": "Access removed",
|
||||
"share_failed": "Failed to update sharing",
|
||||
"preset": {
|
||||
"freeBusy": "Free/busy only",
|
||||
"read": "Read only",
|
||||
"readWrite": "Read & write",
|
||||
"manager": "Manager",
|
||||
"custom": "Custom"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Advanced Search",
|
||||
"from": "From",
|
||||
|
||||
+74
-2
@@ -1285,6 +1285,10 @@
|
||||
"label": "Favicons de remitente (Experimental)",
|
||||
"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": {
|
||||
"label": "Atajos de Teclado",
|
||||
"description": "Ver atajos de teclado disponibles",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "{count} correos seleccionados",
|
||||
"edit_draft": "Editar borrador"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "Atajos de Teclado",
|
||||
"tip": "Presione ? en cualquier momento para mostrar esta ayuda",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "Libreta de direcciones renombrada",
|
||||
"rename_failed": "Error al renombrar la libreta de direcciones",
|
||||
"default": "Predeterminada",
|
||||
"manage": "Administrar libretas de direcciones"
|
||||
"manage": "Administrar libretas de direcciones",
|
||||
"share": "Compartir libreta de direcciones",
|
||||
"new_contact_in_book": "Nuevo contacto en esta libreta",
|
||||
"delete": "Eliminar libreta de direcciones",
|
||||
"confirm_delete": "¿Eliminar «{name}»? Todos los contactos de esta libreta se eliminarán.",
|
||||
"deleted": "Libreta de direcciones eliminada",
|
||||
"delete_failed": "No se pudo eliminar la libreta de direcciones"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Direcciones de correo",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.",
|
||||
"clear_events": "Borrar eventos",
|
||||
"events_cleared": "{count} eventos borrados",
|
||||
"error_clear": "No se pudieron borrar los eventos del calendario"
|
||||
"error_clear": "No se pudieron borrar los eventos del calendario",
|
||||
"share": "Compartir calendario",
|
||||
"new_event_in_calendar": "Nuevo evento en este calendario"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Suscripción iCal",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "La búsqueda no está disponible en la vista unificada"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Compartir «{name}»",
|
||||
"description": "Concede acceso a otros usuarios o grupos de este servidor. Los cambios surten efecto inmediatamente.",
|
||||
"no_shares": "Aún no se ha compartido con nadie.",
|
||||
"add_person": "Añadir persona o grupo",
|
||||
"search_placeholder": "Buscar por nombre o correo…",
|
||||
"loading_principals": "Cargando usuarios…",
|
||||
"no_principals": "No se han encontrado otros usuarios ni grupos.",
|
||||
"no_match": "Sin resultados.",
|
||||
"remove": "Quitar acceso",
|
||||
"group": "Grupo",
|
||||
"share_added": "Acceso concedido",
|
||||
"share_updated": "Acceso actualizado",
|
||||
"share_removed": "Acceso retirado",
|
||||
"share_failed": "No se pudo actualizar el uso compartido",
|
||||
"preset": {
|
||||
"freeBusy": "Solo disponibilidad",
|
||||
"read": "Solo lectura",
|
||||
"readWrite": "Lectura y escritura",
|
||||
"manager": "Administrador",
|
||||
"custom": "Personalizado"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-2
@@ -1285,6 +1285,10 @@
|
||||
"label": "Favicons des expéditeurs (Expérimental)",
|
||||
"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": {
|
||||
"label": "Raccourcis clavier",
|
||||
"description": "Voir les raccourcis clavier disponibles",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "{count} emails sélectionnés",
|
||||
"edit_draft": "Modifier le brouillon"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "Raccourcis clavier",
|
||||
"tip": "Appuyez sur ? à tout moment pour afficher cette aide",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "Carnet d'adresses renommé",
|
||||
"rename_failed": "Échec du renommage du carnet d'adresses",
|
||||
"default": "Par défaut",
|
||||
"manage": "Gérer les carnets d'adresses"
|
||||
"manage": "Gérer les carnets d'adresses",
|
||||
"share": "Partager le carnet d'adresses",
|
||||
"new_contact_in_book": "Nouveau contact dans ce carnet d'adresses",
|
||||
"delete": "Supprimer le carnet d'adresses",
|
||||
"confirm_delete": "Supprimer « {name} » ? Tous les contacts de ce carnet d'adresses seront supprimés.",
|
||||
"deleted": "Carnet d'adresses supprimé",
|
||||
"delete_failed": "Échec de la suppression du carnet d'adresses"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Adresses e-mail",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.",
|
||||
"clear_events": "Supprimer les événements",
|
||||
"events_cleared": "{count} événements supprimés",
|
||||
"error_clear": "Impossible de supprimer les événements du calendrier"
|
||||
"error_clear": "Impossible de supprimer les événements du calendrier",
|
||||
"share": "Partager le calendrier",
|
||||
"new_event_in_calendar": "Nouvel événement dans ce calendrier"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Abonnement iCal",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "La recherche n'est pas disponible dans la vue unifiée"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Partager « {name} »",
|
||||
"description": "Accordez l'accès à d'autres utilisateurs ou groupes de ce serveur. Les modifications sont immédiates.",
|
||||
"no_shares": "Pas encore partagé.",
|
||||
"add_person": "Ajouter une personne ou un groupe",
|
||||
"search_placeholder": "Rechercher par nom ou e-mail…",
|
||||
"loading_principals": "Chargement des utilisateurs…",
|
||||
"no_principals": "Aucun autre utilisateur ou groupe trouvé.",
|
||||
"no_match": "Aucun résultat.",
|
||||
"remove": "Révoquer l'accès",
|
||||
"group": "Groupe",
|
||||
"share_added": "Accès accordé",
|
||||
"share_updated": "Accès mis à jour",
|
||||
"share_removed": "Accès révoqué",
|
||||
"share_failed": "Échec de la mise à jour du partage",
|
||||
"preset": {
|
||||
"freeBusy": "Disponibilité uniquement",
|
||||
"read": "Lecture seule",
|
||||
"readWrite": "Lecture & écriture",
|
||||
"manager": "Gestionnaire",
|
||||
"custom": "Personnalisé"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-2
@@ -1285,6 +1285,10 @@
|
||||
"label": "Favicon dei mittenti (Sperimentale)",
|
||||
"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": {
|
||||
"label": "Scorciatoie da tastiera",
|
||||
"description": "Visualizza le scorciatoie da tastiera disponibili",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "{count} messaggi selezionati",
|
||||
"edit_draft": "Modifica bozza"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "Scorciatoie da tastiera",
|
||||
"tip": "Premi ? in qualsiasi momento per mostrare questo aiuto",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "Rubrica rinominata",
|
||||
"rename_failed": "Impossibile rinominare la rubrica",
|
||||
"default": "Predefinita",
|
||||
"manage": "Gestisci rubriche"
|
||||
"manage": "Gestisci rubriche",
|
||||
"share": "Condividi rubrica",
|
||||
"new_contact_in_book": "Nuovo contatto in questa rubrica",
|
||||
"delete": "Elimina rubrica",
|
||||
"confirm_delete": "Eliminare \"{name}\"? Tutti i contatti in questa rubrica verranno rimossi.",
|
||||
"deleted": "Rubrica eliminata",
|
||||
"delete_failed": "Impossibile eliminare la rubrica"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Indirizzi email",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.",
|
||||
"clear_events": "Cancella eventi",
|
||||
"events_cleared": "{count} eventi cancellati",
|
||||
"error_clear": "Impossibile cancellare gli eventi del calendario"
|
||||
"error_clear": "Impossibile cancellare gli eventi del calendario",
|
||||
"share": "Condividi calendario",
|
||||
"new_event_in_calendar": "Nuovo evento in questo calendario"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Abbonamento iCal",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "La ricerca non è disponibile nella vista unificata"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Condividi \"{name}\"",
|
||||
"description": "Concedi l'accesso ad altri utenti o gruppi su questo server. Le modifiche hanno effetto immediato.",
|
||||
"no_shares": "Non ancora condiviso.",
|
||||
"add_person": "Aggiungi persona o gruppo",
|
||||
"search_placeholder": "Cerca per nome o email…",
|
||||
"loading_principals": "Caricamento utenti…",
|
||||
"no_principals": "Nessun altro utente o gruppo trovato.",
|
||||
"no_match": "Nessun risultato.",
|
||||
"remove": "Rimuovi accesso",
|
||||
"group": "Gruppo",
|
||||
"share_added": "Accesso concesso",
|
||||
"share_updated": "Accesso aggiornato",
|
||||
"share_removed": "Accesso rimosso",
|
||||
"share_failed": "Impossibile aggiornare la condivisione",
|
||||
"preset": {
|
||||
"freeBusy": "Solo libero/occupato",
|
||||
"read": "Sola lettura",
|
||||
"readWrite": "Lettura e scrittura",
|
||||
"manager": "Gestore",
|
||||
"custom": "Personalizzato"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-2
@@ -1285,6 +1285,10 @@
|
||||
"label": "送信者ファビコン(実験的)",
|
||||
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "迷惑メールフォルダでアバターを表示",
|
||||
"description": "迷惑メールフォルダの送信者にプロフィール画像とファビコンを表示します。フィッシング詐欺に正規のような見た目を与えないため、既定では無効です。"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"label": "キーボードショートカット",
|
||||
"description": "利用可能なキーボードショートカットを表示",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "{count}件のメールを選択",
|
||||
"edit_draft": "下書きを編集"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "キーボードショートカット",
|
||||
"tip": "? キーを押すといつでもこのヘルプを表示できます",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "アドレス帳の名前を変更しました",
|
||||
"rename_failed": "アドレス帳の名前変更に失敗しました",
|
||||
"default": "デフォルト",
|
||||
"manage": "アドレス帳を管理"
|
||||
"manage": "アドレス帳を管理",
|
||||
"share": "アドレス帳を共有",
|
||||
"new_contact_in_book": "このアドレス帳に新規連絡先",
|
||||
"delete": "アドレス帳を削除",
|
||||
"confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。",
|
||||
"deleted": "アドレス帳を削除しました",
|
||||
"delete_failed": "アドレス帳の削除に失敗しました"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "メールアドレス",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。",
|
||||
"clear_events": "イベントを削除",
|
||||
"events_cleared": "{count}件のイベントを削除しました",
|
||||
"error_clear": "カレンダーイベントの削除に失敗しました"
|
||||
"error_clear": "カレンダーイベントの削除に失敗しました",
|
||||
"share": "カレンダーを共有",
|
||||
"new_event_in_calendar": "このカレンダーに新規イベント"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal購読",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "統合ビューでは検索を利用できません"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "「{name}」を共有",
|
||||
"description": "このサーバー上の他のユーザーまたはグループにアクセス権を付与します。変更はすぐに反映されます。",
|
||||
"no_shares": "まだ誰にも共有されていません。",
|
||||
"add_person": "ユーザーまたはグループを追加",
|
||||
"search_placeholder": "名前またはメールで検索…",
|
||||
"loading_principals": "ユーザーを読み込み中…",
|
||||
"no_principals": "他のユーザーまたはグループは見つかりません。",
|
||||
"no_match": "一致する項目がありません。",
|
||||
"remove": "アクセス権を削除",
|
||||
"group": "グループ",
|
||||
"share_added": "アクセス権を付与しました",
|
||||
"share_updated": "アクセス権を更新しました",
|
||||
"share_removed": "アクセス権を削除しました",
|
||||
"share_failed": "共有の更新に失敗しました",
|
||||
"preset": {
|
||||
"freeBusy": "空き時間情報のみ",
|
||||
"read": "読み取り専用",
|
||||
"readWrite": "読み取り・書き込み",
|
||||
"manager": "管理者",
|
||||
"custom": "カスタム"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-2
@@ -1285,6 +1285,10 @@
|
||||
"label": "보낸 사람 파비콘 (실험적 기능)",
|
||||
"description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "스팸함에서 아바타 표시",
|
||||
"description": "스팸함의 보낸 사람에 대한 프로필 이미지와 파비콘을 표시해요. 피싱 메일이 정상적인 메일처럼 보이지 않도록 기본적으로 꺼져 있어요."
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"label": "단축키",
|
||||
"description": "사용 가능한 키보드 단축키를 확인해 보세요",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "{count}개의 메일 선택됨",
|
||||
"edit_draft": "임시보관 메일 수정"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "단축키",
|
||||
"tip": "언제든 ? 키를 누르면 이 도움말을 볼 수 있어요",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "주소록 이름이 변경되었습니다",
|
||||
"rename_failed": "주소록 이름 변경 실패",
|
||||
"default": "기본",
|
||||
"manage": "주소록 관리"
|
||||
"manage": "주소록 관리",
|
||||
"share": "주소록 공유",
|
||||
"new_contact_in_book": "이 주소록에 새 연락처",
|
||||
"delete": "주소록 삭제",
|
||||
"confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.",
|
||||
"deleted": "주소록이 삭제되었습니다",
|
||||
"delete_failed": "주소록 삭제에 실패했습니다"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "이메일",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"error_delete": "캘린더를 삭제하지 못했어요",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "CalDAV URL 복사",
|
||||
"url_copied": "CalDAV URL이 클립보드에 복사되었어요"
|
||||
"url_copied": "CalDAV URL이 클립보드에 복사되었어요",
|
||||
"share": "캘린더 공유",
|
||||
"new_event_in_calendar": "이 캘린더에 새 일정"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal 구독",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "\"{name}\" 공유",
|
||||
"description": "이 서버의 다른 사용자나 그룹에 액세스 권한을 부여합니다. 변경 사항은 즉시 적용됩니다.",
|
||||
"no_shares": "아직 공유되지 않았습니다.",
|
||||
"add_person": "사용자 또는 그룹 추가",
|
||||
"search_placeholder": "이름 또는 이메일로 검색…",
|
||||
"loading_principals": "사용자 불러오는 중…",
|
||||
"no_principals": "다른 사용자나 그룹을 찾을 수 없습니다.",
|
||||
"no_match": "일치하는 항목이 없습니다.",
|
||||
"remove": "액세스 권한 제거",
|
||||
"group": "그룹",
|
||||
"share_added": "액세스 권한이 부여되었습니다",
|
||||
"share_updated": "액세스 권한이 업데이트되었습니다",
|
||||
"share_removed": "액세스 권한이 제거되었습니다",
|
||||
"share_failed": "공유 업데이트에 실패했습니다",
|
||||
"preset": {
|
||||
"freeBusy": "한가함/바쁨만",
|
||||
"read": "읽기 전용",
|
||||
"readWrite": "읽기 및 쓰기",
|
||||
"manager": "관리자",
|
||||
"custom": "사용자 지정"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-2
@@ -1266,6 +1266,10 @@
|
||||
"label": "Sūtītāju ikonas (Eksperimentāli)",
|
||||
"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": {
|
||||
"label": "Īsinājumtaustiņi",
|
||||
"description": "Skatīt pieejamos tastatūras īsinājumtaustiņus",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "{count} vēstules atlasītas",
|
||||
"edit_draft": "Rediģēt melnrakstu"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "Īsinājumtaustiņi",
|
||||
"tip": "Nospiediet ? jebkurā laikā, lai skatītu palīdzību",
|
||||
@@ -1770,7 +1811,13 @@
|
||||
"renamed": "Adrešu grāmata pārdēvēta",
|
||||
"rename_failed": "Neizdevās pārdēvēt adrešu grāmatu",
|
||||
"default": "Noklusējuma",
|
||||
"manage": "Pārvaldīt adrešu grāmatas"
|
||||
"manage": "Pārvaldīt adrešu grāmatas",
|
||||
"share": "Kopīgot adrešu grāmatu",
|
||||
"new_contact_in_book": "Jauns kontakts šajā adrešu grāmatā",
|
||||
"delete": "Dzēst adrešu grāmatu",
|
||||
"confirm_delete": "Dzēst \"{name}\"? Visi kontakti šajā adrešu grāmatā tiks noņemti.",
|
||||
"deleted": "Adrešu grāmata dzēsta",
|
||||
"delete_failed": "Neizdevās dzēst adrešu grāmatu"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-pasta adreses",
|
||||
@@ -2298,7 +2345,9 @@
|
||||
"error_delete": "Neizdevās izdzēst kalendāru",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "Kopēt CalDAV URL",
|
||||
"url_copied": "CalDAV URL nokopēts starpliktuvē"
|
||||
"url_copied": "CalDAV URL nokopēts starpliktuvē",
|
||||
"share": "Kopīgot kalendāru",
|
||||
"new_event_in_calendar": "Jauns notikums šajā kalendārā"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal abonements",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Meklēšana nav pieejama apvienotajā skatā"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Kopīgot \"{name}\"",
|
||||
"description": "Piešķiriet piekļuvi citiem lietotājiem vai grupām šajā serverī. Izmaiņas stājas spēkā nekavējoties.",
|
||||
"no_shares": "Vēl nav kopīgots.",
|
||||
"add_person": "Pievienot personu vai grupu",
|
||||
"search_placeholder": "Meklēt pēc vārda vai e-pasta…",
|
||||
"loading_principals": "Ielādē lietotājus…",
|
||||
"no_principals": "Citi lietotāji vai grupas nav atrastas.",
|
||||
"no_match": "Nav atbilstību.",
|
||||
"remove": "Noņemt piekļuvi",
|
||||
"group": "Grupa",
|
||||
"share_added": "Piekļuve piešķirta",
|
||||
"share_updated": "Piekļuve atjaunināta",
|
||||
"share_removed": "Piekļuve noņemta",
|
||||
"share_failed": "Neizdevās atjaunināt kopīgošanu",
|
||||
"preset": {
|
||||
"freeBusy": "Tikai brīvs/aizņemts",
|
||||
"read": "Tikai lasīšana",
|
||||
"readWrite": "Lasīšana un rakstīšana",
|
||||
"manager": "Pārvaldnieks",
|
||||
"custom": "Pielāgots"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-2
@@ -1285,6 +1285,10 @@
|
||||
"label": "Afzender-favicons (Experimenteel)",
|
||||
"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": {
|
||||
"label": "Sneltoetsen",
|
||||
"description": "Bekijk beschikbare sneltoetsen",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "{count} e-mails geselecteerd",
|
||||
"edit_draft": "Concept bewerken"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "Sneltoetsen",
|
||||
"tip": "Druk op ? om deze hulp te tonen",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "Adresboek hernoemd",
|
||||
"rename_failed": "Adresboek hernoemen mislukt",
|
||||
"default": "Standaard",
|
||||
"manage": "Adresboeken beheren"
|
||||
"manage": "Adresboeken beheren",
|
||||
"share": "Adresboek delen",
|
||||
"new_contact_in_book": "Nieuw contact in dit adresboek",
|
||||
"delete": "Adresboek verwijderen",
|
||||
"confirm_delete": "\"{name}\" verwijderen? Alle contacten in dit adresboek worden verwijderd.",
|
||||
"deleted": "Adresboek verwijderd",
|
||||
"delete_failed": "Adresboek kon niet worden verwijderd"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-mailadressen",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"clear_events": "Afspraken verwijderen",
|
||||
"events_cleared": "{count} afspraken verwijderd",
|
||||
"error_clear": "Kan agendagebeurtenissen niet verwijderen"
|
||||
"error_clear": "Kan agendagebeurtenissen niet verwijderen",
|
||||
"share": "Agenda delen",
|
||||
"new_event_in_calendar": "Nieuwe afspraak in deze agenda"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal-abonnement",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "\"{name}\" delen",
|
||||
"description": "Geef andere gebruikers of groepen op deze server toegang. Wijzigingen zijn direct van kracht.",
|
||||
"no_shares": "Nog niet gedeeld.",
|
||||
"add_person": "Persoon of groep toevoegen",
|
||||
"search_placeholder": "Zoeken op naam of e-mail…",
|
||||
"loading_principals": "Gebruikers laden…",
|
||||
"no_principals": "Geen andere gebruikers of groepen gevonden.",
|
||||
"no_match": "Geen overeenkomsten.",
|
||||
"remove": "Toegang intrekken",
|
||||
"group": "Groep",
|
||||
"share_added": "Toegang verleend",
|
||||
"share_updated": "Toegang bijgewerkt",
|
||||
"share_removed": "Toegang ingetrokken",
|
||||
"share_failed": "Delen kon niet worden bijgewerkt",
|
||||
"preset": {
|
||||
"freeBusy": "Alleen vrij/bezet",
|
||||
"read": "Alleen lezen",
|
||||
"readWrite": "Lezen en schrijven",
|
||||
"manager": "Beheerder",
|
||||
"custom": "Aangepast"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-2
@@ -1285,6 +1285,10 @@
|
||||
"label": "Favikony nadawców (eksperymentalne)",
|
||||
"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": {
|
||||
"label": "Skróty klawiszowe",
|
||||
"description": "Wyświetl dostępne skróty klawiszowe",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "{count} zaznaczonych wiadomości",
|
||||
"edit_draft": "Edytuj szkic"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "Skróty klawiszowe",
|
||||
"tip": "Naciśnij ? w dowolnym momencie, aby wyświetlić tę pomoc",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "Zmieniono nazwę książki adresowej",
|
||||
"rename_failed": "Nie udało się zmienić nazwy książki adresowej",
|
||||
"default": "Domyślna",
|
||||
"manage": "Zarządzaj książkami adresowymi"
|
||||
"manage": "Zarządzaj książkami adresowymi",
|
||||
"share": "Udostępnij książkę adresową",
|
||||
"new_contact_in_book": "Nowy kontakt w tej książce adresowej",
|
||||
"delete": "Usuń książkę adresową",
|
||||
"confirm_delete": "Usunąć „{name}\"? Wszystkie kontakty w tej książce adresowej zostaną usunięte.",
|
||||
"deleted": "Książka adresowa usunięta",
|
||||
"delete_failed": "Nie udało się usunąć książki adresowej"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Adresy e-mail",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"error_delete": "Nie udało się usunąć kalendarza",
|
||||
"caldav_url": "Adres URL CalDAV",
|
||||
"copy_url": "Kopiuj adres URL CalDAV",
|
||||
"url_copied": "Adres URL CalDAV skopiowano do schowka"
|
||||
"url_copied": "Adres URL CalDAV skopiowano do schowka",
|
||||
"share": "Udostępnij kalendarz",
|
||||
"new_event_in_calendar": "Nowe wydarzenie w tym kalendarzu"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Subskrypcja iCal",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Udostępnij „{name}\"",
|
||||
"description": "Udziel dostępu innym użytkownikom lub grupom na tym serwerze. Zmiany są natychmiastowe.",
|
||||
"no_shares": "Jeszcze nie udostępniono.",
|
||||
"add_person": "Dodaj osobę lub grupę",
|
||||
"search_placeholder": "Szukaj po imieniu lub e-mailu…",
|
||||
"loading_principals": "Ładowanie użytkowników…",
|
||||
"no_principals": "Nie znaleziono innych użytkowników ani grup.",
|
||||
"no_match": "Brak wyników.",
|
||||
"remove": "Usuń dostęp",
|
||||
"group": "Grupa",
|
||||
"share_added": "Dostęp przyznany",
|
||||
"share_updated": "Dostęp zaktualizowany",
|
||||
"share_removed": "Dostęp usunięty",
|
||||
"share_failed": "Nie udało się zaktualizować udostępniania",
|
||||
"preset": {
|
||||
"freeBusy": "Tylko dostępność",
|
||||
"read": "Tylko do odczytu",
|
||||
"readWrite": "Odczyt i zapis",
|
||||
"manager": "Menedżer",
|
||||
"custom": "Niestandardowe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+109
-37
@@ -20,7 +20,7 @@
|
||||
"generic": "Ocorreu um erro. Por favor, tente novamente.",
|
||||
"totp_required": "É necessário um código de autenticação de dois fatores. Insira seu código abaixo.",
|
||||
"totp_invalid": "Código de autenticação inválido. Verifique seu aplicativo de autenticação.",
|
||||
"oauth_discovery_failed": "SSO está ativado mas o provedor de identidade não pôde ser contactado. Verifique sua configuração OAuth."
|
||||
"oauth_discovery_failed": "SSO está ativado mas o provedor de identidade não pôde ser contatado. Verifique sua configuração OAuth."
|
||||
},
|
||||
"show_password": "Mostrar senha",
|
||||
"hide_password": "Ocultar senha",
|
||||
@@ -80,7 +80,7 @@
|
||||
"calendar": "Calendário",
|
||||
"settings": "Configurações",
|
||||
"admin": "Admin",
|
||||
"files": "Ficheiros",
|
||||
"files": "Arquivos",
|
||||
"loading_mailboxes": "Carregando caixas de entrada...",
|
||||
"push_connected": "Atualizações em tempo real ativas",
|
||||
"push_disconnected": "Atualizações em tempo real inativas",
|
||||
@@ -505,7 +505,7 @@
|
||||
"show_less": "Mostrar menos",
|
||||
"forgot_attachment": {
|
||||
"title": "Esqueceu um anexo?",
|
||||
"message": "A sua mensagem menciona \"{keyword}\" mas nenhum ficheiro está anexado. Enviar mesmo assim?",
|
||||
"message": "A sua mensagem menciona \"{keyword}\" mas nenhum arquivo está anexado. Enviar mesmo assim?",
|
||||
"send_anyway": "Enviar mesmo assim",
|
||||
"back": "Voltar à edição"
|
||||
}
|
||||
@@ -669,7 +669,7 @@
|
||||
"security": "Segurança",
|
||||
"encryption": "Criptografia",
|
||||
"files": "Arquivos",
|
||||
"contacts": "Contactos",
|
||||
"contacts": "Contatos",
|
||||
"sidebar_apps": "Apps da barra lateral",
|
||||
"notifications": "Notificações",
|
||||
"layout": "Layout",
|
||||
@@ -1285,6 +1285,10 @@
|
||||
"label": "Favicons de remetente (Experimental)",
|
||||
"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": {
|
||||
"label": "Atalhos de Teclado",
|
||||
"description": "Visualizar atalhos de teclado disponíveis",
|
||||
@@ -1318,12 +1322,12 @@
|
||||
"manage_description": "Adicionar, editar ou remover apps personalizados da barra lateral"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contactos",
|
||||
"description": "Importar e exportar os seus contactos",
|
||||
"import_label": "Importar contactos",
|
||||
"import_description": "Importar contactos de um ficheiro vCard (.vcf)",
|
||||
"export_label": "Exportar contactos",
|
||||
"export_description": "Exportar todos os contactos como ficheiro vCard (.vcf)",
|
||||
"title": "Contatos",
|
||||
"description": "Importar e exportar os seus contatos",
|
||||
"import_label": "Importar contatos",
|
||||
"import_description": "Importar contatos de um arquivo vCard (.vcf)",
|
||||
"export_label": "Exportar contatos",
|
||||
"export_description": "Exportar todos os contatos como arquivo vCard (.vcf)",
|
||||
"manage_title": "Catálogos de endereços",
|
||||
"manage_description": "Renomeie seus catálogos de endereços",
|
||||
"no_address_books": "Nenhum catálogo de endereços encontrado",
|
||||
@@ -1499,7 +1503,7 @@
|
||||
},
|
||||
"folder_layout": {
|
||||
"label": "Navegação de pastas",
|
||||
"description": "Escolha como as pastas são apresentadas: integradas com os ficheiros ou numa árvore na barra lateral",
|
||||
"description": "Escolha como as pastas são apresentadas: integradas com os arquivos ou numa árvore na barra lateral",
|
||||
"inline": "Integrado",
|
||||
"sidebar": "Barra lateral"
|
||||
},
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "{count} e-mails selecionados",
|
||||
"edit_draft": "Editar rascunho"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "Atalhos de Teclado",
|
||||
"tip": "Pressione ? a qualquer momento para mostrar esta ajuda",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "Catálogo de endereços renomeado",
|
||||
"rename_failed": "Falha ao renomear o catálogo de endereços",
|
||||
"default": "Padrão",
|
||||
"manage": "Gerenciar catálogos de endereços"
|
||||
"manage": "Gerenciar catálogos de endereços",
|
||||
"share": "Compartilhar lista de contatos",
|
||||
"new_contact_in_book": "Novo contato nesta lista",
|
||||
"delete": "Excluir lista de contatos",
|
||||
"confirm_delete": "Excluir \"{name}\"? Todos os contatos desta lista serão removidos.",
|
||||
"deleted": "Lista de contatos excluída",
|
||||
"delete_failed": "Falha ao excluir a lista de contatos"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Endereços de e-mail",
|
||||
@@ -1827,7 +1874,7 @@
|
||||
"cert_already_imported": "Certificado já importado",
|
||||
"cert_imported": "Certificado importado",
|
||||
"cert_import_failed": "Falha ao importar o certificado",
|
||||
"section_contact": "Contact details",
|
||||
"section_contact": "Detalhes do contato",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
@@ -2196,7 +2243,7 @@
|
||||
"hover_preview_delay_2s": "Atraso de 2 segundos",
|
||||
"hover_preview_off": "Desativado",
|
||||
"show_birthday_calendar": "Calendário de aniversários",
|
||||
"show_birthday_calendar_desc": "Mostrar um calendário virtual com os aniversários dos seus contactos"
|
||||
"show_birthday_calendar_desc": "Mostrar um calendário virtual com os aniversários dos seus contatos"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Segunda-feira",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.",
|
||||
"clear_events": "Limpar eventos",
|
||||
"events_cleared": "{count} eventos removidos",
|
||||
"error_clear": "Falha ao limpar os eventos do calendário"
|
||||
"error_clear": "Falha ao limpar os eventos do calendário",
|
||||
"share": "Compartilhar calendário",
|
||||
"new_event_in_calendar": "Novo evento neste calendário"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Assinatura iCal",
|
||||
@@ -2432,12 +2481,12 @@
|
||||
"hint": "Clique num e-mail à esquerda para começar, ou inicie o tour."
|
||||
},
|
||||
"files": {
|
||||
"title": "Ficheiros",
|
||||
"search_placeholder": "Pesquisar ficheiros...",
|
||||
"empty_state_title": "Ainda não há ficheiros",
|
||||
"empty_state_description": "Carregue ficheiros ou crie pastas para começar",
|
||||
"title": "Arquivos",
|
||||
"search_placeholder": "Pesquisar arquivos...",
|
||||
"empty_state_title": "Ainda não há arquivos",
|
||||
"empty_state_description": "Carregue arquivos ou crie pastas para começar",
|
||||
"upload": "Carregar",
|
||||
"upload_files": "Carregar ficheiros",
|
||||
"upload_files": "Carregar arquivos",
|
||||
"new_folder": "Nova pasta",
|
||||
"new_folder_name": "Nome da pasta",
|
||||
"rename": "Renomear",
|
||||
@@ -2452,13 +2501,13 @@
|
||||
"modified": "Modificado",
|
||||
"type": "Tipo",
|
||||
"folder": "Pasta",
|
||||
"file": "Ficheiro",
|
||||
"file": "Arquivo",
|
||||
"parent_directory": "Diretório superior",
|
||||
"breadcrumb_root": "Início",
|
||||
"drop_files_here": "Largue ficheiros ou pastas aqui para carregar",
|
||||
"drop_files_here": "Largue arquivos ou pastas aqui para carregar",
|
||||
"uploading": "A carregar...",
|
||||
"upload_success": "{count, plural, one {1 ficheiro carregado} other {# ficheiros carregados}}",
|
||||
"upload_error": "Falha ao carregar o ficheiro",
|
||||
"upload_success": "{count, plural, one {1 arquivo carregado} other {# arquivos carregados}}",
|
||||
"upload_error": "Falha ao carregar o arquivo",
|
||||
"create_folder_success": "Pasta criada",
|
||||
"create_folder_error": "Falha ao criar a pasta",
|
||||
"delete_success": "Eliminado com sucesso",
|
||||
@@ -2466,11 +2515,11 @@
|
||||
"rename_success": "Renomeado com sucesso",
|
||||
"rename_error": "Falha ao renomear",
|
||||
"download_error": "Falha ao transferir",
|
||||
"not_available": "O armazenamento de ficheiros não está disponível neste servidor",
|
||||
"not_available": "O armazenamento de arquivos não está disponível neste servidor",
|
||||
"cancel": "Cancelar",
|
||||
"create": "Criar",
|
||||
"save": "Guardar",
|
||||
"no_results": "Nenhum ficheiro corresponde à sua pesquisa",
|
||||
"no_results": "Nenhum arquivo corresponde à sua pesquisa",
|
||||
"batch_delete_confirm_message": "Tem a certeza de que deseja eliminar {count, plural, one {1 item} other {# itens}}? Esta ação não pode ser desfeita.",
|
||||
"batch_delete_success": "{count, plural, one {1 item eliminado} other {# itens eliminados}}",
|
||||
"grid_view": "Vista em grelha",
|
||||
@@ -2486,27 +2535,27 @@
|
||||
"move_error": "Falha ao mover",
|
||||
"paste_success": "Colado com sucesso",
|
||||
"paste_error": "Falha ao colar",
|
||||
"new_text_file": "Novo ficheiro de texto",
|
||||
"file_name": "Nome do ficheiro",
|
||||
"new_text_file": "Novo arquivo de texto",
|
||||
"file_name": "Nome do arquivo",
|
||||
"retry": "Tentar novamente",
|
||||
"refresh": "Atualizar",
|
||||
"toggle_favorite": "Alternar favorito",
|
||||
"duplicate": "Duplicar",
|
||||
"duplicate_success": "Duplicado com sucesso",
|
||||
"duplicate_error": "Falha ao duplicar",
|
||||
"create_file_success": "Ficheiro criado",
|
||||
"create_file_error": "Falha ao criar ficheiro",
|
||||
"create_file_success": "Arquivo criado",
|
||||
"create_file_error": "Falha ao criar arquivo",
|
||||
"favorites": "Favoritos",
|
||||
"recent": "Recentes",
|
||||
"properties": "Propriedades",
|
||||
"open_folder": "Abrir pasta",
|
||||
"upload_folder": "Carregar pasta",
|
||||
"file_too_large": "\"{name}\" excede o tamanho máximo do ficheiro ({max})",
|
||||
"file_too_large": "\"{name}\" excede o tamanho máximo do arquivo ({max})",
|
||||
"undo": "Desfazer",
|
||||
"undo_success": "Ação desfeita",
|
||||
"undo_error": "Falha ao desfazer",
|
||||
"toolbar": "Ações de arquivo",
|
||||
"file_list": "Ficheiros e pastas",
|
||||
"file_list": "Arquivos e pastas",
|
||||
"context_menu": "Ações",
|
||||
"settings_title": "Configurações de arquivos",
|
||||
"settings_display": "Exibição",
|
||||
@@ -2529,7 +2578,7 @@
|
||||
"settings_show_hidden": "Mostrar arquivos ocultos",
|
||||
"settings_show_hidden_desc": "Exibir arquivos e pastas que começam com um ponto",
|
||||
"settings_folder_layout": "Navegação de pastas",
|
||||
"settings_folder_layout_desc": "Escolha como as pastas são apresentadas: integradas com os ficheiros ou numa árvore na barra lateral",
|
||||
"settings_folder_layout_desc": "Escolha como as pastas são apresentadas: integradas com os arquivos ou numa árvore na barra lateral",
|
||||
"settings_folder_layout_inline": "Integrado",
|
||||
"settings_folder_layout_sidebar": "Barra lateral",
|
||||
"disabled_title": "O recurso de arquivos foi desativado pelo seu administrador",
|
||||
@@ -2654,16 +2703,39 @@
|
||||
"event_modal_desc": "Aqui está o formulário do evento. Preencha o título, escolha uma data e hora, adicione um local ou participantes. Clique em salvar quando terminar - ou feche e siga em frente.",
|
||||
"contacts_list_title": "Seus contatos",
|
||||
"contacts_list_desc": "Aqui estão seus contatos. Clique em qualquer contato para ver seus detalhes à direita. Você também pode criar novos contatos, importar vCards ou organizar contatos em grupos.",
|
||||
"files_title": "Armazenamento de ficheiros",
|
||||
"files_title": "Armazenamento de arquivos",
|
||||
"settings_tabs_title": "Menu de configurações",
|
||||
"settings_tabs_desc": "Aqui estão todas as categorias de configurações. Personalize a aparência, gerencie identidades, configure filtros de e-mail, ajuste o calendário e muito mais.",
|
||||
"files_desc": "O navegador de ficheiros permite carregar, organizar e partilhar ficheiros - como uma nuvem pessoal integrada no seu e-mail.",
|
||||
"demo_banner_title": "Controlos de demonstração",
|
||||
"files_desc": "O navegador de arquivos permite carregar, organizar e compartilhar arquivos - como uma nuvem pessoal integrada no seu e-mail.",
|
||||
"demo_banner_title": "Controles de demonstração",
|
||||
"demo_banner_desc": "Está no modo de demonstração - tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.",
|
||||
"quota_title": "Utilização do armazenamento",
|
||||
"quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "A pesquisa não está disponível na vista unificada"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Compartilhar \"{name}\"",
|
||||
"description": "Conceda acesso a outros usuários ou grupos neste servidor. As alterações têm efeito imediato.",
|
||||
"no_shares": "Ainda não compartilhado.",
|
||||
"add_person": "Adicionar pessoa ou grupo",
|
||||
"search_placeholder": "Buscar por nome ou e-mail…",
|
||||
"loading_principals": "Carregando usuários…",
|
||||
"no_principals": "Nenhum outro usuário ou grupo encontrado.",
|
||||
"no_match": "Sem resultados.",
|
||||
"remove": "Remover acesso",
|
||||
"group": "Grupo",
|
||||
"share_added": "Acesso concedido",
|
||||
"share_updated": "Acesso atualizado",
|
||||
"share_removed": "Acesso removido",
|
||||
"share_failed": "Falha ao atualizar o compartilhamento",
|
||||
"preset": {
|
||||
"freeBusy": "Apenas disponibilidade",
|
||||
"read": "Somente leitura",
|
||||
"readWrite": "Leitura e escrita",
|
||||
"manager": "Gerente",
|
||||
"custom": "Personalizado"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
-2
@@ -1285,6 +1285,10 @@
|
||||
"label": "Фавиконы отправителей (Экспериментально)",
|
||||
"description": "Показывать иконки сайтов как аватары для корпоративных отправителей"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Показывать аватары в папке «Спам»",
|
||||
"description": "Показывать аватары и фавиконы отправителей в папке «Спам». По умолчанию отключено, чтобы фишинговые письма не выглядели правдоподобно."
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"label": "Сочетания клавиш",
|
||||
"description": "Просмотреть доступные сочетания клавиш",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "{count} писем выбрано",
|
||||
"edit_draft": "Редактировать черновик"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "Сочетания клавиш",
|
||||
"tip": "Нажмите ? в любое время для отображения справки",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "Адресная книга переименована",
|
||||
"rename_failed": "Не удалось переименовать адресную книгу",
|
||||
"default": "По умолчанию",
|
||||
"manage": "Управление адресными книгами"
|
||||
"manage": "Управление адресными книгами",
|
||||
"share": "Поделиться адресной книгой",
|
||||
"new_contact_in_book": "Новый контакт в этой адресной книге",
|
||||
"delete": "Удалить адресную книгу",
|
||||
"confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.",
|
||||
"deleted": "Адресная книга удалена",
|
||||
"delete_failed": "Не удалось удалить адресную книгу"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреса электронной почты",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"error_delete": "Не удалось удалить календарь",
|
||||
"caldav_url": "URL CalDAV",
|
||||
"copy_url": "Скопировать CalDAV URL",
|
||||
"url_copied": "CalDAV URL скопирован в буфер обмена"
|
||||
"url_copied": "CalDAV URL скопирован в буфер обмена",
|
||||
"share": "Поделиться календарём",
|
||||
"new_event_in_calendar": "Новое событие в этом календаре"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Подписка iCal",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Поиск недоступен в объединённом представлении"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Поделиться «{name}»",
|
||||
"description": "Предоставьте доступ другим пользователям или группам на этом сервере. Изменения вступают в силу немедленно.",
|
||||
"no_shares": "Пока никому не предоставлен доступ.",
|
||||
"add_person": "Добавить пользователя или группу",
|
||||
"search_placeholder": "Искать по имени или email…",
|
||||
"loading_principals": "Загрузка пользователей…",
|
||||
"no_principals": "Других пользователей или групп не найдено.",
|
||||
"no_match": "Нет совпадений.",
|
||||
"remove": "Отозвать доступ",
|
||||
"group": "Группа",
|
||||
"share_added": "Доступ предоставлен",
|
||||
"share_updated": "Доступ обновлён",
|
||||
"share_removed": "Доступ отозван",
|
||||
"share_failed": "Не удалось обновить общий доступ",
|
||||
"preset": {
|
||||
"freeBusy": "Только занятость",
|
||||
"read": "Только чтение",
|
||||
"readWrite": "Чтение и запись",
|
||||
"manager": "Управляющий",
|
||||
"custom": "Пользовательский"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-2
@@ -1285,6 +1285,10 @@
|
||||
"label": "Favicons відправника (експериментальний)",
|
||||
"description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Показувати аватари в папці «Спам»",
|
||||
"description": "Показувати зображення профілю та фавікони відправників у папці «Спам». Типово вимкнено, щоб фішингові листи не виглядали достовірно."
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"label": "Комбінації клавіш",
|
||||
"description": "Переглянути доступні комбінації клавіш",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "Вибрано електронних листів: {count}",
|
||||
"edit_draft": "Редагувати чернетку"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "Комбінації клавіш",
|
||||
"tip": "Натисніть ? у будь-який час, щоб показати цю допомогу",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "Адресну книгу перейменовано",
|
||||
"rename_failed": "Не вдалося перейменувати адресну книгу",
|
||||
"default": "За замовчуванням",
|
||||
"manage": "Керуйте адресними книгами"
|
||||
"manage": "Керуйте адресними книгами",
|
||||
"share": "Поділитися адресною книгою",
|
||||
"new_contact_in_book": "Новий контакт у цій адресній книзі",
|
||||
"delete": "Видалити адресну книгу",
|
||||
"confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.",
|
||||
"deleted": "Адресну книгу видалено",
|
||||
"delete_failed": "Не вдалося видалити адресну книгу"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреси електронної пошти",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"error_delete": "Не вдалося видалити календар",
|
||||
"caldav_url": "URL-адреса CalDAV",
|
||||
"copy_url": "Скопіюйте URL-адресу CalDAV",
|
||||
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну"
|
||||
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну",
|
||||
"share": "Поділитися календарем",
|
||||
"new_event_in_calendar": "Нова подія в цьому календарі"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Підписка iCal",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Пошук недоступний в об'єднаному перегляді"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Поділитися «{name}»",
|
||||
"description": "Надайте доступ іншим користувачам або групам на цьому сервері. Зміни набувають чинності негайно.",
|
||||
"no_shares": "Поки що ні з ким не поділено.",
|
||||
"add_person": "Додати людину або групу",
|
||||
"search_placeholder": "Шукати за іменем або email…",
|
||||
"loading_principals": "Завантаження користувачів…",
|
||||
"no_principals": "Інших користувачів або груп не знайдено.",
|
||||
"no_match": "Збігів немає.",
|
||||
"remove": "Видалити доступ",
|
||||
"group": "Група",
|
||||
"share_added": "Доступ надано",
|
||||
"share_updated": "Доступ оновлено",
|
||||
"share_removed": "Доступ видалено",
|
||||
"share_failed": "Не вдалося оновити спільний доступ",
|
||||
"preset": {
|
||||
"freeBusy": "Лише зайнятість",
|
||||
"read": "Лише читання",
|
||||
"readWrite": "Читання та запис",
|
||||
"manager": "Керівник",
|
||||
"custom": "Власне"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-2
@@ -1285,6 +1285,10 @@
|
||||
"label": "发件人图标(实验性)",
|
||||
"description": "使用企业发件人的网站图标作为头像"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "在垃圾邮件文件夹中显示头像",
|
||||
"description": "在垃圾邮件文件夹中显示发件人的头像和网站图标。默认关闭,避免钓鱼邮件因熟悉的图标看起来更可信。"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"label": "键盘快捷键",
|
||||
"description": "查看可用快捷键",
|
||||
@@ -1586,6 +1590,43 @@
|
||||
"items_selected": "已选择 {count} 封邮件",
|
||||
"edit_draft": "编辑草稿"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
"mark_all_folders_read": "Mark all folders as read",
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
|
||||
"prompt_new_subfolder": "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": {
|
||||
"title": "键盘快捷键",
|
||||
"tip": "按?随时显示此帮助",
|
||||
@@ -1774,7 +1815,13 @@
|
||||
"renamed": "地址簿已重命名",
|
||||
"rename_failed": "重命名地址簿失败",
|
||||
"default": "默认",
|
||||
"manage": "管理地址簿"
|
||||
"manage": "管理地址簿",
|
||||
"share": "共享通讯录",
|
||||
"new_contact_in_book": "在此通讯录中新建联系人",
|
||||
"delete": "删除通讯录",
|
||||
"confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。",
|
||||
"deleted": "通讯录已删除",
|
||||
"delete_failed": "删除通讯录失败"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "邮箱地址",
|
||||
@@ -2299,7 +2346,9 @@
|
||||
"error_delete": "删除日历失败",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "复制 CalDAV URL",
|
||||
"url_copied": "CalDAV URL 已复制到剪贴板"
|
||||
"url_copied": "CalDAV URL 已复制到剪贴板",
|
||||
"share": "共享日历",
|
||||
"new_event_in_calendar": "在此日历中新建事件"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal 订阅",
|
||||
@@ -2665,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "统一视图中无法使用搜索"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "共享「{name}」",
|
||||
"description": "向此服务器上的其他用户或群组授予访问权限。更改会立即生效。",
|
||||
"no_shares": "尚未共享。",
|
||||
"add_person": "添加用户或群组",
|
||||
"search_placeholder": "按姓名或邮箱搜索…",
|
||||
"loading_principals": "正在加载用户…",
|
||||
"no_principals": "未找到其他用户或群组。",
|
||||
"no_match": "无匹配项。",
|
||||
"remove": "取消访问",
|
||||
"group": "群组",
|
||||
"share_added": "已授予访问权限",
|
||||
"share_updated": "已更新访问权限",
|
||||
"share_removed": "已取消访问权限",
|
||||
"share_failed": "更新共享失败",
|
||||
"preset": {
|
||||
"freeBusy": "仅显示忙/闲",
|
||||
"read": "只读",
|
||||
"readWrite": "读写",
|
||||
"manager": "管理员",
|
||||
"custom": "自定义"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.2",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.2",
|
||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import createIntlMiddleware from "next-intl/middleware";
|
||||
import { routing } from "./i18n/routing";
|
||||
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
|
||||
|
||||
const intlMiddleware = createIntlMiddleware(routing);
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
// Next 16's Proxy always runs on Node.js runtime and route-segment config
|
||||
// (e.g. `export const config = { matcher }`) is no longer allowed in the
|
||||
// proxy file. We replicate the previous matcher inline by short-circuiting
|
||||
// requests for API routes, Next internals and static assets.
|
||||
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/;
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
if (PROXY_SKIP_PATTERN.test(request.nextUrl.pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const nonce = crypto.randomUUID();
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
@@ -16,6 +27,14 @@ export function proxy(request: NextRequest) {
|
||||
|
||||
const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
|
||||
|
||||
// Plugins may declare iframe origins they need (e.g. for embedded video).
|
||||
// Each origin is validated at install time and re-validated here.
|
||||
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
|
||||
const frameSrc =
|
||||
pluginFrameOrigins.length > 0
|
||||
? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
|
||||
: `frame-src 'self' blob:`;
|
||||
|
||||
const csp = [
|
||||
`default-src 'self'`,
|
||||
`script-src ${scriptSrc}`,
|
||||
@@ -23,7 +42,7 @@ export function proxy(request: NextRequest) {
|
||||
`img-src 'self' data: blob: https:`,
|
||||
`font-src 'self'`,
|
||||
`connect-src ${connectSrc}`,
|
||||
`frame-src 'self' blob:`,
|
||||
frameSrc,
|
||||
`object-src 'none'`,
|
||||
`base-uri 'self'`,
|
||||
`form-action 'self'`,
|
||||
@@ -78,7 +97,3 @@ export function proxy(request: NextRequest) {
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next|.*\\..*).*)"],
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from '@/lib/jmap/types';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||
import { parseDuration } from '@/components/calendar/event-card';
|
||||
@@ -125,6 +125,7 @@ interface CalendarStore {
|
||||
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
|
||||
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
|
||||
shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
|
||||
createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
|
||||
removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
|
||||
clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise<number>;
|
||||
@@ -653,6 +654,29 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
shareCalendar: async (client, calendarId, principalId, rights) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
const cal = get().calendars.find(c => c.id === calendarId);
|
||||
const realId = cal?.originalId || calendarId;
|
||||
const targetAccountId = cal?.accountId;
|
||||
await client.setCalendarShare(realId, principalId, rights, targetAccountId);
|
||||
set((state) => ({
|
||||
calendars: state.calendars.map(c => {
|
||||
if (c.id !== calendarId) return c;
|
||||
const next = { ...(c.shareWith ?? {}) };
|
||||
if (rights === null) delete next[principalId];
|
||||
else next[principalId] = rights;
|
||||
return { ...c, shareWith: next };
|
||||
}),
|
||||
}));
|
||||
} catch (error) {
|
||||
debug.error('Failed to share calendar:', error);
|
||||
set({ error: 'Failed to share calendar' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
createCalendar: async (client, calendar) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
|
||||
+42
-1
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
||||
import type { ContactCard, AddressBook, AddressBookRights, ContactName } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { debug } from '@/lib/debug';
|
||||
@@ -101,6 +101,8 @@ interface ContactStore {
|
||||
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
||||
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
||||
renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise<void>;
|
||||
removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise<void>;
|
||||
shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise<void>;
|
||||
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
|
||||
|
||||
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
||||
@@ -655,6 +657,45 @@ export const useContactStore = create<ContactStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
removeAddressBook: async (client, addressBook) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
const originalId = addressBook.originalId || addressBook.id;
|
||||
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||
await client.deleteAddressBook(originalId, accountId);
|
||||
set((state) => ({
|
||||
addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id),
|
||||
contacts: state.contacts.filter(c => !c.addressBookIds?.[addressBook.id]),
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to delete address book';
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
shareAddressBook: async (client, addressBook, principalId, rights) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
const originalId = addressBook.originalId || addressBook.id;
|
||||
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||
await client.setAddressBookShare(originalId, principalId, rights, accountId);
|
||||
set((state) => ({
|
||||
addressBooks: state.addressBooks.map(b => {
|
||||
if (b.id !== addressBook.id) return b;
|
||||
const next = { ...(b.shareWith ?? {}) };
|
||||
if (rights === null) delete next[principalId];
|
||||
else next[principalId] = rights;
|
||||
return { ...b, shareWith: next };
|
||||
}),
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to share address book';
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
renameKeyword: async (client, oldKeyword, newKeyword) => {
|
||||
set({ error: null });
|
||||
const oldKw = oldKeyword.trim();
|
||||
|
||||
+50
-9
@@ -118,6 +118,7 @@ interface EmailStore {
|
||||
deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
||||
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
|
||||
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
||||
markMailboxAsRead: (client: IJMAPClient, mailboxId: string) => Promise<number>;
|
||||
|
||||
// Unified mailbox operations
|
||||
fetchUnifiedEmails: (accounts: UnifiedAccountClient[], role: UnifiedMailboxRole) => Promise<void>;
|
||||
@@ -289,7 +290,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
// JMAP operations
|
||||
fetchMailboxes: async (client) => {
|
||||
set({ isLoading: true, error: null });
|
||||
// Only toggle the email list's isLoading on the initial load. Background
|
||||
// refreshes (after a move/archive that may have created new folders) must
|
||||
// not flash the list's loading state, which hides the results-count bar
|
||||
// and dims the list while folders re-fetch.
|
||||
const isInitialLoad = get().mailboxes.length === 0;
|
||||
if (isInitialLoad) set({ isLoading: true, error: null });
|
||||
try {
|
||||
const mailboxes = await client.getAllMailboxes();
|
||||
|
||||
@@ -297,21 +303,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// doesn't exist in the fetched list (e.g. after an account switch)
|
||||
const currentSelectedMailbox = get().selectedMailbox;
|
||||
const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox);
|
||||
const loadingPatch = isInitialLoad ? { isLoading: false } : {};
|
||||
if (!selectionValid) {
|
||||
// Find inbox from PRIMARY account (not shared accounts)
|
||||
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
|
||||
if (inboxMailbox) {
|
||||
set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false });
|
||||
set({ mailboxes, selectedMailbox: inboxMailbox.id, ...loadingPatch });
|
||||
} else {
|
||||
set({ mailboxes, selectedMailbox: '', isLoading: false });
|
||||
set({ mailboxes, selectedMailbox: '', ...loadingPatch });
|
||||
}
|
||||
} else {
|
||||
set({ mailboxes, isLoading: false });
|
||||
set({ mailboxes, ...loadingPatch });
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to fetch mailboxes",
|
||||
isLoading: false
|
||||
...(isInitialLoad ? { isLoading: false } : {})
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1254,9 +1261,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
// Refresh emails to get updated list
|
||||
// Refresh emails to get updated list (honors active search/filters)
|
||||
if (!get().isUnifiedView) {
|
||||
await get().fetchEmails(client, get().selectedMailbox);
|
||||
await get().refreshCurrentMailbox(client);
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
@@ -1267,7 +1274,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
batchArchive: async (client) => {
|
||||
const { selectedEmailIds, emails, mailboxes, fetchMailboxes, fetchEmails, selectedMailbox } = get();
|
||||
const { selectedEmailIds, emails, mailboxes, fetchMailboxes } = get();
|
||||
if (selectedEmailIds.size === 0) return;
|
||||
|
||||
const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive');
|
||||
@@ -1293,7 +1300,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false });
|
||||
|
||||
await fetchMailboxes(client);
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
// Refresh the current mailbox view (honors active search/filters)
|
||||
await get().refreshCurrentMailbox(client);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to archive emails',
|
||||
@@ -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
|
||||
fetchUnifiedEmails: async (accounts, role) => {
|
||||
set({
|
||||
|
||||
@@ -20,7 +20,7 @@ import { apiFetch } from '@/lib/browser-navigation';
|
||||
// ─── Slot State ──────────────────────────────────────────────
|
||||
|
||||
const SLOT_NAMES: SlotName[] = [
|
||||
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
|
||||
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar', 'composer-sidebar-right',
|
||||
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
|
||||
'calendar-event-actions', 'admin-plugin-page',
|
||||
];
|
||||
|
||||
@@ -187,6 +187,7 @@ interface SettingsState {
|
||||
|
||||
// Experimental
|
||||
senderFavicons: boolean;
|
||||
showAvatarsInJunk: boolean; // Show profile images/favicons in the junk folder
|
||||
|
||||
// Sidebar
|
||||
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
|
||||
@@ -334,6 +335,7 @@ const DEFAULT_SETTINGS = {
|
||||
|
||||
// Experimental
|
||||
senderFavicons: true,
|
||||
showAvatarsInJunk: false,
|
||||
|
||||
// Sidebar
|
||||
colorfulSidebarIcons: true,
|
||||
@@ -475,6 +477,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
showRailAccountList: state.showRailAccountList,
|
||||
enableUnifiedMailbox: state.enableUnifiedMailbox,
|
||||
senderFavicons: state.senderFavicons,
|
||||
showAvatarsInJunk: state.showAvatarsInJunk,
|
||||
colorfulSidebarIcons: state.colorfulSidebarIcons,
|
||||
folderIcons: state.folderIcons,
|
||||
emailKeywords: state.emailKeywords,
|
||||
|
||||
Reference in New Issue
Block a user