Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4501b3894b | ||
|
|
2edf2fab89 | ||
|
|
d493bb17dc | ||
|
|
234129397d | ||
|
|
9b3a47f9be | ||
|
|
0fcc932e66 | ||
|
|
0fe8e81dc7 | ||
|
|
267f7257cf | ||
|
|
fb8c9db716 | ||
|
|
2793d4b4af | ||
|
|
96c2ee9e13 | ||
|
|
af115e3245 | ||
|
|
fc79bf4f9b | ||
|
|
b844b88733 | ||
|
|
bcdde9f454 | ||
|
|
bb72ac92ae | ||
|
|
6457b27125 | ||
|
|
ef562bcaad | ||
|
|
9fdbb62205 | ||
|
|
2edbf379e2 | ||
|
|
cdc521b693 | ||
|
|
e7249f8bd3 | ||
|
|
a57492d7c0 | ||
|
|
2b4ff2f3de | ||
|
|
5a3f9faafe | ||
|
|
a74cd32364 | ||
|
|
38c04099e2 | ||
|
|
ae14e09f66 | ||
|
|
ada356e440 | ||
|
|
a886edda6f | ||
|
|
ff97d8bc4c | ||
|
|
b1db100c3a | ||
|
|
19584cfef6 | ||
|
|
8a2ef5a6c5 |
+1
-1
@@ -72,7 +72,7 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Hostname the server binds to (default: 0.0.0.0)
|
# Hostname the server binds to (default: 0.0.0.0)
|
||||||
# Set to "::" for IPv6 or "[::]" for dual-stack support.
|
# Set to "::" for dual-stack
|
||||||
# HOSTNAME=0.0.0.0
|
# HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
# Port the server listens on (default: 3000)
|
# Port the server listens on (default: 3000)
|
||||||
|
|||||||
+40
-1
@@ -1,6 +1,45 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 1.4.0 (2026-03-17)
|
## 1.4.3 (2026-03-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Auth**: Implement multi-account support with up to 5 simultaneous accounts and instant switching
|
||||||
|
- **Auth**: Add account switcher component with connection status, default account selection, and per-account logout
|
||||||
|
- **Auth**: Support multi-account OAuth and basic auth with per-account session persistence
|
||||||
|
- **Contacts**: Enhance contacts sidebar with collapsible sections, bulk operations, and address book grouping
|
||||||
|
- **Contacts**: Add contact import functionality and keyword filtering
|
||||||
|
- **Settings**: Add per-account encrypted settings storage with server-side sync support
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **UI**: Adjust popover alignment in sub-address helper component
|
||||||
|
- **Settings**: Improve error logging in settings sync functionality
|
||||||
|
|
||||||
|
## 1.4.2 (2026-03-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Calendar**: Add task list view for calendar tasks with task details and management
|
||||||
|
- **Calendar**: Add shared calendar grouping with visual separation in sidebar
|
||||||
|
- **Calendar**: Support double-click to create events and improve modal date handling
|
||||||
|
- **Contacts**: Add address book directories with drag-and-drop and editor picker
|
||||||
|
- **Email**: Add email attachment support in sendEmail functionality
|
||||||
|
- **Email**: Implement draft editing functionality across email components
|
||||||
|
- **Email**: Implement unwrapping of embedded message/rfc822 attachments with enhanced HTML body validation
|
||||||
|
- **Email**: Add email export/import localization keys for multiple languages
|
||||||
|
- **Contacts**: Update gender handling to use speakToAs structure
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Email**: Resolve default sender to canonical identity on local-part login
|
||||||
|
- **Email**: Refactor overflow handling in EmailViewer to use hidden priorities and layout effects
|
||||||
|
- **Email**: Remove debugMode usage from EmailViewer component
|
||||||
|
- **Calendar**: Enhance IMIP invitation and cancellation handling for calendar events
|
||||||
|
- **Calendar**: Add time-based sorting for events in buildWeekSegments function
|
||||||
|
- **Dependencies**: Update dompurify to 3.3.3 and elliptic to 6.6.1, add undici override
|
||||||
|
|
||||||
|
## 1.4.1 (2026-03-18)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
Built with Next.js and the JMAP protocol.
|
Built with Next.js and the JMAP protocol.
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function OAuthCallbackInner() {
|
|||||||
sessionStorage.removeItem("oauth_state");
|
sessionStorage.removeItem("oauth_state");
|
||||||
sessionStorage.removeItem("oauth_code_verifier");
|
sessionStorage.removeItem("oauth_code_verifier");
|
||||||
sessionStorage.removeItem("oauth_server_url");
|
sessionStorage.removeItem("oauth_server_url");
|
||||||
|
sessionStorage.removeItem("oauth_add_account_mode");
|
||||||
let redirectTo = `/${params.locale}`;
|
let redirectTo = `/${params.locale}`;
|
||||||
try {
|
try {
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
|
|||||||
@@ -236,10 +236,12 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const openCreateModal = useCallback((date?: Date, endDate?: Date) => {
|
const openCreateModal = useCallback((date?: Date, endDate?: Date) => {
|
||||||
setEditEvent(null);
|
setEditEvent(null);
|
||||||
setDefaultModalDate(date || selectedDate);
|
const d = date || selectedDate;
|
||||||
|
setDefaultModalDate(d);
|
||||||
setDefaultModalEndDate(endDate);
|
setDefaultModalEndDate(endDate);
|
||||||
|
setSelectedDate(d);
|
||||||
setShowEventModal(true);
|
setShowEventModal(true);
|
||||||
}, [selectedDate]);
|
}, [selectedDate, setSelectedDate]);
|
||||||
|
|
||||||
const openEditModal = useCallback((event: CalendarEvent) => {
|
const openEditModal = useCallback((event: CalendarEvent) => {
|
||||||
setEditEvent(event);
|
setEditEvent(event);
|
||||||
@@ -609,6 +611,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const visibleEvents = useMemo(() =>
|
const visibleEvents = useMemo(() =>
|
||||||
events.filter((e) => {
|
events.filter((e) => {
|
||||||
|
if (!e.calendarIds) return false;
|
||||||
const calIds = Object.keys(e.calendarIds);
|
const calIds = Object.keys(e.calendarIds);
|
||||||
return calIds.some((id) => selectedCalendarIds.includes(id));
|
return calIds.some((id) => selectedCalendarIds.includes(id));
|
||||||
}),
|
}),
|
||||||
@@ -638,6 +641,7 @@ export default function CalendarPage() {
|
|||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
@@ -690,7 +694,7 @@ export default function CalendarPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex-1 flex flex-col overflow-hidden">
|
<div className="relative flex-1 flex flex-col overflow-hidden">
|
||||||
{viewContent}
|
{viewContent}
|
||||||
{isLoadingEvents && calendars.length > 0 && (
|
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
||||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||||
<div className="h-5 w-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
<div className="h-5 w-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
@@ -708,7 +712,7 @@ export default function CalendarPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
@@ -791,6 +795,7 @@ export default function CalendarPage() {
|
|||||||
{!isMobile && showEventModal && (
|
{!isMobile && showEventModal && (
|
||||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
||||||
<EventModal
|
<EventModal
|
||||||
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
@@ -852,6 +857,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{showEventModal && isMobile && (
|
{showEventModal && isMobile && (
|
||||||
<EventModal
|
<EventModal
|
||||||
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ContactForm } from "@/components/contacts/contact-form";
|
|||||||
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
||||||
|
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||||
import { exportContacts } from "@/components/contacts/contact-export";
|
import { exportContacts } from "@/components/contacts/contact-export";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -25,7 +26,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
|
|||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| "list"
|
| "list"
|
||||||
@@ -46,6 +47,7 @@ export default function ContactsPage() {
|
|||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const {
|
const {
|
||||||
contacts,
|
contacts,
|
||||||
|
addressBooks,
|
||||||
selectedContactId,
|
selectedContactId,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
supportsSync,
|
supportsSync,
|
||||||
@@ -71,10 +73,13 @@ export default function ContactsPage() {
|
|||||||
clearSelection,
|
clearSelection,
|
||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
|
moveContactToAddressBook,
|
||||||
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
|
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -82,10 +87,10 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
// Panel resize state - sidebar (categories)
|
// Panel resize state - sidebar (categories)
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 180; } catch { return 180; }
|
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
|
||||||
});
|
});
|
||||||
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
||||||
const sidebarDragStartWidth = useRef(180);
|
const sidebarDragStartWidth = useRef(256);
|
||||||
|
|
||||||
// Panel resize state - contact list
|
// Panel resize state - contact list
|
||||||
const [listWidth, setListWidth] = useState(() => {
|
const [listWidth, setListWidth] = useState(() => {
|
||||||
@@ -124,6 +129,16 @@ export default function ContactsPage() {
|
|||||||
// Contacts to display based on active category
|
// Contacts to display based on active category
|
||||||
const displayedContacts = useMemo(() => {
|
const displayedContacts = useMemo(() => {
|
||||||
if (activeCategory === "all") return individuals;
|
if (activeCategory === "all") return individuals;
|
||||||
|
if ("addressBookId" in activeCategory) {
|
||||||
|
const bookId = activeCategory.addressBookId;
|
||||||
|
return individuals.filter(c => {
|
||||||
|
if (!c.addressBookIds) return false;
|
||||||
|
return c.addressBookIds[bookId] === true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ("keyword" in activeCategory) {
|
||||||
|
return individuals.filter(c => c.keywords?.[activeCategory.keyword]);
|
||||||
|
}
|
||||||
// Show members of the selected group
|
// Show members of the selected group
|
||||||
return getGroupMembers(activeCategory.groupId);
|
return getGroupMembers(activeCategory.groupId);
|
||||||
}, [activeCategory, individuals, getGroupMembers]);
|
}, [activeCategory, individuals, getGroupMembers]);
|
||||||
@@ -131,20 +146,49 @@ export default function ContactsPage() {
|
|||||||
// Label for the current category
|
// Label for the current category
|
||||||
const categoryLabel = useMemo(() => {
|
const categoryLabel = useMemo(() => {
|
||||||
if (activeCategory === "all") return t("tabs.all");
|
if (activeCategory === "all") return t("tabs.all");
|
||||||
|
if ("addressBookId" in activeCategory) {
|
||||||
|
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
||||||
|
return book?.name || t("tabs.all");
|
||||||
|
}
|
||||||
|
if ("keyword" in activeCategory) {
|
||||||
|
return activeCategory.keyword;
|
||||||
|
}
|
||||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
const group = contacts.find(c => c.id === activeCategory.groupId);
|
||||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
return group ? getContactDisplayName(group) : t("tabs.all");
|
||||||
}, [activeCategory, contacts, t]);
|
}, [activeCategory, contacts, addressBooks, t]);
|
||||||
|
|
||||||
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
||||||
setActiveCategory(category);
|
setActiveCategory(category);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
if (typeof category === "object") {
|
if (typeof category === "object" && "groupId" in category) {
|
||||||
setSelectedGroupId(category.groupId);
|
setSelectedGroupId(category.groupId);
|
||||||
|
setView("group-detail");
|
||||||
} else {
|
} else {
|
||||||
setSelectedGroupId(null);
|
setSelectedGroupId(null);
|
||||||
}
|
}
|
||||||
}, [clearSelection]);
|
}, [clearSelection]);
|
||||||
|
|
||||||
|
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await moveContactToAddressBook(client, contactIds, addressBook);
|
||||||
|
const msg = contactIds.length === 1
|
||||||
|
? t("address_books.moved", { name: addressBook.name })
|
||||||
|
: t("address_books.moved_plural", { count: contactIds.length, name: addressBook.name });
|
||||||
|
toast.success(msg);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to move contacts:', error);
|
||||||
|
toast.error(t("address_books.move_failed"));
|
||||||
|
}
|
||||||
|
}, [client, moveContactToAddressBook, t]);
|
||||||
|
|
||||||
|
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||||
|
return importContacts(
|
||||||
|
supportsSync && client ? client : null,
|
||||||
|
importedContacts
|
||||||
|
);
|
||||||
|
}, [supportsSync, client, importContacts]);
|
||||||
|
|
||||||
const handleSelectContact = (id: string) => {
|
const handleSelectContact = (id: string) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -239,6 +283,35 @@ export default function ContactsPage() {
|
|||||||
setView("group-edit");
|
setView("group-edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditGroupFromSidebar = useCallback((groupId: string) => {
|
||||||
|
setSelectedGroupId(groupId);
|
||||||
|
setActiveCategory({ groupId });
|
||||||
|
setView("group-edit");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: t("groups.delete_confirm_title"),
|
||||||
|
message: t("groups.delete_confirm"),
|
||||||
|
confirmText: t("form.delete"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteGroup(supportsSync && client ? client : null, groupId);
|
||||||
|
toast.success(t("toast.deleted"));
|
||||||
|
if (selectedGroupId === groupId) {
|
||||||
|
setSelectedGroupId(null);
|
||||||
|
setActiveCategory("all");
|
||||||
|
setView("list");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete group:', error);
|
||||||
|
toast.error(t("toast.error_delete"));
|
||||||
|
}
|
||||||
|
}, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]);
|
||||||
|
|
||||||
const handleDeleteGroup = async () => {
|
const handleDeleteGroup = async () => {
|
||||||
if (!selectedGroup) return;
|
if (!selectedGroup) return;
|
||||||
|
|
||||||
@@ -357,13 +430,14 @@ export default function ContactsPage() {
|
|||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "create":
|
case "create":
|
||||||
return <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
|
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
if (!selectedContact) return null;
|
if (!selectedContact) return null;
|
||||||
return (
|
return (
|
||||||
<ContactForm
|
<ContactForm
|
||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
|
addressBooks={addressBooks}
|
||||||
onSave={handleSaveEdit}
|
onSave={handleSaveEdit}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
@@ -381,7 +455,6 @@ export default function ContactsPage() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onSelectMember={(id) => {
|
onSelectMember={(id) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
setActiveCategory("all");
|
|
||||||
setView("detail");
|
setView("detail");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -479,7 +552,7 @@ export default function ContactsPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
@@ -508,20 +581,25 @@ export default function ContactsPage() {
|
|||||||
<ContactsSidebar
|
<ContactsSidebar
|
||||||
groups={groups}
|
groups={groups}
|
||||||
individuals={individuals}
|
individuals={individuals}
|
||||||
|
addressBooks={addressBooks}
|
||||||
activeCategory={activeCategory}
|
activeCategory={activeCategory}
|
||||||
onSelectCategory={handleSelectCategory}
|
onSelectCategory={handleSelectCategory}
|
||||||
onCreateGroup={handleCreateGroup}
|
onCreateGroup={handleCreateGroup}
|
||||||
onCreateContact={handleCreateNew}
|
onCreateContact={handleCreateNew}
|
||||||
|
onImport={() => setShowImportDialog(true)}
|
||||||
|
onEditGroup={handleEditGroupFromSidebar}
|
||||||
|
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||||
|
onDropContacts={handleDropContacts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||||
onResize={(delta) => setSidebarWidth(Math.max(140, Math.min(300, sidebarDragStartWidth.current + delta)))}
|
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||||
onResizeEnd={() => {
|
onResizeEnd={() => {
|
||||||
setIsSidebarResizing(false);
|
setIsSidebarResizing(false);
|
||||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => { setSidebarWidth(180); localStorage.setItem("contacts-sidebar-width", "180"); }}
|
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -605,6 +683,17 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
{showImportDialog && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||||
|
<ContactImportDialog
|
||||||
|
existingContacts={contacts}
|
||||||
|
onImport={handleImportContacts}
|
||||||
|
onClose={() => setShowImportDialog(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ export default function FilesPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
|||||||
+25
-15
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
|||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
|
|
||||||
const APP_VERSION = "1.4.0";
|
const APP_VERSION = "1.4.3";
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||||
@@ -28,6 +28,8 @@ export default function LoginPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
||||||
@@ -102,7 +104,7 @@ export default function LoginPage() {
|
|||||||
}, [serverUrl]);
|
}, [serverUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated && !isAddAccountMode) {
|
||||||
let redirectTo = '/';
|
let redirectTo = '/';
|
||||||
try {
|
try {
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
@@ -113,7 +115,7 @@ export default function LoginPage() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router]);
|
}, [isAuthenticated, router, isAddAccountMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -303,6 +305,9 @@ export default function LoginPage() {
|
|||||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||||
sessionStorage.setItem("oauth_state", state);
|
sessionStorage.setItem("oauth_state", state);
|
||||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
||||||
|
if (isAddAccountMode) {
|
||||||
|
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||||
|
}
|
||||||
|
|
||||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||||
authUrl.searchParams.set("response_type", "code");
|
authUrl.searchParams.set("response_type", "code");
|
||||||
@@ -329,15 +334,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
saveUsername(formData.username);
|
saveUsername(formData.username);
|
||||||
let redirectTo = '/';
|
router.push('/');
|
||||||
try {
|
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
|
||||||
if (saved) {
|
|
||||||
sessionStorage.removeItem('redirect_after_login');
|
|
||||||
redirectTo = saved;
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
router.push(redirectTo);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -426,10 +423,10 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||||
{appName}
|
{isAddAccountMode ? t("add_account_title") : appName}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1.5">
|
<p className="text-sm text-muted-foreground mt-1.5">
|
||||||
{t("title") !== appName ? t("title") : "Sign in to your account"}
|
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -737,6 +734,19 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAddAccountMode && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full h-10 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+36
-2
@@ -437,11 +437,12 @@ export default function Home() {
|
|||||||
fromEmail?: string;
|
fromEmail?: string;
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||||
}) => {
|
}) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody);
|
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments);
|
||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
|
|
||||||
// Refresh the current mailbox to update the UI
|
// Refresh the current mailbox to update the UI
|
||||||
@@ -468,6 +469,33 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditDraft = (email?: Email) => {
|
||||||
|
const draft = email || selectedEmail;
|
||||||
|
if (!draft) return;
|
||||||
|
const bodyText = draft.bodyValues
|
||||||
|
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
||||||
|
: '';
|
||||||
|
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
||||||
|
? draft.bodyValues[draft.htmlBody[0].partId].value
|
||||||
|
: undefined;
|
||||||
|
setPendingDraft({
|
||||||
|
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||||
|
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||||
|
bcc: draft.bcc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||||
|
subject: draft.subject || '',
|
||||||
|
body: htmlBody || bodyText,
|
||||||
|
showCc: (draft.cc?.length || 0) > 0,
|
||||||
|
showBcc: (draft.bcc?.length || 0) > 0,
|
||||||
|
selectedIdentityId: null,
|
||||||
|
subAddressTag: '',
|
||||||
|
mode: 'compose',
|
||||||
|
draftId: draft.id,
|
||||||
|
});
|
||||||
|
setComposerMode('compose');
|
||||||
|
setShowComposer(true);
|
||||||
|
if (isMobile) setActiveView('viewer');
|
||||||
|
};
|
||||||
|
|
||||||
const handleReplyAll = () => {
|
const handleReplyAll = () => {
|
||||||
setComposerMode('replyAll');
|
setComposerMode('replyAll');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -741,7 +769,9 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
router.push('/login');
|
if (!useAuthStore.getState().isAuthenticated) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
@@ -1370,6 +1400,9 @@ export default function Home() {
|
|||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
await handleUndoSpam();
|
await handleUndoSpam();
|
||||||
}}
|
}}
|
||||||
|
onEditDraft={(email) => {
|
||||||
|
handleEditDraft(email);
|
||||||
|
}}
|
||||||
className="flex-1 min-h-0"
|
className="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -1543,6 +1576,7 @@ export default function Home() {
|
|||||||
onNavigateNext={handleNavigateNext}
|
onNavigateNext={handleNavigateNext}
|
||||||
onNavigatePrev={handleNavigatePrev}
|
onNavigatePrev={handleNavigatePrev}
|
||||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||||
|
onEditDraft={handleEditDraft}
|
||||||
currentUserEmail={client?.["username"]}
|
currentUserEmail={client?.["username"]}
|
||||||
currentUserName={client?.["username"]?.split("@")[0]}
|
currentUserName={client?.["username"]?.split("@")[0]}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ export default function SettingsPage() {
|
|||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<div className="border-t border-border px-5 py-3">
|
<div className="border-t border-border px-5 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { logout(); router.push('/login'); }}
|
onClick={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||||
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
|
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
@@ -317,7 +317,7 @@ export default function SettingsPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -12,20 +12,30 @@ const COOKIE_OPTIONS = {
|
|||||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getSlot(request: NextRequest): number {
|
||||||
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
|
if (raw === null) return 0;
|
||||||
|
const slot = parseInt(raw, 10);
|
||||||
|
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
||||||
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serverUrl, username, password } = await request.json();
|
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||||
if (!serverUrl || !username || !password) {
|
if (!serverUrl || !username || !password) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
const token = encryptSession(serverUrl, username, password);
|
const token = encryptSession(serverUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(SESSION_COOKIE, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -34,10 +44,12 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
const token = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||||
@@ -45,7 +57,7 @@ export async function GET() {
|
|||||||
|
|
||||||
const credentials = decryptSession(token);
|
const credentials = decryptSession(token);
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
cookieStore.delete(SESSION_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +70,21 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.delete(SESSION_COOKIE);
|
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||||
|
|
||||||
|
if (all) {
|
||||||
|
// Delete all session cookies (slots 0-4)
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
cookieStore.delete(sessionCookieName(i));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
cookieStore.delete(sessionCookieName(slot));
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
|||||||
+52
-10
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
|
|
||||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
||||||
|
|
||||||
@@ -14,6 +14,15 @@ const COOKIE_OPTIONS = {
|
|||||||
maxAge: 30 * 24 * 60 * 60,
|
maxAge: 30 * 24 * 60 * 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getSlot(request: NextRequest): number {
|
||||||
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
|
if (raw === null) return 0;
|
||||||
|
const slot = parseInt(raw, 10);
|
||||||
|
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function getRequiredConfig() {
|
function getRequiredConfig() {
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||||
@@ -53,12 +62,13 @@ function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { code, code_verifier, redirect_uri } = await request.json();
|
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
if (!code || !code_verifier || !redirect_uri) {
|
if (!code || !code_verifier || !redirect_uri) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
const tokenEndpoint = await getTokenEndpoint();
|
const tokenEndpoint = await getTokenEndpoint();
|
||||||
|
|
||||||
const params = buildOAuthParams({
|
const params = buildOAuthParams({
|
||||||
@@ -93,8 +103,9 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -104,10 +115,12 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT() {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||||
@@ -129,7 +142,7 @@ export async function PUT() {
|
|||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const errorText = await tokenResponse.text();
|
const errorText = await tokenResponse.text();
|
||||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +154,7 @@ export async function PUT() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -154,10 +167,39 @@ export async function PUT() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||||
|
|
||||||
|
if (all) {
|
||||||
|
// Revoke and delete all refresh token cookies (slots 0-4)
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const name = refreshTokenCookieName(i);
|
||||||
|
const token = cookieStore.get(name)?.value;
|
||||||
|
if (token) {
|
||||||
|
// Best-effort revocation
|
||||||
|
try {
|
||||||
|
const metadata = await getMetadata().catch(() => null);
|
||||||
|
if (metadata?.revocation_endpoint) {
|
||||||
|
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
|
||||||
|
await fetch(metadata.revocation_endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
cookieStore.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
const metadata = await getMetadata().catch((err) => {
|
const metadata = await getMetadata().catch((err) => {
|
||||||
logger.warn('Failed to discover OAuth metadata during logout', {
|
logger.warn('Failed to discover OAuth metadata during logout', {
|
||||||
error: err instanceof Error ? err.message : 'Unknown error',
|
error: err instanceof Error ? err.message : 'Unknown error',
|
||||||
@@ -186,7 +228,7 @@ export async function DELETE() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
}
|
}
|
||||||
|
|
||||||
let end_session_url: string | undefined;
|
let end_session_url: string | undefined;
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
return NextResponse.json({ settings });
|
return NextResponse.json({ settings });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Settings load error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
logger.error('Settings load error', { error: message, code });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,9 @@ export async function POST(request: NextRequest) {
|
|||||||
await saveUserSettings(username, serverUrl, settings);
|
await saveUserSettings(username, serverUrl, settings);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Settings save error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
logger.error('Settings save error', { error: message, code });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface CalendarMonthViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onCreateAtTime?: (date: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ export function CalendarMonthView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
isMobile,
|
isMobile,
|
||||||
}: CalendarMonthViewProps) {
|
}: CalendarMonthViewProps) {
|
||||||
@@ -165,6 +167,7 @@ export function CalendarMonthView({
|
|||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
aria-label={fullDateLabel}
|
aria-label={fullDateLabel}
|
||||||
onClick={() => onSelectDate(day)}
|
onClick={() => onSelectDate(day)}
|
||||||
|
onDoubleClick={() => onCreateAtTime?.(day)}
|
||||||
onDragOver={(e) => handleCellDragOver(e, key)}
|
onDragOver={(e) => handleCellDragOver(e, key)}
|
||||||
onDragLeave={handleCellDragLeave}
|
onDragLeave={handleCellDragLeave}
|
||||||
onDrop={(e) => handleCellDrop(e, day)}
|
onDrop={(e) => handleCellDrop(e, day)}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { Globe, Plus, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
@@ -42,6 +42,20 @@ export function CalendarSidebarPanel({
|
|||||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||||
|
const sharedAccountGroups = useMemo(() => {
|
||||||
|
const shared = calendars.filter(c => c.isShared);
|
||||||
|
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
|
||||||
|
for (const cal of shared) {
|
||||||
|
const key = cal.accountId!;
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, { accountName: cal.accountName || key, calendars: [] });
|
||||||
|
}
|
||||||
|
groups.get(key)!.calendars.push(cal);
|
||||||
|
}
|
||||||
|
return Array.from(groups.values());
|
||||||
|
}, [calendars]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!colorPickerId && !contextMenuCalId) return;
|
if (!colorPickerId && !contextMenuCalId) return;
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
@@ -97,108 +111,122 @@ export function CalendarSidebarPanel({
|
|||||||
|
|
||||||
if (calendars.length === 0 && !onSubscribe) return null;
|
if (calendars.length === 0 && !onSubscribe) return null;
|
||||||
|
|
||||||
|
const renderCalendarItem = (cal: Calendar) => {
|
||||||
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
|
||||||
|
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
||||||
|
)}
|
||||||
|
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||||
|
/>
|
||||||
|
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||||
|
{cal.name}
|
||||||
|
</span>
|
||||||
|
{isSubscriptionCalendar(cal.id) && (
|
||||||
|
<>
|
||||||
|
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
|
||||||
|
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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={() => 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||||
{t("my_calendars")}
|
{t("my_calendars")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{calendars.map((cal) => {
|
{personalCalendars.map(renderCalendarItem)}
|
||||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
|
||||||
const color = cal.color || "#3b82f6";
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
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"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
|
|
||||||
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
|
||||||
)}
|
|
||||||
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
|
||||||
/>
|
|
||||||
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
|
||||||
{cal.name}
|
|
||||||
</span>
|
|
||||||
{isSubscriptionCalendar(cal.id) && (
|
|
||||||
<>
|
|
||||||
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
|
||||||
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
|
|
||||||
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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={() => 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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sharedAccountGroups.map((group) => (
|
||||||
|
<div key={group.accountName} className="mt-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
|
||||||
|
<Share2 className="w-3 h-3" />
|
||||||
|
{group.accountName}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.calendars.map(renderCalendarItem)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function CalendarToolbar({
|
|||||||
{t("my_calendars")}
|
{t("my_calendars")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{calendars.map((cal) => {
|
{calendars.filter(c => !c.isShared).map((cal) => {
|
||||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
const color = cal.color || "#3b82f6";
|
const color = cal.color || "#3b82f6";
|
||||||
return (
|
return (
|
||||||
@@ -179,6 +179,49 @@ export function CalendarToolbar({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const shared = calendars.filter(c => c.isShared);
|
||||||
|
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
|
||||||
|
for (const c of shared) {
|
||||||
|
const key = c.accountId!;
|
||||||
|
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
|
||||||
|
groups.get(key)!.cals.push(c);
|
||||||
|
}
|
||||||
|
return Array.from(groups.values()).map((group) => (
|
||||||
|
<div key={group.accountName} className="mt-2">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
|
||||||
|
{group.accountName}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.cals.map((cal) => {
|
||||||
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cal.id}
|
||||||
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
|
||||||
|
"hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
|
||||||
|
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
||||||
|
)}
|
||||||
|
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||||
|
/>
|
||||||
|
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||||
|
{cal.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useCallback } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns";
|
||||||
|
import { Check, Circle, Flag, CalendarDays, ListTodo } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
|
||||||
|
import type { TaskViewFilter } from "@/stores/task-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
|
interface TaskListViewProps {
|
||||||
|
tasks: CalendarTask[];
|
||||||
|
calendars: Calendar[];
|
||||||
|
selectedCalendarIds: string[];
|
||||||
|
filter: TaskViewFilter;
|
||||||
|
showCompleted: boolean;
|
||||||
|
onSelectTask: (task: CalendarTask) => void;
|
||||||
|
onToggleComplete: (task: CalendarTask) => void;
|
||||||
|
selectedTaskId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskPriorityIcon(priority: number) {
|
||||||
|
if (priority >= 1 && priority <= 4) return <Flag className="h-3.5 w-3.5 text-red-500" />;
|
||||||
|
if (priority === 5) return <Flag className="h-3.5 w-3.5 text-orange-500" />;
|
||||||
|
if (priority >= 6 && priority <= 9) return <Flag className="h-3.5 w-3.5 text-gray-400" />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<typeof useTranslations>, timeFormat: string): { label: string; className: string } {
|
||||||
|
const dueDate = parseISO(due);
|
||||||
|
const overdue = isPast(dueDate) && !isToday(dueDate);
|
||||||
|
|
||||||
|
if (isToday(dueDate)) {
|
||||||
|
return {
|
||||||
|
label: t("tasks.due_today"),
|
||||||
|
className: "text-blue-600 dark:text-blue-400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isTomorrow(dueDate)) {
|
||||||
|
return {
|
||||||
|
label: t("tasks.due_tomorrow"),
|
||||||
|
className: "text-muted-foreground",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (overdue) {
|
||||||
|
return {
|
||||||
|
label: t("tasks.overdue"),
|
||||||
|
className: "text-red-600 dark:text-red-400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = showWithoutTime
|
||||||
|
? format(dueDate, "MMM d")
|
||||||
|
: format(dueDate, timeFormat === "12h" ? "MMM d, h:mm a" : "MMM d, HH:mm");
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: formatted,
|
||||||
|
className: "text-muted-foreground",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskListView({
|
||||||
|
tasks,
|
||||||
|
calendars,
|
||||||
|
selectedCalendarIds,
|
||||||
|
filter,
|
||||||
|
showCompleted,
|
||||||
|
onSelectTask,
|
||||||
|
onToggleComplete,
|
||||||
|
selectedTaskId,
|
||||||
|
}: TaskListViewProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
|
||||||
|
const filteredTasks = useMemo(() => {
|
||||||
|
let result = tasks.filter(task => {
|
||||||
|
const calIds = Object.keys(task.calendarIds);
|
||||||
|
return calIds.some(id => selectedCalendarIds.includes(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!showCompleted) {
|
||||||
|
result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter) {
|
||||||
|
case "pending":
|
||||||
|
result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
|
||||||
|
break;
|
||||||
|
case "completed":
|
||||||
|
result = result.filter(task => task.progress === "completed");
|
||||||
|
break;
|
||||||
|
case "overdue":
|
||||||
|
result = result.filter(task => {
|
||||||
|
if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
|
||||||
|
return isPast(parseISO(task.due)) && !isToday(parseISO(task.due));
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: overdue first, then by due date (no due date last), then by priority
|
||||||
|
result.sort((a, b) => {
|
||||||
|
// Completed tasks at the bottom
|
||||||
|
if (a.progress === "completed" && b.progress !== "completed") return 1;
|
||||||
|
if (a.progress !== "completed" && b.progress === "completed") return -1;
|
||||||
|
|
||||||
|
// Tasks with due dates before those without
|
||||||
|
if (a.due && !b.due) return -1;
|
||||||
|
if (!a.due && b.due) return 1;
|
||||||
|
if (a.due && b.due) {
|
||||||
|
const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
|
||||||
|
if (dateCompare !== 0) return dateCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Higher priority first (lower number = higher priority, but 0 = no priority goes last)
|
||||||
|
const aPri = a.priority || 10;
|
||||||
|
const bPri = b.priority || 10;
|
||||||
|
return aPri - bPri;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [tasks, selectedCalendarIds, filter, showCompleted]);
|
||||||
|
|
||||||
|
const handleToggle = useCallback((e: React.MouseEvent, task: CalendarTask) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleComplete(task);
|
||||||
|
}, [onToggleComplete]);
|
||||||
|
|
||||||
|
if (filteredTasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12">
|
||||||
|
<ListTodo className="h-12 w-12 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">{t("tasks.no_tasks")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{filteredTasks.map(task => {
|
||||||
|
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||||
|
const isCompleted = task.progress === "completed";
|
||||||
|
const priorityIcon = getTaskPriorityIcon(task.priority);
|
||||||
|
const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => onSelectTask(task)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors",
|
||||||
|
selectedTaskId === task.id && "bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleToggle(e, task)}
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors",
|
||||||
|
isCompleted
|
||||||
|
? "bg-green-500 border-green-500 text-white"
|
||||||
|
: "border-muted-foreground/40 hover:border-primary"
|
||||||
|
)}
|
||||||
|
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
|
||||||
|
>
|
||||||
|
{isCompleted && <Check className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium truncate",
|
||||||
|
isCompleted && "line-through text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{task.title || t("tasks.no_title")}
|
||||||
|
</span>
|
||||||
|
{priorityIcon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{dueDateInfo && (
|
||||||
|
<span className={cn("text-xs flex items-center gap-1", dueDateInfo.className)}>
|
||||||
|
<CalendarDays className="h-3 w-3" />
|
||||||
|
{dueDateInfo.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{cal && (
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: cal.color || "#3b82f6" }} />
|
||||||
|
{cal.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ describe('ContactListItem', () => {
|
|||||||
density: 'regular' as const,
|
density: 'regular' as const,
|
||||||
onClick: vi.fn(),
|
onClick: vi.fn(),
|
||||||
onCheckboxClick: vi.fn(),
|
onCheckboxClick: vi.fn(),
|
||||||
|
selectedContactIds: new Set<string>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
it('renders contact name and email', () => {
|
it('renders contact name and email', () => {
|
||||||
|
|||||||
@@ -351,13 +351,16 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{contact.gender && (contact.gender.sex || contact.gender.identity) && (
|
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
|
||||||
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
|
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{contact.gender.sex && <span>{t(`detail.gender_${contact.gender.sex.toUpperCase()}`, { defaultValue: contact.gender.sex })}</span>}
|
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>}
|
||||||
{contact.gender.identity && (
|
{contact.speakToAs.pronouns && (() => {
|
||||||
<span className="text-muted-foreground">{contact.gender.sex ? " — " : ""}{contact.gender.identity}</span>
|
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||||
)}
|
return firstPronoun ? (
|
||||||
|
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun}</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle } from "lucide-react";
|
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo } from "@/lib/jmap/types";
|
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook } from "@/lib/jmap/types";
|
||||||
|
|
||||||
interface EmailEntry {
|
interface EmailEntry {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -47,6 +47,7 @@ interface AddressEntry {
|
|||||||
|
|
||||||
interface ContactFormProps {
|
interface ContactFormProps {
|
||||||
contact?: ContactCard | null;
|
contact?: ContactCard | null;
|
||||||
|
addressBooks?: AddressBook[];
|
||||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -122,7 +123,7 @@ function Select({ value, onChange, children, className }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
|
export function ContactForm({ contact, addressBooks, onSave, onCancel }: ContactFormProps) {
|
||||||
const t = useTranslations("contacts.form");
|
const t = useTranslations("contacts.form");
|
||||||
const isEditing = !!contact;
|
const isEditing = !!contact;
|
||||||
|
|
||||||
@@ -235,12 +236,31 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
|
|||||||
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
|
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
|
||||||
);
|
);
|
||||||
|
|
||||||
const [genderSex, setGenderSex] = useState(contact?.gender?.sex || "");
|
const [genderSex, setGenderSex] = useState(contact?.speakToAs?.grammaticalGender || "");
|
||||||
const [genderIdentity, setGenderIdentity] = useState(contact?.gender?.identity || "");
|
const [genderIdentity, setGenderIdentity] = useState(
|
||||||
|
contact?.speakToAs?.pronouns ? Object.values(contact.speakToAs.pronouns)[0]?.pronouns || "" : ""
|
||||||
|
);
|
||||||
const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || "");
|
const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || "");
|
||||||
const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || "");
|
const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || "");
|
||||||
const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || "");
|
const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || "");
|
||||||
|
|
||||||
|
// Address book selection
|
||||||
|
const currentBookId = useMemo(() => {
|
||||||
|
if (contact?.addressBookIds) {
|
||||||
|
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
// For shared contacts, the addressBookIds uses the original (non-namespaced) id
|
||||||
|
// but we need the namespaced id to match addressBooks entries
|
||||||
|
if (contact.isShared && contact.accountId) {
|
||||||
|
return `${contact.accountId}:${ids[0]}`;
|
||||||
|
}
|
||||||
|
return ids[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, [contact]);
|
||||||
|
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
||||||
@@ -371,12 +391,16 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
|
|||||||
notes: note.trim()
|
notes: note.trim()
|
||||||
? { n0: { note: note.trim() } }
|
? { n0: { note: note.trim() } }
|
||||||
: undefined,
|
: undefined,
|
||||||
gender: (genderSex.trim() || genderIdentity.trim())
|
speakToAs: (genderSex.trim() || genderIdentity.trim())
|
||||||
? { sex: genderSex.trim() || undefined, identity: genderIdentity.trim() || undefined }
|
? {
|
||||||
|
grammaticalGender: genderSex.trim() || undefined,
|
||||||
|
pronouns: genderIdentity.trim() ? { p0: { pronouns: genderIdentity.trim() } } : undefined,
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
calendarUri: calendarUri.trim() || undefined,
|
calendarUri: calendarUri.trim() || undefined,
|
||||||
schedulingUri: schedulingUri.trim() || undefined,
|
schedulingUri: schedulingUri.trim() || undefined,
|
||||||
freeBusyUri: freeBusyUri.trim() || undefined,
|
freeBusyUri: freeBusyUri.trim() || undefined,
|
||||||
|
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@@ -410,6 +434,26 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
|
||||||
|
{/* Address Book Selector */}
|
||||||
|
{addressBooks && addressBooks.length > 1 && (
|
||||||
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
|
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact">
|
||||||
|
<select
|
||||||
|
value={selectedBookId}
|
||||||
|
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||||
|
>
|
||||||
|
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
||||||
|
{addressBooks.map((book) => (
|
||||||
|
<option key={book.id} value={book.id}>
|
||||||
|
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormSection>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Name & Identity — full width */}
|
{/* Name & Identity — full width */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
<FormSection icon={User} title={t("section_identity")} category="contact">
|
<FormSection icon={User} title={t("section_identity")} category="contact">
|
||||||
@@ -737,11 +781,11 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
|
|||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||||
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
<option value="M">{t("gender_male")}</option>
|
<option value="masculine">{t("gender_male")}</option>
|
||||||
<option value="F">{t("gender_female")}</option>
|
<option value="feminine">{t("gender_female")}</option>
|
||||||
<option value="O">{t("gender_other")}</option>
|
<option value="other">{t("gender_other")}</option>
|
||||||
<option value="N">{t("gender_none")}</option>
|
<option value="none">{t("gender_none")}</option>
|
||||||
<option value="U">{t("gender_unknown")}</option>
|
<option value="unknown">{t("gender_unknown")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, type DragEvent } from "react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { ContactCard } from "@/lib/jmap/types";
|
||||||
@@ -13,19 +14,47 @@ interface ContactListItemProps {
|
|||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
hasSelection: boolean;
|
hasSelection: boolean;
|
||||||
density: Density;
|
density: Density;
|
||||||
|
selectedContactIds: Set<string>;
|
||||||
onClick: (e: React.MouseEvent) => void;
|
onClick: (e: React.MouseEvent) => void;
|
||||||
onCheckboxClick: (e: React.MouseEvent) => void;
|
onCheckboxClick: (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, onClick, onCheckboxClick }: ContactListItemProps) {
|
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick }: ContactListItemProps) {
|
||||||
const name = getContactDisplayName(contact);
|
const name = getContactDisplayName(contact);
|
||||||
const email = getContactPrimaryEmail(contact);
|
const email = getContactPrimaryEmail(contact);
|
||||||
const org = contact.organizations
|
const org = contact.organizations
|
||||||
? Object.values(contact.organizations)[0]?.name
|
? Object.values(contact.organizations)[0]?.name
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
|
// Drag all selected contacts if this one is selected, otherwise just this one
|
||||||
|
const ids = selectedContactIds.has(contact.id)
|
||||||
|
? Array.from(selectedContactIds)
|
||||||
|
: [contact.id];
|
||||||
|
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids));
|
||||||
|
e.dataTransfer.setData("text/plain", name || email || contact.id);
|
||||||
|
|
||||||
|
// Custom drag preview
|
||||||
|
const preview = document.createElement("div");
|
||||||
|
preview.style.cssText = `
|
||||||
|
position: fixed; top: -9999px; left: 0;
|
||||||
|
padding: 8px 16px; background-color: var(--color-primary, #3b82f6);
|
||||||
|
color: var(--color-primary-foreground, #ffffff); border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-size: 14px; font-weight: 500;
|
||||||
|
z-index: 9999; white-space: nowrap; pointer-events: none;
|
||||||
|
`;
|
||||||
|
preview.textContent = ids.length === 1 ? (name || "1 contact") : `${ids.length} contacts`;
|
||||||
|
document.body.appendChild(preview);
|
||||||
|
e.dataTransfer.setDragImage(preview, 0, 0);
|
||||||
|
requestAnimationFrame(() => preview.remove());
|
||||||
|
}, [contact.id, name, email, selectedContactIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={handleDragStart}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ export function ContactList({
|
|||||||
isChecked={selectedContactIds.has(contact.id)}
|
isChecked={selectedContactIds.has(contact.id)}
|
||||||
hasSelection={hasSelection}
|
hasSelection={hasSelection}
|
||||||
density={density}
|
density={density}
|
||||||
|
selectedContactIds={selectedContactIds}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,35 +1,93 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { BookUser, Users, Plus, UserPlus } from "lucide-react";
|
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName } from "@/stores/contact-store";
|
import { getContactDisplayName } from "@/stores/contact-store";
|
||||||
|
|
||||||
export type ContactCategory = "all" | { groupId: string };
|
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string };
|
||||||
|
|
||||||
interface ContactsSidebarProps {
|
interface ContactsSidebarProps {
|
||||||
groups: ContactCard[];
|
groups: ContactCard[];
|
||||||
individuals: ContactCard[];
|
individuals: ContactCard[];
|
||||||
|
addressBooks: AddressBook[];
|
||||||
activeCategory: ContactCategory;
|
activeCategory: ContactCategory;
|
||||||
onSelectCategory: (category: ContactCategory) => void;
|
onSelectCategory: (category: ContactCategory) => void;
|
||||||
onCreateGroup: () => void;
|
onCreateGroup: () => void;
|
||||||
onCreateContact: () => void;
|
onCreateContact: () => void;
|
||||||
|
onImport?: () => void;
|
||||||
|
onEditGroup?: (groupId: string) => void;
|
||||||
|
onDeleteGroup?: (groupId: string) => void;
|
||||||
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COLLAPSED_KEY = "contacts-sidebar-collapsed";
|
||||||
|
|
||||||
|
function loadCollapsed(): Record<string, boolean> {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(COLLAPSED_KEY);
|
||||||
|
return v ? JSON.parse(v) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCollapsed(state: Record<string, boolean>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSED_KEY, JSON.stringify(state));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function ContactsSidebar({
|
export function ContactsSidebar({
|
||||||
groups,
|
groups,
|
||||||
individuals,
|
individuals,
|
||||||
|
addressBooks,
|
||||||
activeCategory,
|
activeCategory,
|
||||||
onSelectCategory,
|
onSelectCategory,
|
||||||
onCreateGroup,
|
onCreateGroup,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
|
onImport,
|
||||||
|
onEditGroup,
|
||||||
|
onDeleteGroup,
|
||||||
|
onDropContacts,
|
||||||
className,
|
className,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const toggleSection = useCallback((key: string) => {
|
||||||
|
setCollapsed(prev => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] };
|
||||||
|
saveCollapsed(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showMenu) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
menuRef.current && !menuRef.current.contains(e.target as Node) &&
|
||||||
|
menuBtnRef.current && !menuBtnRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [showMenu]);
|
||||||
|
|
||||||
const sortedGroups = useMemo(() => {
|
const sortedGroups = useMemo(() => {
|
||||||
return [...groups].sort((a, b) =>
|
return [...groups].sort((a, b) =>
|
||||||
@@ -39,17 +97,126 @@ export function ContactsSidebar({
|
|||||||
|
|
||||||
const isAllActive = activeCategory === "all";
|
const isAllActive = activeCategory === "all";
|
||||||
|
|
||||||
|
// Group address books: personal vs shared accounts
|
||||||
|
const personalBooks = useMemo(() =>
|
||||||
|
addressBooks.filter(b => !b.isShared),
|
||||||
|
[addressBooks]);
|
||||||
|
|
||||||
|
const sharedBookGroups = useMemo(() => {
|
||||||
|
const map = new Map<string, { accountId: string; accountName: string; books: AddressBook[] }>();
|
||||||
|
for (const book of addressBooks) {
|
||||||
|
if (!book.isShared || !book.accountId) continue;
|
||||||
|
const existing = map.get(book.accountId);
|
||||||
|
if (existing) {
|
||||||
|
existing.books.push(book);
|
||||||
|
} else {
|
||||||
|
map.set(book.accountId, {
|
||||||
|
accountId: book.accountId,
|
||||||
|
accountName: book.accountName || book.accountId,
|
||||||
|
books: [book],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}, [addressBooks]);
|
||||||
|
|
||||||
|
// Count contacts per address book
|
||||||
|
const contactCountByBook = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const contact of individuals) {
|
||||||
|
if (!contact.addressBookIds) continue;
|
||||||
|
for (const bookId of Object.keys(contact.addressBookIds)) {
|
||||||
|
if (!contact.addressBookIds[bookId]) continue;
|
||||||
|
counts[bookId] = (counts[bookId] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [individuals]);
|
||||||
|
|
||||||
|
// Auto-collect keywords from all contacts
|
||||||
|
const allKeywords = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const contact of individuals) {
|
||||||
|
if (!contact.keywords) continue;
|
||||||
|
for (const [kw, active] of Object.entries(contact.keywords)) {
|
||||||
|
if (!active) continue;
|
||||||
|
counts[kw] = (counts[kw] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
}, [individuals]);
|
||||||
|
|
||||||
|
// Resolve actual group member counts against living contacts
|
||||||
|
const memberCountByGroup = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!group.members) {
|
||||||
|
counts[group.id] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const memberKeys = Object.keys(group.members).filter(k => group.members![k]);
|
||||||
|
const normalizedKeys = memberKeys.map(k => k.startsWith('urn:uuid:') ? k.slice(9) : k);
|
||||||
|
counts[group.id] = individuals.filter(c => {
|
||||||
|
if (memberKeys.includes(c.id) || normalizedKeys.includes(c.id)) return true;
|
||||||
|
if (c.uid) {
|
||||||
|
const bareUid = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid;
|
||||||
|
return memberKeys.includes(c.uid) || normalizedKeys.includes(bareUid);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [groups, individuals]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-3 border-b border-border flex items-center justify-between" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="px-3 border-b border-border flex items-center justify-between" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||||
<Button size="icon" variant="ghost" onClick={onCreateContact} className="h-7 w-7 flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<UserPlus className="w-4 h-4" />
|
<Button
|
||||||
</Button>
|
ref={menuBtnRef}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowMenu(v => !v)}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{showMenu && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
{t("create_new")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
||||||
|
>
|
||||||
|
<UsersRound className="w-4 h-4" />
|
||||||
|
{t("groups.create")}
|
||||||
|
</button>
|
||||||
|
{onImport && (
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onImport(); }}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
{t("import.title")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Navigation */}
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
{/* All contacts */}
|
{/* All contacts */}
|
||||||
<button
|
<button
|
||||||
@@ -69,30 +236,63 @@ export function ContactsSidebar({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Groups section */}
|
{/* My Address Books */}
|
||||||
{(sortedGroups.length > 0) && (
|
{personalBooks.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<button
|
||||||
|
onClick={() => toggleSection("addressBooks")}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
|
>
|
||||||
|
{collapsed.addressBooks ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t("address_books.title")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||||
|
<AddressBookItem
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
|
||||||
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
|
onDropContacts={onDropContacts}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Groups section */}
|
||||||
|
{sortedGroups.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection("groups")}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
|
>
|
||||||
|
{collapsed.groups ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{t("tabs.groups")}
|
{t("tabs.groups")}
|
||||||
</span>
|
</span>
|
||||||
<Button size="icon" variant="ghost" onClick={onCreateGroup} className="h-5 w-5">
|
</button>
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sortedGroups.map((group) => {
|
{!collapsed.groups && sortedGroups.map((group) => {
|
||||||
const isActive = typeof activeCategory === "object" && activeCategory.groupId === group.id;
|
const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id;
|
||||||
const memberCount = group.members
|
const memberCount = memberCountByGroup[group.id] || 0;
|
||||||
? Object.values(group.members).filter(Boolean).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => onSelectCategory({ groupId: group.id })}
|
onClick={() => onSelectCategory({ groupId: group.id })}
|
||||||
|
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted"
|
: "text-foreground/80 hover:bg-muted"
|
||||||
@@ -110,25 +310,172 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedGroups.length === 0 && (
|
{/* Categories section (from contact keywords) */}
|
||||||
<div className="mt-2 px-3">
|
{allKeywords.length > 0 && (
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="mt-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<button
|
||||||
{t("tabs.groups")}
|
onClick={() => toggleSection("categories")}
|
||||||
</span>
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onCreateGroup}
|
|
||||||
className="w-full justify-start text-xs text-muted-foreground h-7"
|
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1.5" />
|
{collapsed.categories ? (
|
||||||
{t("groups.create")}
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
</Button>
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t("detail.categories")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed.categories && allKeywords.map(([keyword, count]) => {
|
||||||
|
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={keyword}
|
||||||
|
onClick={() => onSelectCategory({ keyword })}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-foreground/80 hover:bg-muted"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||||
|
>
|
||||||
|
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
<span className="truncate">{keyword}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Shared accounts with address books */}
|
||||||
|
{sharedBookGroups.map((group) => (
|
||||||
|
<div key={group.accountId} className="mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
|
>
|
||||||
|
{collapsed[`shared-${group.accountId}`] ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<Share2 className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
||||||
|
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
||||||
|
<AddressBookItem
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
|
||||||
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
|
onDropContacts={onDropContacts}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Group context menu */}
|
||||||
|
{groupContextMenu.data && (
|
||||||
|
<ContextMenu
|
||||||
|
ref={groupMenuRef}
|
||||||
|
isOpen={groupContextMenu.isOpen}
|
||||||
|
position={groupContextMenu.position}
|
||||||
|
onClose={closeGroupContextMenu}
|
||||||
|
>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("groups.edit")}
|
||||||
|
onClick={() => {
|
||||||
|
closeGroupContextMenu();
|
||||||
|
onEditGroup?.(groupContextMenu.data!.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("form.delete")}
|
||||||
|
onClick={() => {
|
||||||
|
closeGroupContextMenu();
|
||||||
|
onDeleteGroup?.(groupContextMenu.data!.id);
|
||||||
|
}}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AddressBookItem({
|
||||||
|
book,
|
||||||
|
isActive,
|
||||||
|
contactCount,
|
||||||
|
onSelect,
|
||||||
|
onDropContacts,
|
||||||
|
}: {
|
||||||
|
book: AddressBook;
|
||||||
|
isActive: boolean;
|
||||||
|
contactCount: number;
|
||||||
|
onSelect: () => void;
|
||||||
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
|
}) {
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
if (!e.dataTransfer.types.includes("application/x-contact-ids")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
setIsDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => {
|
||||||
|
setIsDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
const data = e.dataTransfer.getData("application/x-contact-ids");
|
||||||
|
if (!data || !onDropContacts) return;
|
||||||
|
try {
|
||||||
|
const contactIds = JSON.parse(data) as string[];
|
||||||
|
if (contactIds.length > 0) {
|
||||||
|
onDropContacts(contactIds, book);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore invalid data
|
||||||
|
}
|
||||||
|
}, [book, onDropContacts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-foreground/80 hover:bg-muted",
|
||||||
|
isDragOver && "bg-primary/20 ring-2 ring-primary/50"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||||
|
>
|
||||||
|
<Book className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{book.name}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
{contactCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ interface EmailComposerProps {
|
|||||||
fromEmail?: string;
|
fromEmail?: string;
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onDiscardDraft?: (draftId: string) => void;
|
onDiscardDraft?: (draftId: string) => void;
|
||||||
@@ -664,6 +665,22 @@ export function EmailComposer({
|
|||||||
finalBody = body + '\n\n-- \n' + currentIdentity.textSignature;
|
finalBody = body + '\n\n-- \n' + currentIdentity.textSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append quoted original text for the plain text part in reply/forward
|
||||||
|
if (replyTo && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
|
const originalText = replyTo.body || '';
|
||||||
|
if (originalText) {
|
||||||
|
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : '';
|
||||||
|
const fromAddr = replyTo.from?.[0];
|
||||||
|
const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown');
|
||||||
|
|
||||||
|
if (mode === 'forward') {
|
||||||
|
finalBody += `\n\n---------- ${t('prefix.forward')} ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||||
|
} else {
|
||||||
|
finalBody += `\n\nOn ${date}, ${fromStr} wrote:\n> ${originalText.split('\n').join('\n> ')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build HTML signature block (prefer htmlSignature, fall back to escaped textSignature)
|
// Build HTML signature block (prefer htmlSignature, fall back to escaped textSignature)
|
||||||
const buildSignatureHtml = (): string => {
|
const buildSignatureHtml = (): string => {
|
||||||
if (currentIdentity?.htmlSignature) {
|
if (currentIdentity?.htmlSignature) {
|
||||||
@@ -794,6 +811,11 @@ export function EmailComposer({
|
|||||||
await sendRawEmail(client, payload, currentIdentity.id);
|
await sendRawEmail(client, payload, currentIdentity.id);
|
||||||
} else {
|
} else {
|
||||||
// Standard JMAP send path
|
// Standard JMAP send path
|
||||||
|
// Collect uploaded attachment blobIds for the send request
|
||||||
|
const uploadedAttachments = attachments
|
||||||
|
.filter(att => att.blobId && !att.uploading && !att.error)
|
||||||
|
.map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type || 'application/octet-stream', size: att.file.size }));
|
||||||
|
|
||||||
await onSend?.({
|
await onSend?.({
|
||||||
to: toAddresses,
|
to: toAddresses,
|
||||||
cc: ccAddresses,
|
cc: ccAddresses,
|
||||||
@@ -805,6 +827,7 @@ export function EmailComposer({
|
|||||||
fromEmail,
|
fromEmail,
|
||||||
fromName: currentIdentity?.name || undefined,
|
fromName: currentIdentity?.name || undefined,
|
||||||
identityId: currentIdentity?.id,
|
identityId: currentIdentity?.id,
|
||||||
|
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
EditIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
@@ -60,6 +61,7 @@ interface EmailContextMenuProps {
|
|||||||
onMoveToMailbox?: (mailboxId: string) => void;
|
onMoveToMailbox?: (mailboxId: string) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
onUndoSpam?: () => void;
|
onUndoSpam?: () => void;
|
||||||
|
onEditDraft?: () => void;
|
||||||
// Batch actions
|
// Batch actions
|
||||||
onBatchMarkAsRead?: (read: boolean) => void;
|
onBatchMarkAsRead?: (read: boolean) => void;
|
||||||
onBatchDelete?: () => void;
|
onBatchDelete?: () => void;
|
||||||
@@ -126,12 +128,14 @@ export function EmailContextMenu({
|
|||||||
onBatchMoveToMailbox,
|
onBatchMoveToMailbox,
|
||||||
onBatchMarkAsSpam,
|
onBatchMarkAsSpam,
|
||||||
onBatchUndoSpam,
|
onBatchUndoSpam,
|
||||||
|
onEditDraft,
|
||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
const tColor = useTranslations("email_viewer.color_tag");
|
const tColor = useTranslations("email_viewer.color_tag");
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const isDraft = email.keywords?.['$draft'] === true;
|
||||||
const currentColor = getCurrentColor(email.keywords);
|
const currentColor = getCurrentColor(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
@@ -188,6 +192,18 @@ export function EmailContextMenu({
|
|||||||
</ContextMenuHeader>
|
</ContextMenuHeader>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Draft - only for single draft emails */}
|
||||||
|
{!showBatchActions && isDraft && onEditDraft && (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={EditIcon}
|
||||||
|
label={t("edit_draft")}
|
||||||
|
onClick={() => handleAction(onEditDraft)}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Single email actions - Reply, Reply All, Forward */}
|
{/* Single email actions - Reply, Reply All, Forward */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface EmailListProps {
|
|||||||
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
|
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
|
||||||
onMarkAsSpam?: (email: Email) => void;
|
onMarkAsSpam?: (email: Email) => void;
|
||||||
onUndoSpam?: (email: Email) => void;
|
onUndoSpam?: (email: Email) => void;
|
||||||
|
onEditDraft?: (email: Email) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailList({
|
export function EmailList({
|
||||||
@@ -57,6 +58,7 @@ export function EmailList({
|
|||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
onMoveToMailbox,
|
onMoveToMailbox,
|
||||||
|
onEditDraft,
|
||||||
}: EmailListProps) {
|
}: EmailListProps) {
|
||||||
const t = useTranslations('email_list');
|
const t = useTranslations('email_list');
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
@@ -467,6 +469,7 @@ export function EmailList({
|
|||||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
||||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
||||||
|
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
||||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||||
onBatchDelete={() => client && batchDelete(client)}
|
onBatchDelete={() => client && batchDelete(client)}
|
||||||
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
||||||
|
|||||||
+879
-852
File diff suppressed because it is too large
Load Diff
@@ -316,7 +316,16 @@ function EmailCard({
|
|||||||
|
|
||||||
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
||||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||||
useHtmlVersion = !!htmlContent;
|
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
|
||||||
|
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
||||||
|
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
|
||||||
|
if (hasTextBody && htmlContent) {
|
||||||
|
const stripped = htmlContent.replace(/<\/?(html|head|body|meta|!doctype|!DOCTYPE|br\s*\/?)[^>]*>/gi, '').trim();
|
||||||
|
const hasRichContent = /<(table|tr|td|th|img|style|link|div\s+[^>]*class|span\s+[^>]*class|font|center|blockquote|ul|ol|li|h[1-6])\b/i.test(stripped);
|
||||||
|
useHtmlVersion = hasRichContent;
|
||||||
|
} else {
|
||||||
|
useHtmlVersion = !!htmlContent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useHtmlVersion && htmlContent) {
|
if (useHtmlVersion && htmlContent) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { X, Mail, Pencil, Trash2, Plus, AlertTriangle } from 'lucide-react';
|
import { X, Mail, Pencil, Trash2, Plus, AlertTriangle, Star } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
@@ -19,6 +19,12 @@ import { toast } from '@/stores/toast-store';
|
|||||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||||
|
|
||||||
|
function emailMatchesUsername(email: string, username: string): boolean {
|
||||||
|
if (email === username) return true;
|
||||||
|
if (!username.includes('@') && email.split('@')[0] === username) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
interface IdentityFormData {
|
interface IdentityFormData {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -39,6 +45,8 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
|
|
||||||
const client = useAuthStore((state) => state.client);
|
const client = useAuthStore((state) => state.client);
|
||||||
const identities = useIdentityStore((state) => state.identities);
|
const identities = useIdentityStore((state) => state.identities);
|
||||||
|
const preferredPrimaryId = useIdentityStore((state) => state.preferredPrimaryId);
|
||||||
|
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
|
||||||
const syncIdentities = useSyncIdentities();
|
const syncIdentities = useSyncIdentities();
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
@@ -52,11 +60,26 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
try {
|
try {
|
||||||
const serverIdentities = await client.getIdentities();
|
const serverIdentities = await client.getIdentities();
|
||||||
const username = useAuthStore.getState().username;
|
const username = useAuthStore.getState().username;
|
||||||
|
const preferredPrimaryId = useIdentityStore.getState().preferredPrimaryId;
|
||||||
const sorted = [...serverIdentities].sort((a, b) => {
|
const sorted = [...serverIdentities].sort((a, b) => {
|
||||||
const aMatch = a.email === username ? -1 : 0;
|
const aMatch = emailMatchesUsername(a.email, username || '');
|
||||||
const bMatch = b.email === username ? -1 : 0;
|
const bMatch = emailMatchesUsername(b.email, username || '');
|
||||||
return aMatch - bMatch;
|
if (aMatch && !bMatch) return -1;
|
||||||
|
if (!aMatch && bMatch) return 1;
|
||||||
|
if (aMatch && bMatch) {
|
||||||
|
if (!a.mayDelete && b.mayDelete) return -1;
|
||||||
|
if (a.mayDelete && !b.mayDelete) return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
});
|
});
|
||||||
|
// Move preferred primary to front if set
|
||||||
|
if (preferredPrimaryId) {
|
||||||
|
const idx = sorted.findIndex((id) => id.id === preferredPrimaryId);
|
||||||
|
if (idx > 0) {
|
||||||
|
const [preferred] = sorted.splice(idx, 1);
|
||||||
|
sorted.unshift(preferred);
|
||||||
|
}
|
||||||
|
}
|
||||||
useIdentityStore.getState().setIdentities(sorted);
|
useIdentityStore.getState().setIdentities(sorted);
|
||||||
syncIdentities();
|
syncIdentities();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -168,6 +191,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
}
|
}
|
||||||
}, [client, refreshIdentities, t, tNotif, confirmDialog]);
|
}, [client, refreshIdentities, t, tNotif, confirmDialog]);
|
||||||
|
|
||||||
|
const handleSetPrimary = useCallback((identity: Identity) => {
|
||||||
|
setPreferredPrimary(identity.id);
|
||||||
|
// Re-sort: move the preferred identity to the front
|
||||||
|
const reordered = [identity, ...identities.filter((id) => id.id !== identity.id)];
|
||||||
|
useIdentityStore.getState().setIdentities(reordered);
|
||||||
|
syncIdentities();
|
||||||
|
toast.success(tNotif('identity_set_primary'));
|
||||||
|
}, [identities, setPreferredPrimary, syncIdentities, tNotif]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -281,6 +313,17 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{identities[0]?.id !== identity.id && identities.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSetPrimary(identity)}
|
||||||
|
disabled={!!editingId || isCreating}
|
||||||
|
title={t('set_as_primary')}
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function SubAddressHelper({
|
|||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-full left-0 mt-1 z-50',
|
'absolute top-full right-0 mt-1 z-50',
|
||||||
'bg-background border border-border rounded-lg shadow-lg',
|
'bg-background border border-border rounded-lg shadow-lg',
|
||||||
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
|
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
interface AccountSwitcherProps {
|
||||||
|
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
|
||||||
|
variant?: "rail" | "expanded";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
|
||||||
|
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||||
|
const sizeClasses = size === "sm" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)}
|
||||||
|
style={{ backgroundColor: account.avatarColor }}
|
||||||
|
title={account.label}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountSwitcher({ variant = "rail", className }: AccountSwitcherProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
||||||
|
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
||||||
|
const activeAccount = accounts.find((a) => a.id === activeAccountId);
|
||||||
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||||
|
const primaryIdentity = useAuthStore((s) => s.primaryIdentity);
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (!buttonRef.current) return;
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
if (variant === "rail") {
|
||||||
|
setPopoverStyle({
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.right + 8,
|
||||||
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setPopoverStyle({
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [variant]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
updatePosition();
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
buttonRef.current?.contains(e.target as Node) ||
|
||||||
|
popoverRef.current?.contains(e.target as Node)
|
||||||
|
) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [open, updatePosition]);
|
||||||
|
|
||||||
|
const handleSwitch = async (accountId: string) => {
|
||||||
|
if (accountId === activeAccountId) return;
|
||||||
|
setOpen(false);
|
||||||
|
await switchAccount(accountId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAccount = () => {
|
||||||
|
setOpen(false);
|
||||||
|
router.push(`/login?mode=add-account` as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
setOpen(false);
|
||||||
|
logout();
|
||||||
|
if (useAccountStore.getState().accounts.length === 0) {
|
||||||
|
router.push("/login" as never);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoutAll = () => {
|
||||||
|
setOpen(false);
|
||||||
|
logoutAll();
|
||||||
|
router.push("/login" as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetDefault = (accountId: string) => {
|
||||||
|
setDefaultAccount(accountId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display name for the active account
|
||||||
|
const displayName = primaryIdentity?.name || activeAccount?.displayName || activeAccount?.label || "";
|
||||||
|
const displayEmail = primaryIdentity?.email || activeAccount?.email || activeAccount?.username || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-md transition-colors",
|
||||||
|
variant === "rail"
|
||||||
|
? "justify-center w-10 h-10 hover:bg-muted"
|
||||||
|
: "w-full px-2 py-1.5 hover:bg-muted text-left min-w-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
title={variant === "rail" ? (displayName || displayEmail) : undefined}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
{activeAccount ? (
|
||||||
|
<>
|
||||||
|
<AccountAvatar account={activeAccount} size={variant === "rail" ? "sm" : "md"} />
|
||||||
|
{variant === "expanded" && (
|
||||||
|
<>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">{displayName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{displayEmail}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn("w-3.5 h-3.5 text-muted-foreground flex-shrink-0 transition-transform", open && "rotate-180")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={cn(
|
||||||
|
"rounded-full bg-muted flex items-center justify-center text-muted-foreground",
|
||||||
|
variant === "rail" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm"
|
||||||
|
)}>
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && createPortal(
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
style={popoverStyle}
|
||||||
|
className="w-72 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
{/* Account List */}
|
||||||
|
<div className="py-1 max-h-64 overflow-y-auto">
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const isActive = account.id === activeAccountId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={account.id}
|
||||||
|
onClick={() => handleSwitch(account.id)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-start gap-3 px-3 py-2.5 text-left transition-colors",
|
||||||
|
isActive ? "bg-accent/50" : "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
role="menuitem"
|
||||||
|
disabled={isActive}
|
||||||
|
>
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<AccountAvatar account={account} size="md" />
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<Check className="w-2.5 h-2.5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{account.displayName || account.label}
|
||||||
|
</span>
|
||||||
|
{account.isDefault && (
|
||||||
|
<Star className="w-3 h-3 text-amber-500 flex-shrink-0 fill-amber-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{account.email || account.username}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
|
{account.hasError ? (
|
||||||
|
<AlertCircle className="w-3 h-3 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<span className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full",
|
||||||
|
account.isConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{new URL(account.serverUrl).hostname}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator + Add Account */}
|
||||||
|
{accounts.length < MAX_ACCOUNTS && (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={handleAddAccount}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t("add_account")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Separator + Actions */}
|
||||||
|
<div className="border-t border-border">
|
||||||
|
{activeAccount && !activeAccount.isDefault && accounts.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetDefault(activeAccount.id)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
{t("set_as_default")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
{t("sign_out_of", { account: displayEmail })}
|
||||||
|
</button>
|
||||||
|
{accounts.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={handleLogoutAll}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
{t("sign_out_all")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
|
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
|
||||||
|
import { AccountSwitcher } from "./account-switcher";
|
||||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||||
import { usePathname, Link } from "@/i18n/navigation";
|
import { usePathname, Link } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -432,13 +433,7 @@ export function NavigationRail({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{onLogout && (
|
{onLogout && (
|
||||||
<button
|
<AccountSwitcher variant="rail" />
|
||||||
onClick={onLogout}
|
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
||||||
title={t("sign_out")}
|
|
||||||
>
|
|
||||||
<LogOut className="w-[18px] h-[18px]" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { toast } from "@/stores/toast-store";
|
|||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
|
import { AccountSwitcher } from "./account-switcher";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
mailboxes: Mailbox[];
|
mailboxes: Mailbox[];
|
||||||
@@ -485,15 +486,8 @@ export function Sidebar({
|
|||||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isCollapsed && primaryIdentity && (
|
{!isCollapsed && (
|
||||||
<div className="min-w-0">
|
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||||
<p className="text-sm font-medium text-foreground truncate" title={primaryIdentity.name}>
|
|
||||||
{primaryIdentity.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate" title={primaryIdentity.email}>
|
|
||||||
{primaryIdentity.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ export function CalendarManagementSettings() {
|
|||||||
const buildCalDavUrl = (calendarId: string) => {
|
const buildCalDavUrl = (calendarId: string) => {
|
||||||
if (!serverUrl || !username) return null;
|
if (!serverUrl || !username) return null;
|
||||||
const base = serverUrl.replace(/\/$/, '');
|
const base = serverUrl.replace(/\/$/, '');
|
||||||
return `${base}/dav/calendars/user/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
|
return `${base}/dav/cal/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyUrl = async (url: string) => {
|
const handleCopyUrl = async (url: string) => {
|
||||||
|
|||||||
@@ -239,9 +239,10 @@ export function useTimeGridInteractions({
|
|||||||
clearTimeout(clickTimerRef.current);
|
clearTimeout(clickTimerRef.current);
|
||||||
clickTimerRef.current = null;
|
clickTimerRef.current = null;
|
||||||
}
|
}
|
||||||
const key = format(day, "yyyy-MM-dd");
|
const d = new Date(day);
|
||||||
setQuickCreate({ dayKey: key, day, hour, top: hour * hourHeight });
|
d.setHours(hour, 0, 0, 0);
|
||||||
}, [hourHeight]);
|
onCreateRange(d);
|
||||||
|
}, [onCreateRange]);
|
||||||
|
|
||||||
const handleQuickCreateSubmit = useCallback(async (title: string) => {
|
const handleQuickCreateSubmit = useCallback(async (title: string) => {
|
||||||
if (!quickCreate) return;
|
if (!quickCreate) return;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @vitest-environment node
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ describe("parseVCard", () => {
|
|||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
const card = result[0];
|
const card = result[0];
|
||||||
|
|
||||||
expect(card.gender).toEqual({ sex: "F", identity: "Female" });
|
expect(card.speakToAs).toEqual({ grammaticalGender: "feminine", pronouns: { p0: { pronouns: "Female" } } });
|
||||||
expect(card.media?.m0).toEqual({
|
expect(card.media?.m0).toEqual({
|
||||||
kind: "logo",
|
kind: "logo",
|
||||||
uri: "https://example.com/logo.png",
|
uri: "https://example.com/logo.png",
|
||||||
@@ -331,7 +331,7 @@ describe("generateVCard", () => {
|
|||||||
components: [{ kind: "given", value: "Jane" }],
|
components: [{ kind: "given", value: "Jane" }],
|
||||||
isOrdered: true,
|
isOrdered: true,
|
||||||
},
|
},
|
||||||
gender: { sex: "F", identity: "Female" },
|
speakToAs: { grammaticalGender: "feminine", pronouns: { p0: { pronouns: "Female" } } },
|
||||||
media: {
|
media: {
|
||||||
m0: { kind: "logo", uri: "https://example.com/logo.png", mediaType: "image/png" },
|
m0: { kind: "logo", uri: "https://example.com/logo.png", mediaType: "image/png" },
|
||||||
m1: { kind: "sound", uri: "https://example.com/sound.ogg", mediaType: "audio/ogg" },
|
m1: { kind: "sound", uri: "https://example.com/sound.ogg", mediaType: "audio/ogg" },
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Manages per-account state snapshots for fast switching.
|
||||||
|
* When user switches from Account A → B, we snapshot A's store state
|
||||||
|
* into memory, clear stores, then restore B's cached state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
|
import { useContactStore } from '@/stores/contact-store';
|
||||||
|
import { useCalendarStore } from '@/stores/calendar-store';
|
||||||
|
import { useFilterStore } from '@/stores/filter-store';
|
||||||
|
import { useIdentityStore } from '@/stores/identity-store';
|
||||||
|
import { useVacationStore } from '@/stores/vacation-store';
|
||||||
|
|
||||||
|
// Minimal snapshot shapes — we only capture what we need
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type StoreSnapshot = Record<string, any>;
|
||||||
|
|
||||||
|
interface AccountSnapshot {
|
||||||
|
email: StoreSnapshot;
|
||||||
|
contact: StoreSnapshot;
|
||||||
|
calendar: StoreSnapshot;
|
||||||
|
filter: StoreSnapshot;
|
||||||
|
identity: StoreSnapshot;
|
||||||
|
vacation: StoreSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, AccountSnapshot>();
|
||||||
|
|
||||||
|
/** Capture current store states for the given account */
|
||||||
|
export function snapshotAccount(accountId: string): void {
|
||||||
|
const emailState = useEmailStore.getState();
|
||||||
|
const contactState = useContactStore.getState();
|
||||||
|
const calendarState = useCalendarStore.getState();
|
||||||
|
const filterState = useFilterStore.getState();
|
||||||
|
const identityState = useIdentityStore.getState();
|
||||||
|
const vacationState = useVacationStore.getState();
|
||||||
|
|
||||||
|
cache.set(accountId, {
|
||||||
|
email: {
|
||||||
|
emails: emailState.emails,
|
||||||
|
mailboxes: emailState.mailboxes,
|
||||||
|
selectedEmail: emailState.selectedEmail,
|
||||||
|
selectedMailbox: emailState.selectedMailbox,
|
||||||
|
searchQuery: emailState.searchQuery,
|
||||||
|
quota: emailState.quota,
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
contacts: contactState.contacts,
|
||||||
|
addressBooks: contactState.addressBooks,
|
||||||
|
supportsSync: contactState.supportsSync,
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
calendars: calendarState.calendars,
|
||||||
|
events: calendarState.events,
|
||||||
|
selectedCalendarIds: calendarState.selectedCalendarIds,
|
||||||
|
viewMode: calendarState.viewMode,
|
||||||
|
supportsCalendar: calendarState.supportsCalendar,
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
rules: filterState.rules,
|
||||||
|
isSupported: filterState.isSupported,
|
||||||
|
},
|
||||||
|
identity: {
|
||||||
|
identities: identityState.identities,
|
||||||
|
preferredPrimaryId: identityState.preferredPrimaryId,
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
isEnabled: vacationState.isEnabled,
|
||||||
|
isSupported: vacationState.isSupported,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore cached store states for the given account. Returns false if no cache exists. */
|
||||||
|
export function restoreAccount(accountId: string): boolean {
|
||||||
|
const snapshot = cache.get(accountId);
|
||||||
|
if (!snapshot) return false;
|
||||||
|
|
||||||
|
useEmailStore.setState(snapshot.email);
|
||||||
|
useContactStore.setState(snapshot.contact);
|
||||||
|
useCalendarStore.setState(snapshot.calendar);
|
||||||
|
useFilterStore.setState(snapshot.filter);
|
||||||
|
useIdentityStore.setState(snapshot.identity);
|
||||||
|
useVacationStore.setState(snapshot.vacation);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear all stores (used before restoring a different account) */
|
||||||
|
export function clearAllStores(): void {
|
||||||
|
useEmailStore.setState({
|
||||||
|
emails: [],
|
||||||
|
mailboxes: [],
|
||||||
|
selectedEmail: null,
|
||||||
|
selectedMailbox: '',
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
searchQuery: '',
|
||||||
|
quota: null,
|
||||||
|
});
|
||||||
|
useIdentityStore.getState().clearIdentities();
|
||||||
|
useContactStore.getState().clearContacts();
|
||||||
|
useVacationStore.getState().clearState();
|
||||||
|
useCalendarStore.getState().clearState();
|
||||||
|
useFilterStore.getState().clearState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evict cached state for one account */
|
||||||
|
export function evictAccount(accountId: string): void {
|
||||||
|
cache.delete(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evict all cached states */
|
||||||
|
export function evictAll(): void {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Utilities for multi-account support:
|
||||||
|
* - Account ID generation
|
||||||
|
* - Deterministic avatar colors
|
||||||
|
* - Account-scoped localStorage keys
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Generate a unique, deterministic account ID from username and server URL */
|
||||||
|
export function generateAccountId(username: string, serverUrl: string): string {
|
||||||
|
const host = new URL(serverUrl).hostname;
|
||||||
|
return `${username}@${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic avatar/accent color from an email string */
|
||||||
|
export function generateAvatarColor(email: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < email.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + email.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
// 12 distinct, accessible hues
|
||||||
|
const colors = [
|
||||||
|
'#2563eb', // blue
|
||||||
|
'#7c3aed', // violet
|
||||||
|
'#db2777', // pink
|
||||||
|
'#dc2626', // red
|
||||||
|
'#ea580c', // orange
|
||||||
|
'#d97706', // amber
|
||||||
|
'#65a30d', // lime
|
||||||
|
'#16a34a', // green
|
||||||
|
'#0d9488', // teal
|
||||||
|
'#0891b2', // cyan
|
||||||
|
'#6366f1', // indigo
|
||||||
|
'#9333ea', // purple
|
||||||
|
];
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get initials for an avatar from a display name or email */
|
||||||
|
export function getInitials(name: string, email?: string): string {
|
||||||
|
if (name) {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return parts[0][0]?.toUpperCase() ?? '?';
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
return email[0]?.toUpperCase() ?? '?';
|
||||||
|
}
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an account-scoped localStorage key */
|
||||||
|
export function getAccountScopedKey(baseKey: string, accountId: string): string {
|
||||||
|
return `${baseKey}::${accountId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum number of accounts allowed */
|
||||||
|
export const MAX_ACCOUNTS = 5;
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
export const SESSION_COOKIE = 'jmap_session';
|
export const SESSION_COOKIE = 'jmap_session';
|
||||||
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
||||||
|
export function sessionCookieName(slot: number): string {
|
||||||
|
return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca
|
|||||||
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
|
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
|
||||||
return left.event.showWithoutTime ? -1 : 1;
|
return left.event.showWithoutTime ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
const timeDiff = new Date(left.event.start).getTime() - new Date(right.event.start).getTime();
|
||||||
|
if (timeDiff !== 0) return timeDiff;
|
||||||
return (left.event.title || "").localeCompare(right.event.title || "");
|
return (left.event.title || "").localeCompare(right.event.title || "");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+634
-92
@@ -1088,7 +1088,16 @@ export class JMAPClient {
|
|||||||
["Email/get", {
|
["Email/get", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
ids: thread.emailIds,
|
ids: thread.emailIds,
|
||||||
properties: [...EMAIL_LIST_PROPERTIES],
|
properties: [
|
||||||
|
...EMAIL_LIST_PROPERTIES,
|
||||||
|
"textBody", "htmlBody", "bodyValues",
|
||||||
|
"attachments", "blobId", "sentAt", "bcc", "replyTo",
|
||||||
|
"messageId", "inReplyTo", "references", "headers", "bodyStructure",
|
||||||
|
],
|
||||||
|
fetchTextBodyValues: true,
|
||||||
|
fetchHTMLBodyValues: true,
|
||||||
|
fetchAllBodyValues: true,
|
||||||
|
maxBodyValueBytes: 256000,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1396,9 +1405,10 @@ export class JMAPClient {
|
|||||||
fromEmail?: string,
|
fromEmail?: string,
|
||||||
draftId?: string,
|
draftId?: string,
|
||||||
fromName?: string,
|
fromName?: string,
|
||||||
htmlBody?: string
|
htmlBody?: string,
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const emailId = draftId || `draft-${Date.now()}`;
|
const emailId = `send-${Date.now()}`;
|
||||||
const mailboxes = await this.getMailboxes();
|
const mailboxes = await this.getMailboxes();
|
||||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
if (!sentMailbox) {
|
if (!sentMailbox) {
|
||||||
@@ -1415,54 +1425,64 @@ export class JMAPClient {
|
|||||||
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||||
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
||||||
if (identities.length > 0) {
|
if (identities.length > 0) {
|
||||||
const matchingIdentity = identities.find((id) => id.email === (fromEmail || this.username));
|
const target = fromEmail || this.username;
|
||||||
|
const matchingIdentity = identities.find((id) => id.email === target)
|
||||||
|
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
|
||||||
finalIdentityId = matchingIdentity?.id || identities[0].id;
|
finalIdentityId = matchingIdentity?.id || identities[0].id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always create a new email with the final body content
|
||||||
|
const emailCreate: Record<string, unknown> = {
|
||||||
|
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
||||||
|
to: to.map(email => ({ email })),
|
||||||
|
cc: cc?.map(email => ({ email })),
|
||||||
|
bcc: bcc?.map(email => ({ email })),
|
||||||
|
subject,
|
||||||
|
keywords: { "$seen": true },
|
||||||
|
mailboxIds: { [sentMailbox.id]: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (htmlBody) {
|
||||||
|
// Send as multipart/alternative with both text and HTML
|
||||||
|
emailCreate.bodyValues = {
|
||||||
|
"text": { value: body },
|
||||||
|
"html": { value: htmlBody },
|
||||||
|
};
|
||||||
|
emailCreate.textBody = [{ partId: "text", type: "text/plain" }];
|
||||||
|
emailCreate.htmlBody = [{ partId: "html", type: "text/html" }];
|
||||||
|
} else {
|
||||||
|
emailCreate.bodyValues = { "1": { value: body } };
|
||||||
|
emailCreate.textBody = [{ partId: "1", type: "text/plain" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments?.length) {
|
||||||
|
emailCreate.attachments = attachments.map(att => ({
|
||||||
|
blobId: att.blobId,
|
||||||
|
type: att.type,
|
||||||
|
name: att.name,
|
||||||
|
disposition: "attachment",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const methodCalls: JMAPMethodCall[] = [];
|
const methodCalls: JMAPMethodCall[] = [];
|
||||||
|
|
||||||
if (draftId) {
|
if (draftId) {
|
||||||
|
// Destroy the old draft and create a new email with the final body
|
||||||
methodCalls.push(["Email/set", {
|
methodCalls.push(["Email/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
update: {
|
destroy: [draftId],
|
||||||
[draftId]: {
|
|
||||||
"keywords/$draft": false,
|
|
||||||
"keywords/$seen": true,
|
|
||||||
mailboxIds: { [sentMailbox.id]: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, "0"]);
|
}, "0"]);
|
||||||
|
methodCalls.push(["Email/set", {
|
||||||
|
accountId: this.accountId,
|
||||||
|
create: { [emailId]: emailCreate },
|
||||||
|
}, "1"]);
|
||||||
methodCalls.push(["EmailSubmission/set", {
|
methodCalls.push(["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
create: { "1": { emailId: draftId, identityId: finalIdentityId } },
|
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
||||||
}, "1"]);
|
}, "2"]);
|
||||||
} else {
|
} else {
|
||||||
// Build email body parts - include HTML if available
|
|
||||||
const emailCreate: Record<string, unknown> = {
|
|
||||||
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
|
||||||
to: to.map(email => ({ email })),
|
|
||||||
cc: cc?.map(email => ({ email })),
|
|
||||||
bcc: bcc?.map(email => ({ email })),
|
|
||||||
subject,
|
|
||||||
keywords: { "$seen": true },
|
|
||||||
mailboxIds: { [sentMailbox.id]: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (htmlBody) {
|
|
||||||
// Send as multipart/alternative with both text and HTML
|
|
||||||
emailCreate.bodyValues = {
|
|
||||||
"text": { value: body },
|
|
||||||
"html": { value: htmlBody },
|
|
||||||
};
|
|
||||||
emailCreate.textBody = [{ partId: "text" }];
|
|
||||||
emailCreate.htmlBody = [{ partId: "html" }];
|
|
||||||
} else {
|
|
||||||
emailCreate.bodyValues = { "1": { value: body } };
|
|
||||||
emailCreate.textBody = [{ partId: "1" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
methodCalls.push(["Email/set", {
|
methodCalls.push(["Email/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
create: { [emailId]: emailCreate },
|
create: { [emailId]: emailCreate },
|
||||||
@@ -1482,8 +1502,8 @@ export class JMAPClient {
|
|||||||
throw new Error(result.description || `Failed to send email: ${result.type}`);
|
throw new Error(result.description || `Failed to send email: ${result.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.notCreated || result.notUpdated) {
|
if (result.notCreated) {
|
||||||
const errors = result.notCreated || result.notUpdated;
|
const errors = result.notCreated;
|
||||||
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
|
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
|
||||||
console.error('Email send error:', firstError);
|
console.error('Email send error:', firstError);
|
||||||
throw new Error(firstError?.description || firstError?.type || 'Failed to send email');
|
throw new Error(firstError?.description || firstError?.type || 'Failed to send email');
|
||||||
@@ -1665,6 +1685,290 @@ export class JMAPClient {
|
|||||||
console.log('[iMIP DEBUG] sendImipReply completed successfully');
|
console.log('[iMIP DEBUG] sendImipReply completed successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an iMIP (RFC 6047) REQUEST email to all participants of a calendar event.
|
||||||
|
* Used when creating or updating an event with participants.
|
||||||
|
*/
|
||||||
|
async sendImipInvitation(event: CalendarEvent): Promise<void> {
|
||||||
|
if (!event.participants) return;
|
||||||
|
|
||||||
|
const mailboxes = await this.getMailboxes();
|
||||||
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
|
if (!sentMailbox) {
|
||||||
|
throw new Error('No sent mailbox found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the organizer participant
|
||||||
|
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
|
||||||
|
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username;
|
||||||
|
const organizerName = organizerEntry?.name || '';
|
||||||
|
|
||||||
|
// Resolve identity
|
||||||
|
const identityResponse = await this.request([
|
||||||
|
["Identity/get", { accountId: this.accountId }, "0"]
|
||||||
|
]);
|
||||||
|
let identityId = this.accountId;
|
||||||
|
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||||
|
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
||||||
|
const match = identities.find((id) => id.email === organizerEmail);
|
||||||
|
identityId = match?.id || identities[0]?.id || this.accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect attendee participants (non-organizer)
|
||||||
|
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner);
|
||||||
|
if (attendees.length === 0) return;
|
||||||
|
|
||||||
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
|
||||||
|
const formatIcalDate = (dateStr: string, tz?: string | null): string => {
|
||||||
|
if (dateStr.endsWith('Z')) {
|
||||||
|
return dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
}
|
||||||
|
const basic = dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
if (tz) return `TZID=${tz}:${basic}`;
|
||||||
|
return basic;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'PRODID:-//JMAP-Webmail//EN',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:REQUEST',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${event.uid}`,
|
||||||
|
`DTSTAMP:${now}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (event.start) {
|
||||||
|
if (event.showWithoutTime) {
|
||||||
|
const dateOnly = event.start.replace(/[-]/g, '').substring(0, 8);
|
||||||
|
lines.push(`DTSTART;VALUE=DATE:${dateOnly}`);
|
||||||
|
} else {
|
||||||
|
const formatted = formatIcalDate(event.start, event.timeZone);
|
||||||
|
lines.push(formatted.startsWith('TZID=') ? `DTSTART;${formatted}` : `DTSTART:${formatted}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.utcEnd) {
|
||||||
|
if (event.showWithoutTime) {
|
||||||
|
const dateOnly = event.utcEnd.replace(/[-]/g, '').substring(0, 8);
|
||||||
|
lines.push(`DTEND;VALUE=DATE:${dateOnly}`);
|
||||||
|
} else {
|
||||||
|
const formatted = formatIcalDate(event.utcEnd, event.timeZone);
|
||||||
|
lines.push(formatted.startsWith('TZID=') ? `DTEND;${formatted}` : `DTEND:${formatted}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.title) lines.push(`SUMMARY:${event.title}`);
|
||||||
|
if (event.description) lines.push(`DESCRIPTION:${event.description}`);
|
||||||
|
if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`);
|
||||||
|
if (event.status) lines.push(`STATUS:${event.status.toUpperCase()}`);
|
||||||
|
|
||||||
|
const orgCn = organizerName ? `;CN=${organizerName}` : '';
|
||||||
|
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
|
||||||
|
|
||||||
|
for (const attendee of attendees) {
|
||||||
|
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', '');
|
||||||
|
if (!email) continue;
|
||||||
|
const cn = attendee.name ? `;CN=${attendee.name}` : '';
|
||||||
|
const partstat = attendee.participationStatus
|
||||||
|
? `;PARTSTAT=${attendee.participationStatus.toUpperCase()}`
|
||||||
|
: ';PARTSTAT=NEEDS-ACTION';
|
||||||
|
const rsvp = attendee.expectReply ? ';RSVP=TRUE' : '';
|
||||||
|
lines.push(`ATTENDEE${cn}${partstat}${rsvp}:mailto:${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('END:VEVENT');
|
||||||
|
lines.push('END:VCALENDAR');
|
||||||
|
const icsContent = lines.join('\r\n') + '\r\n';
|
||||||
|
|
||||||
|
const subject = `Invitation: ${event.title || 'Event'}`;
|
||||||
|
const toAddresses = attendees
|
||||||
|
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' }))
|
||||||
|
.filter(a => a.email);
|
||||||
|
|
||||||
|
if (toAddresses.length === 0) return;
|
||||||
|
|
||||||
|
const emailId = `imip-invite-${Date.now()}`;
|
||||||
|
const emailCreate: Record<string, unknown> = {
|
||||||
|
from: [{ name: organizerName || undefined, email: organizerEmail }],
|
||||||
|
to: toAddresses,
|
||||||
|
subject,
|
||||||
|
keywords: { "$seen": true },
|
||||||
|
mailboxIds: { [sentMailbox.id]: true },
|
||||||
|
bodyStructure: {
|
||||||
|
type: 'multipart/alternative',
|
||||||
|
subParts: [
|
||||||
|
{ partId: 'text', type: 'text/plain' },
|
||||||
|
{ partId: 'cal', type: 'text/calendar; method=REQUEST' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
bodyValues: {
|
||||||
|
text: { value: `You have been invited to: ${event.title || 'Event'}` },
|
||||||
|
cal: { value: icsContent },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const methodCalls: JMAPMethodCall[] = [
|
||||||
|
["Email/set", {
|
||||||
|
accountId: this.accountId,
|
||||||
|
create: { [emailId]: emailCreate },
|
||||||
|
}, "0"],
|
||||||
|
["EmailSubmission/set", {
|
||||||
|
accountId: this.accountId,
|
||||||
|
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||||
|
}, "1"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await this.request(methodCalls);
|
||||||
|
|
||||||
|
if (response.methodResponses) {
|
||||||
|
for (const [methodName, result] of response.methodResponses) {
|
||||||
|
if (methodName.endsWith('/error')) {
|
||||||
|
throw new Error(result.description || `iMIP invitation failed: ${result.type}`);
|
||||||
|
}
|
||||||
|
if (result.notCreated) {
|
||||||
|
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
||||||
|
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP invitation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an iMIP (RFC 6047) CANCEL email to all participants of a calendar event.
|
||||||
|
* Used when deleting an event that has participants.
|
||||||
|
*/
|
||||||
|
async sendImipCancellation(event: CalendarEvent): Promise<void> {
|
||||||
|
if (!event.participants) return;
|
||||||
|
|
||||||
|
const mailboxes = await this.getMailboxes();
|
||||||
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
|
if (!sentMailbox) {
|
||||||
|
throw new Error('No sent mailbox found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
|
||||||
|
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username;
|
||||||
|
const organizerName = organizerEntry?.name || '';
|
||||||
|
|
||||||
|
const identityResponse = await this.request([
|
||||||
|
["Identity/get", { accountId: this.accountId }, "0"]
|
||||||
|
]);
|
||||||
|
let identityId = this.accountId;
|
||||||
|
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||||
|
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
||||||
|
const match = identities.find((id) => id.email === organizerEmail);
|
||||||
|
identityId = match?.id || identities[0]?.id || this.accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner);
|
||||||
|
if (attendees.length === 0) return;
|
||||||
|
|
||||||
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
|
||||||
|
const formatIcalDate = (dateStr: string, tz?: string | null): string => {
|
||||||
|
if (dateStr.endsWith('Z')) {
|
||||||
|
return dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
}
|
||||||
|
const basic = dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
if (tz) return `TZID=${tz}:${basic}`;
|
||||||
|
return basic;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'PRODID:-//JMAP-Webmail//EN',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:CANCEL',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${event.uid}`,
|
||||||
|
`DTSTAMP:${now}`,
|
||||||
|
`STATUS:CANCELLED`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (event.start) {
|
||||||
|
if (event.showWithoutTime) {
|
||||||
|
const dateOnly = event.start.replace(/[-]/g, '').substring(0, 8);
|
||||||
|
lines.push(`DTSTART;VALUE=DATE:${dateOnly}`);
|
||||||
|
} else {
|
||||||
|
const formatted = formatIcalDate(event.start, event.timeZone);
|
||||||
|
lines.push(formatted.startsWith('TZID=') ? `DTSTART;${formatted}` : `DTSTART:${formatted}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.title) lines.push(`SUMMARY:${event.title}`);
|
||||||
|
if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`);
|
||||||
|
|
||||||
|
const orgCn = organizerName ? `;CN=${organizerName}` : '';
|
||||||
|
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
|
||||||
|
|
||||||
|
for (const attendee of attendees) {
|
||||||
|
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', '');
|
||||||
|
if (!email) continue;
|
||||||
|
const cn = attendee.name ? `;CN=${attendee.name}` : '';
|
||||||
|
lines.push(`ATTENDEE${cn}:mailto:${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('END:VEVENT');
|
||||||
|
lines.push('END:VCALENDAR');
|
||||||
|
const icsContent = lines.join('\r\n') + '\r\n';
|
||||||
|
|
||||||
|
const subject = `Cancelled: ${event.title || 'Event'}`;
|
||||||
|
const toAddresses = attendees
|
||||||
|
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' }))
|
||||||
|
.filter(a => a.email);
|
||||||
|
|
||||||
|
if (toAddresses.length === 0) return;
|
||||||
|
|
||||||
|
const emailId = `imip-cancel-${Date.now()}`;
|
||||||
|
const emailCreate: Record<string, unknown> = {
|
||||||
|
from: [{ name: organizerName || undefined, email: organizerEmail }],
|
||||||
|
to: toAddresses,
|
||||||
|
subject,
|
||||||
|
keywords: { "$seen": true },
|
||||||
|
mailboxIds: { [sentMailbox.id]: true },
|
||||||
|
bodyStructure: {
|
||||||
|
type: 'multipart/alternative',
|
||||||
|
subParts: [
|
||||||
|
{ partId: 'text', type: 'text/plain' },
|
||||||
|
{ partId: 'cal', type: 'text/calendar; method=CANCEL' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
bodyValues: {
|
||||||
|
text: { value: `The event "${event.title || 'Event'}" has been cancelled.` },
|
||||||
|
cal: { value: icsContent },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const methodCalls: JMAPMethodCall[] = [
|
||||||
|
["Email/set", {
|
||||||
|
accountId: this.accountId,
|
||||||
|
create: { [emailId]: emailCreate },
|
||||||
|
}, "0"],
|
||||||
|
["EmailSubmission/set", {
|
||||||
|
accountId: this.accountId,
|
||||||
|
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||||
|
}, "1"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await this.request(methodCalls);
|
||||||
|
|
||||||
|
if (response.methodResponses) {
|
||||||
|
for (const [methodName, result] of response.methodResponses) {
|
||||||
|
if (methodName.endsWith('/error')) {
|
||||||
|
throw new Error(result.description || `iMIP cancellation failed: ${result.type}`);
|
||||||
|
}
|
||||||
|
if (result.notCreated) {
|
||||||
|
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
||||||
|
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP cancellation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
||||||
if (!this.session) {
|
if (!this.session) {
|
||||||
throw new Error('Not connected. Call connect() first.');
|
throw new Error('Not connected. Call connect() first.');
|
||||||
@@ -2036,6 +2340,38 @@ export class JMAPClient {
|
|||||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
|
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCalendarCapableAccountIds(): string[] {
|
||||||
|
const primaryId = this.getCalendarsAccountId();
|
||||||
|
const accountIds: string[] = [];
|
||||||
|
for (const [id, account] of Object.entries(this.accounts)) {
|
||||||
|
if (id === primaryId) continue;
|
||||||
|
// Include accounts that either advertise calendar capability
|
||||||
|
// or are non-personal (shared/group) accounts — Stalwart doesn't
|
||||||
|
// always advertise capabilities on group accounts even when they
|
||||||
|
// have calendar resources.
|
||||||
|
if (account.accountCapabilities?.["urn:ietf:params:jmap:calendars"] || !account.isPersonal) {
|
||||||
|
accountIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [primaryId, ...accountIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContactCapableAccountIds(): string[] {
|
||||||
|
const primaryId = this.getContactsAccountId();
|
||||||
|
const accountIds: string[] = [];
|
||||||
|
for (const [id, account] of Object.entries(this.accounts)) {
|
||||||
|
if (id === primaryId) continue;
|
||||||
|
// Include accounts that either advertise contacts capability
|
||||||
|
// or are non-personal (shared/group) accounts — Stalwart doesn't
|
||||||
|
// always advertise capabilities on group accounts even when they
|
||||||
|
// have contact resources.
|
||||||
|
if (account.accountCapabilities?.["urn:ietf:params:jmap:contacts"] || !account.isPersonal) {
|
||||||
|
accountIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [primaryId, ...accountIds];
|
||||||
|
}
|
||||||
|
|
||||||
async getAddressBooks(): Promise<AddressBook[]> {
|
async getAddressBooks(): Promise<AddressBook[]> {
|
||||||
try {
|
try {
|
||||||
const accountId = this.getContactsAccountId();
|
const accountId = this.getContactsAccountId();
|
||||||
@@ -2053,6 +2389,45 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllAddressBooks(): Promise<AddressBook[]> {
|
||||||
|
try {
|
||||||
|
const allBooks: AddressBook[] = [];
|
||||||
|
const primaryId = this.getContactsAccountId();
|
||||||
|
const accountIds = this.getContactCapableAccountIds();
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const isPrimary = accountId === primaryId;
|
||||||
|
const account = this.accounts[accountId];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.request([
|
||||||
|
["AddressBook/get", { accountId }, "0"]
|
||||||
|
], this.contactUsing());
|
||||||
|
|
||||||
|
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
|
||||||
|
const rawBooks = (response.methodResponses[0][1].list || []) as AddressBook[];
|
||||||
|
const books = rawBooks.map((book) => ({
|
||||||
|
...book,
|
||||||
|
id: isPrimary ? book.id : `${accountId}:${book.id}`,
|
||||||
|
originalId: book.id,
|
||||||
|
accountId,
|
||||||
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||||
|
isShared: !isPrimary,
|
||||||
|
}));
|
||||||
|
allBooks.push(...books);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch address books for account ${accountId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allBooks;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch all address books:', error);
|
||||||
|
return this.getAddressBooks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||||
try {
|
try {
|
||||||
const accountId = this.getContactsAccountId();
|
const accountId = this.getContactsAccountId();
|
||||||
@@ -2079,12 +2454,58 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContact(contactId: string): Promise<ContactCard | null> {
|
async getAllContacts(): Promise<ContactCard[]> {
|
||||||
try {
|
try {
|
||||||
const accountId = this.getContactsAccountId();
|
const allContacts: ContactCard[] = [];
|
||||||
|
const primaryId = this.getContactsAccountId();
|
||||||
|
const accountIds = this.getContactCapableAccountIds();
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const isPrimary = accountId === primaryId;
|
||||||
|
const account = this.accounts[accountId];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.request([
|
||||||
|
["ContactCard/query", { accountId, limit: 1000 }, "0"],
|
||||||
|
["ContactCard/get", {
|
||||||
|
accountId,
|
||||||
|
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
||||||
|
}, "1"],
|
||||||
|
], this.contactUsing());
|
||||||
|
|
||||||
|
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
||||||
|
const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[];
|
||||||
|
const contacts = rawContacts.map((contact) => ({
|
||||||
|
...contact,
|
||||||
|
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
||||||
|
originalId: contact.id,
|
||||||
|
addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries(
|
||||||
|
Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v])
|
||||||
|
) : contact.addressBookIds),
|
||||||
|
accountId,
|
||||||
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||||
|
isShared: !isPrimary,
|
||||||
|
}));
|
||||||
|
allContacts.push(...contacts);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allContacts;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch all contacts:', error);
|
||||||
|
return this.getContacts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContact(contactId: string, accountId?: string): Promise<ContactCard | null> {
|
||||||
|
try {
|
||||||
|
const targetAccountId = accountId || this.getContactsAccountId();
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["ContactCard/get", {
|
["ContactCard/get", {
|
||||||
accountId,
|
accountId: targetAccountId,
|
||||||
ids: [contactId],
|
ids: [contactId],
|
||||||
}, "0"]
|
}, "0"]
|
||||||
], this.contactUsing());
|
], this.contactUsing());
|
||||||
@@ -2100,8 +2521,8 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
|
async createContact(contact: Partial<ContactCard>, targetAccountId?: string): Promise<ContactCard> {
|
||||||
const accountId = this.getContactsAccountId();
|
const accountId = targetAccountId || this.getContactsAccountId();
|
||||||
let addressBookIds = contact.addressBookIds;
|
let addressBookIds = contact.addressBookIds;
|
||||||
if (!addressBookIds || Object.keys(addressBookIds).length === 0) {
|
if (!addressBookIds || Object.keys(addressBookIds).length === 0) {
|
||||||
const books = await this.getAddressBooks();
|
const books = await this.getAddressBooks();
|
||||||
@@ -2111,12 +2532,15 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip shared-only fields before sending to JMAP
|
||||||
|
const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...contactData } = contact as ContactCard;
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["ContactCard/set", {
|
["ContactCard/set", {
|
||||||
accountId,
|
accountId,
|
||||||
create: {
|
create: {
|
||||||
"new-contact": {
|
"new-contact": {
|
||||||
...contact,
|
...contactData,
|
||||||
addressBookIds,
|
addressBookIds,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2133,7 +2557,7 @@ export class JMAPClient {
|
|||||||
|
|
||||||
const createdId = result.created?.["new-contact"]?.id;
|
const createdId = result.created?.["new-contact"]?.id;
|
||||||
if (createdId) {
|
if (createdId) {
|
||||||
const created = await this.getContact(createdId);
|
const created = await this.getContact(createdId, accountId);
|
||||||
if (created) return created;
|
if (created) return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2141,14 +2565,17 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to create contact");
|
throw new Error("Failed to create contact");
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateContact(contactId: string, updates: Partial<ContactCard>): Promise<void> {
|
async updateContact(contactId: string, updates: Partial<ContactCard>, targetAccountId?: string): Promise<void> {
|
||||||
const accountId = this.getContactsAccountId();
|
const accountId = targetAccountId || this.getContactsAccountId();
|
||||||
|
|
||||||
|
// Strip shared-only fields before sending to JMAP
|
||||||
|
const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...cleanUpdates } = updates as ContactCard;
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["ContactCard/set", {
|
["ContactCard/set", {
|
||||||
accountId,
|
accountId,
|
||||||
update: {
|
update: {
|
||||||
[contactId]: updates
|
[contactId]: cleanUpdates
|
||||||
}
|
}
|
||||||
}, "0"]
|
}, "0"]
|
||||||
], this.contactUsing());
|
], this.contactUsing());
|
||||||
@@ -2166,8 +2593,8 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to update contact");
|
throw new Error("Failed to update contact");
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteContact(contactId: string): Promise<void> {
|
async deleteContact(contactId: string, targetAccountId?: string): Promise<void> {
|
||||||
const accountId = this.getContactsAccountId();
|
const accountId = targetAccountId || this.getContactsAccountId();
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["ContactCard/set", {
|
["ContactCard/set", {
|
||||||
@@ -2191,24 +2618,45 @@ export class JMAPClient {
|
|||||||
|
|
||||||
async searchContacts(query: string): Promise<ContactCard[]> {
|
async searchContacts(query: string): Promise<ContactCard[]> {
|
||||||
try {
|
try {
|
||||||
const accountId = this.getContactsAccountId();
|
const allResults: ContactCard[] = [];
|
||||||
|
const primaryId = this.getContactsAccountId();
|
||||||
|
const accountIds = this.getContactCapableAccountIds();
|
||||||
|
|
||||||
const response = await this.request([
|
for (const accountId of accountIds) {
|
||||||
["ContactCard/query", {
|
const isPrimary = accountId === primaryId;
|
||||||
accountId,
|
const account = this.accounts[accountId];
|
||||||
filter: { text: query },
|
|
||||||
limit: 50,
|
|
||||||
}, "0"],
|
|
||||||
["ContactCard/get", {
|
|
||||||
accountId,
|
|
||||||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
|
||||||
}, "1"]
|
|
||||||
], this.contactUsing());
|
|
||||||
|
|
||||||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
try {
|
||||||
return (response.methodResponses[1][1].list || []) as ContactCard[];
|
const response = await this.request([
|
||||||
|
["ContactCard/query", {
|
||||||
|
accountId,
|
||||||
|
filter: { text: query },
|
||||||
|
limit: 50,
|
||||||
|
}, "0"],
|
||||||
|
["ContactCard/get", {
|
||||||
|
accountId,
|
||||||
|
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
||||||
|
}, "1"]
|
||||||
|
], this.contactUsing());
|
||||||
|
|
||||||
|
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
||||||
|
const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[];
|
||||||
|
const contacts = rawContacts.map((contact) => ({
|
||||||
|
...contact,
|
||||||
|
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
||||||
|
originalId: contact.id,
|
||||||
|
accountId,
|
||||||
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||||
|
isShared: !isPrimary,
|
||||||
|
}));
|
||||||
|
allResults.push(...contacts);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to search contacts for account ${accountId}:`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
|
return allResults;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to search contacts:', error);
|
console.error('Failed to search contacts:', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -2232,8 +2680,47 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCalendar(calendar: Partial<Calendar>): Promise<Calendar> {
|
async getAllCalendars(): Promise<Calendar[]> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
try {
|
||||||
|
const allCalendars: Calendar[] = [];
|
||||||
|
const primaryId = this.getCalendarsAccountId();
|
||||||
|
const accountIds = this.getCalendarCapableAccountIds();
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const isPrimary = accountId === primaryId;
|
||||||
|
const account = this.accounts[accountId];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.request([
|
||||||
|
["Calendar/get", { accountId }, "0"]
|
||||||
|
], this.calendarUsing());
|
||||||
|
|
||||||
|
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||||||
|
const rawCalendars = (response.methodResponses[0][1].list || []) as Calendar[];
|
||||||
|
const calendars = rawCalendars.map((cal) => ({
|
||||||
|
...cal,
|
||||||
|
id: isPrimary ? cal.id : `${accountId}:${cal.id}`,
|
||||||
|
originalId: cal.id,
|
||||||
|
accountId,
|
||||||
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||||
|
isShared: !isPrimary,
|
||||||
|
}));
|
||||||
|
allCalendars.push(...calendars);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch calendars for account ${accountId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allCalendars;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch all calendars:', error);
|
||||||
|
return this.getCalendars();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCalendar(calendar: Partial<Calendar>, targetAccountId?: string): Promise<Calendar> {
|
||||||
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Calendar/set", {
|
["Calendar/set", {
|
||||||
@@ -2254,17 +2741,23 @@ export class JMAPClient {
|
|||||||
|
|
||||||
const createdId = result.created?.["new-calendar"]?.id;
|
const createdId = result.created?.["new-calendar"]?.id;
|
||||||
if (createdId) {
|
if (createdId) {
|
||||||
const calendars = await this.getCalendars();
|
// Fetch from the target account to find the created calendar
|
||||||
const created = calendars.find(c => c.id === createdId);
|
const fetchAccountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
if (created) return created;
|
const fetchResponse = await this.request([
|
||||||
|
["Calendar/get", { accountId: fetchAccountId, ids: [createdId] }, "0"]
|
||||||
|
], this.calendarUsing());
|
||||||
|
if (fetchResponse.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||||||
|
const list = fetchResponse.methodResponses[0][1].list || [];
|
||||||
|
if (list[0]) return list[0] as Calendar;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Failed to create calendar");
|
throw new Error("Failed to create calendar");
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCalendar(calendarId: string, updates: Partial<Calendar>): Promise<void> {
|
async updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Calendar/set", {
|
["Calendar/set", {
|
||||||
@@ -2288,8 +2781,8 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to update calendar");
|
throw new Error("Failed to update calendar");
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCalendar(calendarId: string): Promise<void> {
|
async deleteCalendar(calendarId: string, targetAccountId?: string): Promise<void> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Calendar/set", {
|
["Calendar/set", {
|
||||||
@@ -2312,8 +2805,8 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to delete calendar");
|
throw new Error("Failed to delete calendar");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCalendarEvents(calendarIds?: string[]): Promise<CalendarEvent[]> {
|
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
||||||
if (calendarIds && calendarIds.length > 0) {
|
if (calendarIds && calendarIds.length > 0) {
|
||||||
@@ -2340,13 +2833,55 @@ export class JMAPClient {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryCalendarEvents(
|
async queryAllCalendarEvents(
|
||||||
filter: CalendarEventFilter,
|
filter: CalendarEventFilter,
|
||||||
sort?: Array<{ property: string; isAscending: boolean }>,
|
sort?: Array<{ property: string; isAscending: boolean }>,
|
||||||
limit?: number
|
limit?: number
|
||||||
): Promise<CalendarEvent[]> {
|
): Promise<CalendarEvent[]> {
|
||||||
try {
|
try {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const allEvents: CalendarEvent[] = [];
|
||||||
|
const primaryId = this.getCalendarsAccountId();
|
||||||
|
const accountIds = this.getCalendarCapableAccountIds();
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const isPrimary = accountId === primaryId;
|
||||||
|
const account = this.accounts[accountId];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await this.queryCalendarEvents(filter, sort, limit, accountId);
|
||||||
|
const mapped = events.map((event) => ({
|
||||||
|
...event,
|
||||||
|
id: isPrimary ? event.id : `${accountId}:${event.id}`,
|
||||||
|
originalId: event.id,
|
||||||
|
originalCalendarIds: event.calendarIds,
|
||||||
|
calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries(
|
||||||
|
Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v])
|
||||||
|
),
|
||||||
|
accountId,
|
||||||
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||||
|
isShared: !isPrimary,
|
||||||
|
}));
|
||||||
|
allEvents.push(...mapped);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to query calendar events for account ${accountId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEvents;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to query all calendar events:', error);
|
||||||
|
return this.queryCalendarEvents(filter, sort, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryCalendarEvents(
|
||||||
|
filter: CalendarEventFilter,
|
||||||
|
sort?: Array<{ property: string; isAscending: boolean }>,
|
||||||
|
limit?: number,
|
||||||
|
targetAccountId?: string
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
try {
|
||||||
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
const queryArgs: Record<string, unknown> = {
|
const queryArgs: Record<string, unknown> = {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -2375,9 +2910,9 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCalendarEvent(id: string): Promise<CalendarEvent | null> {
|
async getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null> {
|
||||||
try {
|
try {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["CalendarEvent/get", {
|
["CalendarEvent/get", {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -2396,13 +2931,16 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean): Promise<CalendarEvent> {
|
async createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
|
// Strip client-only shared fields before sending to JMAP
|
||||||
|
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
|
||||||
|
|
||||||
const setArgs: Record<string, unknown> = {
|
const setArgs: Record<string, unknown> = {
|
||||||
accountId,
|
accountId,
|
||||||
create: {
|
create: {
|
||||||
"new-event": event
|
"new-event": cleanEvent
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (sendSchedulingMessages !== undefined) {
|
if (sendSchedulingMessages !== undefined) {
|
||||||
@@ -2423,7 +2961,7 @@ export class JMAPClient {
|
|||||||
|
|
||||||
const createdId = result.created?.["new-event"]?.id;
|
const createdId = result.created?.["new-event"]?.id;
|
||||||
if (createdId) {
|
if (createdId) {
|
||||||
const created = await this.getCalendarEvent(createdId);
|
const created = await this.getCalendarEvent(createdId, targetAccountId);
|
||||||
if (created) return created;
|
if (created) return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2434,14 +2972,18 @@ export class JMAPClient {
|
|||||||
async updateCalendarEvent(
|
async updateCalendarEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
updates: Partial<CalendarEvent>,
|
updates: Partial<CalendarEvent>,
|
||||||
sendSchedulingMessages?: boolean
|
sendSchedulingMessages?: boolean,
|
||||||
|
targetAccountId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
|
// Strip client-only shared fields before sending to JMAP
|
||||||
|
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
|
||||||
|
|
||||||
const setArgs: Record<string, unknown> = {
|
const setArgs: Record<string, unknown> = {
|
||||||
accountId,
|
accountId,
|
||||||
update: {
|
update: {
|
||||||
[eventId]: updates
|
[eventId]: cleanUpdates
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (sendSchedulingMessages !== undefined) {
|
if (sendSchedulingMessages !== undefined) {
|
||||||
@@ -2496,8 +3038,8 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to parse calendar file");
|
throw new Error("Failed to parse calendar file");
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean): Promise<void> {
|
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
const setArgs: Record<string, unknown> = {
|
const setArgs: Record<string, unknown> = {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -2524,10 +3066,10 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to delete calendar event");
|
throw new Error("Failed to delete calendar event");
|
||||||
}
|
}
|
||||||
|
|
||||||
async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
async batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||||
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
|
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
|
||||||
|
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
|
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|||||||
+47
-1
@@ -160,9 +160,13 @@ export interface Identity {
|
|||||||
|
|
||||||
export interface ContactCard {
|
export interface ContactCard {
|
||||||
id: string;
|
id: string;
|
||||||
|
originalId?: string;
|
||||||
uid?: string;
|
uid?: string;
|
||||||
addressBookIds: Record<string, boolean>;
|
addressBookIds: Record<string, boolean>;
|
||||||
kind?: 'individual' | 'group' | 'org' | 'location' | 'device' | 'application';
|
kind?: 'individual' | 'group' | 'org' | 'location' | 'device' | 'application';
|
||||||
|
accountId?: string;
|
||||||
|
accountName?: string;
|
||||||
|
isShared?: boolean;
|
||||||
language?: string;
|
language?: string;
|
||||||
name?: ContactName;
|
name?: ContactName;
|
||||||
nicknames?: Record<string, ContactNickname>;
|
nicknames?: Record<string, ContactNickname>;
|
||||||
@@ -183,7 +187,10 @@ export interface ContactCard {
|
|||||||
relatedTo?: Record<string, ContactRelation>;
|
relatedTo?: Record<string, ContactRelation>;
|
||||||
keywords?: Record<string, boolean>;
|
keywords?: Record<string, boolean>;
|
||||||
members?: Record<string, boolean>;
|
members?: Record<string, boolean>;
|
||||||
gender?: { sex?: string; identity?: string };
|
speakToAs?: {
|
||||||
|
grammaticalGender?: string;
|
||||||
|
pronouns?: Record<string, { pronouns: string; pref?: number; contexts?: Record<string, boolean> }>;
|
||||||
|
};
|
||||||
calendarUri?: string;
|
calendarUri?: string;
|
||||||
schedulingUri?: string;
|
schedulingUri?: string;
|
||||||
freeBusyUri?: string;
|
freeBusyUri?: string;
|
||||||
@@ -316,12 +323,16 @@ export interface ContactRelation {
|
|||||||
|
|
||||||
export interface AddressBook {
|
export interface AddressBook {
|
||||||
id: string;
|
id: string;
|
||||||
|
originalId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
isSubscribed?: boolean;
|
isSubscribed?: boolean;
|
||||||
myRights?: AddressBookRights;
|
myRights?: AddressBookRights;
|
||||||
|
accountId?: string;
|
||||||
|
accountName?: string;
|
||||||
|
isShared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddressBookRights {
|
export interface AddressBookRights {
|
||||||
@@ -367,6 +378,7 @@ export interface DeliveryStatus {
|
|||||||
|
|
||||||
export interface Calendar {
|
export interface Calendar {
|
||||||
id: string;
|
id: string;
|
||||||
|
originalId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
@@ -380,6 +392,9 @@ export interface Calendar {
|
|||||||
timeZone: string | null;
|
timeZone: string | null;
|
||||||
shareWith: Record<string, CalendarRights> | null;
|
shareWith: Record<string, CalendarRights> | null;
|
||||||
myRights: CalendarRights;
|
myRights: CalendarRights;
|
||||||
|
accountId?: string;
|
||||||
|
accountName?: string;
|
||||||
|
isShared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarRights {
|
export interface CalendarRights {
|
||||||
@@ -395,7 +410,12 @@ export interface CalendarRights {
|
|||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
id: string;
|
id: string;
|
||||||
|
originalId?: string;
|
||||||
calendarIds: Record<string, boolean>;
|
calendarIds: Record<string, boolean>;
|
||||||
|
originalCalendarIds?: Record<string, boolean>;
|
||||||
|
accountId?: string;
|
||||||
|
accountName?: string;
|
||||||
|
isShared?: boolean;
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
isOrigin: boolean;
|
isOrigin: boolean;
|
||||||
utcStart: string | null;
|
utcStart: string | null;
|
||||||
@@ -544,6 +564,32 @@ export interface CalendarRelation {
|
|||||||
relation: Record<string, boolean> | null;
|
relation: Record<string, boolean> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CalendarTask {
|
||||||
|
id: string;
|
||||||
|
calendarIds: Record<string, boolean>;
|
||||||
|
'@type': 'Task';
|
||||||
|
uid: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
due: string | null;
|
||||||
|
start: string | null;
|
||||||
|
duration: string | null;
|
||||||
|
timeZone: string | null;
|
||||||
|
showWithoutTime: boolean;
|
||||||
|
progress: 'needs-action' | 'in-process' | 'completed' | 'cancelled';
|
||||||
|
progressUpdated: string | null;
|
||||||
|
priority: number;
|
||||||
|
privacy: 'public' | 'private' | 'secret';
|
||||||
|
keywords: Record<string, boolean> | null;
|
||||||
|
categories: Record<string, boolean> | null;
|
||||||
|
color: string | null;
|
||||||
|
created: string | null;
|
||||||
|
updated: string;
|
||||||
|
recurrenceRules: CalendarRecurrenceRule[] | null;
|
||||||
|
alerts: Record<string, CalendarEventAlert> | null;
|
||||||
|
relatedTo: Record<string, CalendarRelation> | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CalendarParticipantIdentity {
|
export interface CalendarParticipantIdentity {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
export const OAUTH_SCOPES = 'openid email profile';
|
export const OAUTH_SCOPES = 'openid email profile';
|
||||||
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
|
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
|
||||||
|
|
||||||
|
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
||||||
|
export function refreshTokenCookieName(slot: number): string {
|
||||||
|
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
@@ -45,7 +45,10 @@ export async function saveUserSettings(username: string, serverUrl: string, sett
|
|||||||
const tag = cipher.getAuthTag();
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
const data = Buffer.concat([iv, tag, encrypted]);
|
const data = Buffer.concat([iv, tag, encrypted]);
|
||||||
await writeFile(getSettingsPath(username, serverUrl), data);
|
const targetPath = getSettingsPath(username, serverUrl);
|
||||||
|
const tmpPath = targetPath + '.tmp';
|
||||||
|
await writeFile(tmpPath, data);
|
||||||
|
await rename(tmpPath, targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadUserSettings(username: string, serverUrl: string): Promise<Record<string, unknown> | null> {
|
export async function loadUserSettings(username: string, serverUrl: string): Promise<Record<string, unknown> | null> {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Crypto engine backed by webcrypto-liner for legacy algorithm support.
|
||||||
|
*
|
||||||
|
* webcrypto-liner extends the native Web Crypto API with algorithms
|
||||||
|
* like DES-EDE3-CBC (3DES) that are commonly found in S/MIME messages
|
||||||
|
* and PKCS#12 files produced by legacy clients (Outlook, Thunderbird, etc.).
|
||||||
|
*
|
||||||
|
* Native Web Crypto calls are passed through to the real implementation;
|
||||||
|
* liner only intercepts algorithms that the browser doesn't natively support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as pkijs from 'pkijs';
|
||||||
|
|
||||||
|
// webcrypto-liner exports a Crypto constructor at runtime that extends native
|
||||||
|
// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only
|
||||||
|
// expose the type alias, so we import the module dynamically and cast.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const liner = require('webcrypto-liner') as {
|
||||||
|
Crypto: { new (): Crypto };
|
||||||
|
setCrypto: (subtle: SubtleCrypto) => void;
|
||||||
|
nativeCrypto: Crypto | Record<string, never>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let linerEngine: pkijs.CryptoEngine | null = null;
|
||||||
|
let linerCryptoInstance: Crypto | null = null;
|
||||||
|
|
||||||
|
function ensureLiner() {
|
||||||
|
if (!linerCryptoInstance) {
|
||||||
|
// In Node.js, webcrypto-liner can't auto-detect the native crypto
|
||||||
|
// (it looks for self.crypto which doesn't exist). Feed it manually
|
||||||
|
// so that native algorithms (RSA, AES, etc.) stay hardware-accelerated
|
||||||
|
// and only truly missing algorithms (3DES) use the software fallback.
|
||||||
|
if (
|
||||||
|
typeof liner.nativeCrypto?.getRandomValues !== 'function' &&
|
||||||
|
typeof globalThis.crypto?.subtle !== 'undefined'
|
||||||
|
) {
|
||||||
|
liner.setCrypto(globalThis.crypto.subtle);
|
||||||
|
}
|
||||||
|
linerCryptoInstance = new liner.Crypto();
|
||||||
|
}
|
||||||
|
if (!linerEngine) {
|
||||||
|
linerEngine = new pkijs.CryptoEngine({
|
||||||
|
crypto: linerCryptoInstance,
|
||||||
|
subtle: linerCryptoInstance.subtle,
|
||||||
|
name: 'webcrypto-liner',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a PKI.js CryptoEngine with 3DES (and other legacy algorithm) support. */
|
||||||
|
export function getLinerCryptoEngine(): pkijs.CryptoEngine {
|
||||||
|
ensureLiner();
|
||||||
|
return linerEngine!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an async operation with the global PKI.js engine set to webcrypto-liner,
|
||||||
|
* then restore the previous engine afterwards.
|
||||||
|
*
|
||||||
|
* Required for operations that use the global engine internally
|
||||||
|
* (e.g. PFX.parseInternalValues for PKCS#12 import).
|
||||||
|
*/
|
||||||
|
export async function withLinerEngine<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
ensureLiner();
|
||||||
|
|
||||||
|
// Save the current global engine so we can restore it
|
||||||
|
const prev = pkijs.getEngine();
|
||||||
|
|
||||||
|
pkijs.setEngine('webcrypto-liner', linerCryptoInstance!, linerEngine!);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
// Restore the previous engine
|
||||||
|
pkijs.setEngine(prev.name, prev.crypto as unknown as pkijs.CryptoEngine);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
classifyCapabilities,
|
classifyCapabilities,
|
||||||
} from './certificate-utils';
|
} from './certificate-utils';
|
||||||
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
|
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
|
||||||
|
import { withLinerEngine } from './crypto-engine';
|
||||||
|
|
||||||
const KDF_ITERATIONS = 600_000;
|
const KDF_ITERATIONS = 600_000;
|
||||||
const AES_KEY_LENGTH = 256;
|
const AES_KEY_LENGTH = 256;
|
||||||
@@ -39,9 +40,12 @@ export async function importPkcs12(
|
|||||||
// PKIjs handles MAC verification internally during parseInternalValues
|
// PKIjs handles MAC verification internally during parseInternalValues
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse internal values
|
// Use webcrypto-liner as the global engine for 3DES support.
|
||||||
await pfx.parseInternalValues({
|
// Many PKCS#12 files use pbeWithSHAAnd3-KeyTripleDES-CBC internally.
|
||||||
password: stringToAB(p12Passphrase),
|
await withLinerEngine(async () => {
|
||||||
|
await pfx.parseInternalValues({
|
||||||
|
password: stringToAB(p12Passphrase),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract certificates and private key from parsed PKCS#12
|
// Extract certificates and private key from parsed PKCS#12
|
||||||
@@ -63,7 +67,9 @@ export async function importPkcs12(
|
|||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
await authSafe.parseInternalValues({ safeContents: safeContentsParams });
|
await withLinerEngine(async () => {
|
||||||
|
await authSafe.parseInternalValues({ safeContents: safeContentsParams });
|
||||||
|
});
|
||||||
|
|
||||||
for (const safeContent of authSafe.parsedValue.safeContents) {
|
for (const safeContent of authSafe.parsedValue.safeContents) {
|
||||||
const sc = safeContent.value ?? safeContent.parsedValue;
|
const sc = safeContent.value ?? safeContent.parsedValue;
|
||||||
@@ -115,8 +121,10 @@ export async function importPkcs12(
|
|||||||
privateKeyInfo = shroudedBag.parsedValue;
|
privateKeyInfo = shroudedBag.parsedValue;
|
||||||
} else {
|
} else {
|
||||||
// Decrypt shrouded key bag to get private key info
|
// Decrypt shrouded key bag to get private key info
|
||||||
await (shroudedBag as unknown as { parseInternalValues(params: { password: ArrayBuffer }): Promise<void> }).parseInternalValues({
|
await withLinerEngine(async () => {
|
||||||
password: stringToAB(p12Passphrase),
|
await (shroudedBag as unknown as { parseInternalValues(params: { password: ArrayBuffer }): Promise<void> }).parseInternalValues({
|
||||||
|
password: stringToAB(p12Passphrase),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
if (shroudedBag.parsedValue) {
|
if (shroudedBag.parsedValue) {
|
||||||
privateKeyInfo = shroudedBag.parsedValue;
|
privateKeyInfo = shroudedBag.parsedValue;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import * as pkijs from 'pkijs';
|
import * as pkijs from 'pkijs';
|
||||||
import * as asn1js from 'asn1js';
|
import * as asn1js from 'asn1js';
|
||||||
import type { SmimeKeyRecord } from './types';
|
import type { SmimeKeyRecord } from './types';
|
||||||
|
import { getLinerCryptoEngine } from './crypto-engine';
|
||||||
|
|
||||||
export interface DecryptionInput {
|
export interface DecryptionInput {
|
||||||
/** Raw CMS EnvelopedData bytes (DER) */
|
/** Raw CMS EnvelopedData bytes (DER) */
|
||||||
@@ -359,11 +360,8 @@ async function decryptWithKey(
|
|||||||
const certAsn1 = asn1js.fromBER(keyRecord.certificate);
|
const certAsn1 = asn1js.fromBER(keyRecord.certificate);
|
||||||
const cert = new pkijs.Certificate({ schema: certAsn1.result });
|
const cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||||
|
|
||||||
const cryptoEngine = new pkijs.CryptoEngine({
|
// Use webcrypto-liner engine for legacy algorithm support (e.g. 3DES)
|
||||||
crypto: crypto,
|
const cryptoEngine = getLinerCryptoEngine();
|
||||||
subtle: crypto.subtle,
|
|
||||||
name: 'webcrypto',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await envelopedData.decrypt(
|
const result = await envelopedData.decrypt(
|
||||||
recipientIndex,
|
recipientIndex,
|
||||||
|
|||||||
+53
-2
@@ -7,6 +7,8 @@
|
|||||||
* Reference: MS-OXTNEF / MS-TNEF specification.
|
* Reference: MS-OXTNEF / MS-TNEF specification.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { debug } from '@/lib/debug';
|
||||||
|
|
||||||
// TNEF signature
|
// TNEF signature
|
||||||
const TNEF_SIGNATURE = 0x223E9F78;
|
const TNEF_SIGNATURE = 0x223E9F78;
|
||||||
|
|
||||||
@@ -234,35 +236,58 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
attachments: [],
|
attachments: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.byteLength < 6) return result;
|
debug.group('TNEF Parser');
|
||||||
|
debug.log('Input data size:', data.byteLength, 'bytes');
|
||||||
|
|
||||||
|
if (data.byteLength < 6) {
|
||||||
|
debug.warn('TNEF data too small (< 6 bytes), skipping');
|
||||||
|
debug.groupEnd();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const r = new BinaryReader(data);
|
const r = new BinaryReader(data);
|
||||||
|
|
||||||
const signature = r.readUint32LE();
|
const signature = r.readUint32LE();
|
||||||
if (signature !== TNEF_SIGNATURE) {
|
if (signature !== TNEF_SIGNATURE) {
|
||||||
|
debug.warn('Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
|
||||||
|
debug.groupEnd();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
debug.log('TNEF signature valid');
|
||||||
|
|
||||||
r.skip(2); // legacy key
|
r.skip(2); // legacy key
|
||||||
|
|
||||||
// Current attachment being assembled
|
// Current attachment being assembled
|
||||||
let curAttach: { name: string; mimeType: string; data: Uint8Array | null } | null = null;
|
let curAttach: { name: string; mimeType: string; data: Uint8Array | null } | null = null;
|
||||||
|
let attrCount = 0;
|
||||||
|
|
||||||
while (r.remaining >= 11) {
|
while (r.remaining >= 11) {
|
||||||
const level = r.readUint8();
|
const level = r.readUint8();
|
||||||
const attrID = r.readUint32LE();
|
const attrID = r.readUint32LE();
|
||||||
const attrLen = r.readUint32LE();
|
const attrLen = r.readUint32LE();
|
||||||
|
attrCount++;
|
||||||
|
|
||||||
if (attrLen > r.remaining - 2) break; // not enough data for payload + checksum
|
if (attrLen > r.remaining - 2) {
|
||||||
|
debug.warn('Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const attrData = r.readBytes(attrLen);
|
const attrData = r.readBytes(attrLen);
|
||||||
r.skip(2); // checksum
|
r.skip(2); // checksum
|
||||||
|
|
||||||
|
const levelName = level === LVL_MESSAGE ? 'MESSAGE' : level === LVL_ATTACHMENT ? 'ATTACHMENT' : 'UNKNOWN(' + level + ')';
|
||||||
|
debug.log('Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
|
||||||
|
|
||||||
if (level === LVL_MESSAGE) {
|
if (level === LVL_MESSAGE) {
|
||||||
if (attrID === attBody) {
|
if (attrID === attBody) {
|
||||||
result.body = new TextDecoder('utf-8').decode(attrData);
|
result.body = new TextDecoder('utf-8').decode(attrData);
|
||||||
|
debug.log(' → Extracted plain text body (' + result.body.length + ' chars)');
|
||||||
} else if (attrID === attMAPIProps) {
|
} else if (attrID === attMAPIProps) {
|
||||||
const props = parseMAPIProps(attrData);
|
const props = parseMAPIProps(attrData);
|
||||||
|
debug.log(' → Parsed', props.size, 'MAPI properties from message');
|
||||||
|
props.forEach((val, propID) => {
|
||||||
|
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||||
|
});
|
||||||
|
|
||||||
// HTML body
|
// HTML body
|
||||||
const htmlProp = props.get(PR_BODY_HTML);
|
const htmlProp = props.get(PR_BODY_HTML);
|
||||||
@@ -273,6 +298,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
} else {
|
} else {
|
||||||
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
|
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
|
||||||
}
|
}
|
||||||
|
debug.log(' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
|
||||||
|
} else {
|
||||||
|
debug.log(' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain text body from MAPI props (fallback)
|
// Plain text body from MAPI props (fallback)
|
||||||
@@ -280,6 +308,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
const bodyProp = props.get(PR_BODY);
|
const bodyProp = props.get(PR_BODY);
|
||||||
if (bodyProp?.value instanceof Uint8Array) {
|
if (bodyProp?.value instanceof Uint8Array) {
|
||||||
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
|
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
|
||||||
|
debug.log(' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
|
||||||
|
} else {
|
||||||
|
debug.log(' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,6 +318,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
if (attrID === attAttachRenddata) {
|
if (attrID === attAttachRenddata) {
|
||||||
// Start of a new attachment — flush previous
|
// Start of a new attachment — flush previous
|
||||||
if (curAttach?.data) {
|
if (curAttach?.data) {
|
||||||
|
debug.log(' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||||
result.attachments.push({
|
result.attachments.push({
|
||||||
name: curAttach.name,
|
name: curAttach.name,
|
||||||
mimeType: curAttach.mimeType,
|
mimeType: curAttach.mimeType,
|
||||||
@@ -294,28 +326,40 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
|
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
|
||||||
|
debug.log(' → New attachment started');
|
||||||
} else if (attrID === attAttachTitle && curAttach) {
|
} else if (attrID === attAttachTitle && curAttach) {
|
||||||
let len = attrData.byteLength;
|
let len = attrData.byteLength;
|
||||||
if (len > 0 && attrData[len - 1] === 0) len--;
|
if (len > 0 && attrData[len - 1] === 0) len--;
|
||||||
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
|
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
|
||||||
|
debug.log(' → Attachment short name:', curAttach.name);
|
||||||
} else if (attrID === attAttachData && curAttach) {
|
} else if (attrID === attAttachData && curAttach) {
|
||||||
curAttach.data = attrData;
|
curAttach.data = attrData;
|
||||||
|
debug.log(' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
|
||||||
} else if (attrID === attAttachment && curAttach) {
|
} else if (attrID === attAttachment && curAttach) {
|
||||||
const props = parseMAPIProps(attrData);
|
const props = parseMAPIProps(attrData);
|
||||||
|
debug.log(' → Parsed', props.size, 'MAPI properties from attachment');
|
||||||
|
props.forEach((val, propID) => {
|
||||||
|
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||||
|
});
|
||||||
|
|
||||||
const longName = props.get(PR_ATTACH_LONG_FILENAME);
|
const longName = props.get(PR_ATTACH_LONG_FILENAME);
|
||||||
if (longName?.value instanceof Uint8Array) {
|
if (longName?.value instanceof Uint8Array) {
|
||||||
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
|
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
|
||||||
|
debug.log(' → Attachment long filename:', curAttach.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
|
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
|
||||||
if (mimeTag?.value instanceof Uint8Array) {
|
if (mimeTag?.value instanceof Uint8Array) {
|
||||||
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
|
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
|
||||||
|
debug.log(' → Attachment MIME type:', curAttach.mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
||||||
if (attachData?.value instanceof Uint8Array) {
|
if (attachData?.value instanceof Uint8Array) {
|
||||||
curAttach.data = attachData.value;
|
curAttach.data = attachData.value;
|
||||||
|
debug.log(' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
|
||||||
|
} else {
|
||||||
|
debug.log(' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,6 +367,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
|
|
||||||
// Flush last attachment
|
// Flush last attachment
|
||||||
if (curAttach?.data) {
|
if (curAttach?.data) {
|
||||||
|
debug.log('Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||||
result.attachments.push({
|
result.attachments.push({
|
||||||
name: curAttach.name,
|
name: curAttach.name,
|
||||||
mimeType: curAttach.mimeType,
|
mimeType: curAttach.mimeType,
|
||||||
@@ -330,6 +375,12 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug.log('TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
|
||||||
|
if (result.attachments.length > 0) {
|
||||||
|
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })));
|
||||||
|
}
|
||||||
|
debug.groupEnd();
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-7
@@ -1,5 +1,29 @@
|
|||||||
import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService } from "@/lib/jmap/types";
|
import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
const VCARD_SEX_TO_GENDER: Record<string, string> = {
|
||||||
|
M: "masculine",
|
||||||
|
F: "feminine",
|
||||||
|
O: "other",
|
||||||
|
N: "none",
|
||||||
|
U: "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
const GENDER_TO_VCARD_SEX: Record<string, string> = {
|
||||||
|
masculine: "M",
|
||||||
|
feminine: "F",
|
||||||
|
other: "O",
|
||||||
|
none: "N",
|
||||||
|
unknown: "U",
|
||||||
|
};
|
||||||
|
|
||||||
|
function vcardSexToGrammaticalGender(sex: string): string {
|
||||||
|
return VCARD_SEX_TO_GENDER[sex.toUpperCase()] || sex.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function grammaticalGenderToVcardSex(gender: string): string {
|
||||||
|
return GENDER_TO_VCARD_SEX[gender.toLowerCase()] || "";
|
||||||
|
}
|
||||||
|
|
||||||
function unfoldLines(vcf: string): string {
|
function unfoldLines(vcf: string): string {
|
||||||
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
}
|
}
|
||||||
@@ -390,9 +414,17 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
|
|
||||||
case "GENDER": {
|
case "GENDER": {
|
||||||
const gParts = val.split(";");
|
const gParts = val.split(";");
|
||||||
card.gender = {};
|
const sexCode = gParts[0]?.toUpperCase();
|
||||||
if (gParts[0]) card.gender.sex = gParts[0];
|
const identityText = gParts[1];
|
||||||
if (gParts[1]) card.gender.identity = gParts[1];
|
if (sexCode || identityText) {
|
||||||
|
card.speakToAs = {};
|
||||||
|
if (sexCode) {
|
||||||
|
card.speakToAs.grammaticalGender = vcardSexToGrammaticalGender(sexCode);
|
||||||
|
}
|
||||||
|
if (identityText) {
|
||||||
|
card.speakToAs.pronouns = { p0: { pronouns: identityText } };
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,10 +716,15 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contact.gender) {
|
if (contact.speakToAs) {
|
||||||
const sex = contact.gender.sex || "";
|
const sex = contact.speakToAs.grammaticalGender
|
||||||
const identity = contact.gender.identity || "";
|
? grammaticalGenderToVcardSex(contact.speakToAs.grammaticalGender)
|
||||||
lines.push(`GENDER:${sex}${identity ? `;${identity}` : ""}`);
|
: "";
|
||||||
|
const pronouns = contact.speakToAs.pronouns;
|
||||||
|
const identity = pronouns ? Object.values(pronouns)[0]?.pronouns || "" : "";
|
||||||
|
if (sex || identity) {
|
||||||
|
lines.push(`GENDER:${sex}${identity ? `;${identity}` : ""}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contact.calendarUri) {
|
if (contact.calendarUri) {
|
||||||
|
|||||||
+40
-8
@@ -34,6 +34,9 @@
|
|||||||
"dismiss": "Schließen",
|
"dismiss": "Schließen",
|
||||||
"or": "oder",
|
"or": "oder",
|
||||||
"sign_in_sso": "Mit SSO anmelden",
|
"sign_in_sso": "Mit SSO anmelden",
|
||||||
|
"add_account_title": "Konto hinzufügen",
|
||||||
|
"add_account_subtitle": "Mit einem anderen Konto anmelden",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
"website": "Webseite",
|
"website": "Webseite",
|
||||||
"imprint": "Impressum",
|
"imprint": "Impressum",
|
||||||
"privacy_policy": "Datenschutz",
|
"privacy_policy": "Datenschutz",
|
||||||
@@ -58,6 +61,11 @@
|
|||||||
"storage_free": "Frei",
|
"storage_free": "Frei",
|
||||||
"storage_total": "Gesamt",
|
"storage_total": "Gesamt",
|
||||||
"sign_out": "Abmelden",
|
"sign_out": "Abmelden",
|
||||||
|
"sign_out_of": "Von {account} abmelden",
|
||||||
|
"sign_out_all": "Von allen Konten abmelden",
|
||||||
|
"add_account": "Konto hinzufügen",
|
||||||
|
"set_as_default": "Als Standard festlegen",
|
||||||
|
"switch_account": "Konto wechseln",
|
||||||
"contacts": "Kontakte",
|
"contacts": "Kontakte",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
@@ -191,8 +199,12 @@
|
|||||||
"mark_read": "Als gelesen markieren",
|
"mark_read": "Als gelesen markieren",
|
||||||
"print": "Drucken",
|
"print": "Drucken",
|
||||||
"view_source": "Quelltext anzeigen",
|
"view_source": "Quelltext anzeigen",
|
||||||
|
"export_email": "Als .eml exportieren",
|
||||||
|
"import_email": ".eml importieren",
|
||||||
"keyboard_shortcuts": "Tastaturkürzel (?)",
|
"keyboard_shortcuts": "Tastaturkürzel (?)",
|
||||||
"email_source": "E-Mail-Quelltext",
|
"email_source": "E-Mail-Quelltext",
|
||||||
|
"draft_banner": "Diese Nachricht ist ein Entwurf",
|
||||||
|
"edit_draft": "Bearbeiten",
|
||||||
"copy_source": "In Zwischenablage kopieren",
|
"copy_source": "In Zwischenablage kopieren",
|
||||||
"source_copied": "Quelltext in Zwischenablage kopiert",
|
"source_copied": "Quelltext in Zwischenablage kopiert",
|
||||||
"attachments": "Anhänge",
|
"attachments": "Anhänge",
|
||||||
@@ -294,7 +306,8 @@
|
|||||||
"unstar": "Markierung entfernen (s)",
|
"unstar": "Markierung entfernen (s)",
|
||||||
"compose": "Verfassen (c)",
|
"compose": "Verfassen (c)",
|
||||||
"previous": "Vorherige E-Mail",
|
"previous": "Vorherige E-Mail",
|
||||||
"next": "Nächste E-Mail"
|
"next": "Nächste E-Mail",
|
||||||
|
"edit_draft": "Entwurf bearbeiten"
|
||||||
},
|
},
|
||||||
"spam": {
|
"spam": {
|
||||||
"button_title": "Spam melden",
|
"button_title": "Spam melden",
|
||||||
@@ -514,6 +527,7 @@
|
|||||||
"identity_created": "Identität erfolgreich erstellt",
|
"identity_created": "Identität erfolgreich erstellt",
|
||||||
"identity_updated": "Identität erfolgreich aktualisiert",
|
"identity_updated": "Identität erfolgreich aktualisiert",
|
||||||
"identity_deleted": "Identität gelöscht",
|
"identity_deleted": "Identität gelöscht",
|
||||||
|
"identity_set_primary": "Primäre Identität aktualisiert",
|
||||||
"identity_create_failed": "Identität erstellen fehlgeschlagen: {error}",
|
"identity_create_failed": "Identität erstellen fehlgeschlagen: {error}",
|
||||||
"identity_update_failed": "Identität aktualisieren fehlgeschlagen: {error}",
|
"identity_update_failed": "Identität aktualisieren fehlgeschlagen: {error}",
|
||||||
"identity_delete_failed": "Identität löschen fehlgeschlagen: {error}",
|
"identity_delete_failed": "Identität löschen fehlgeschlagen: {error}",
|
||||||
@@ -527,7 +541,10 @@
|
|||||||
"templates_exported": "Vorlagen erfolgreich exportiert",
|
"templates_exported": "Vorlagen erfolgreich exportiert",
|
||||||
"templates_imported": "{count, plural, one {# Vorlage importiert} other {# Vorlagen importiert}}",
|
"templates_imported": "{count, plural, one {# Vorlage importiert} other {# Vorlagen importiert}}",
|
||||||
"templates_import_errors": "Einige Vorlagen konnten nicht importiert werden",
|
"templates_import_errors": "Einige Vorlagen konnten nicht importiert werden",
|
||||||
"templates_import_empty": "Keine Vorlagen in der Datei gefunden"
|
"templates_import_empty": "Keine Vorlagen in der Datei gefunden",
|
||||||
|
"export_email_error": "Fehler beim Exportieren der E-Mail",
|
||||||
|
"import_email_success": "E-Mail erfolgreich importiert",
|
||||||
|
"import_email_error": "Fehler beim Importieren der E-Mail"
|
||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"today": "Heute",
|
"today": "Heute",
|
||||||
@@ -1293,7 +1310,8 @@
|
|||||||
"not_spam": "Kein Spam",
|
"not_spam": "Kein Spam",
|
||||||
"color_tag": "Label",
|
"color_tag": "Label",
|
||||||
"remove_color": "Label entfernen",
|
"remove_color": "Label entfernen",
|
||||||
"items_selected": "{count} E-Mails ausgewählt"
|
"items_selected": "{count} E-Mails ausgewählt",
|
||||||
|
"edit_draft": "Entwurf bearbeiten"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Tastaturkürzel",
|
"title": "Tastaturkürzel",
|
||||||
@@ -1358,6 +1376,7 @@
|
|||||||
"delete_confirm": "Diese Identität löschen? Dies kann nicht rückgängig gemacht werden.",
|
"delete_confirm": "Diese Identität löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||||
"cannot_delete": "Diese Identität kann nicht gelöscht werden",
|
"cannot_delete": "Diese Identität kann nicht gelöscht werden",
|
||||||
"primary_identity": "Primär",
|
"primary_identity": "Primär",
|
||||||
|
"set_as_primary": "Als primär festlegen",
|
||||||
"no_identities": "Keine Identitäten gefunden",
|
"no_identities": "Keine Identitäten gefunden",
|
||||||
"display": {
|
"display": {
|
||||||
"reply_to": "Antwort an:",
|
"reply_to": "Antwort an:",
|
||||||
@@ -1458,6 +1477,17 @@
|
|||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"groups": "Gruppen"
|
"groups": "Gruppen"
|
||||||
},
|
},
|
||||||
|
"shared": {
|
||||||
|
"title": "Geteilt"
|
||||||
|
},
|
||||||
|
"address_books": {
|
||||||
|
"title": "Meine Adressbücher",
|
||||||
|
"shared_prefix": "Geteilt: {name}",
|
||||||
|
"moved": "Kontakt verschoben nach {name}",
|
||||||
|
"moved_plural": "{count} Kontakte verschoben nach {name}",
|
||||||
|
"move_failed": "Kontakt konnte nicht verschoben werden",
|
||||||
|
"address_book": "Adressbuch"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-Mail-Adressen",
|
"emails": "E-Mail-Adressen",
|
||||||
"phones": "Telefonnummern",
|
"phones": "Telefonnummern",
|
||||||
@@ -1491,11 +1521,11 @@
|
|||||||
"personal_interest": "Interesse",
|
"personal_interest": "Interesse",
|
||||||
"personal_other": "Sonstiges",
|
"personal_other": "Sonstiges",
|
||||||
"gender": "Geschlecht",
|
"gender": "Geschlecht",
|
||||||
"gender_M": "Männlich",
|
"gender_masculine": "Männlich",
|
||||||
"gender_F": "Weiblich",
|
"gender_feminine": "Weiblich",
|
||||||
"gender_O": "Andere",
|
"gender_other": "Andere",
|
||||||
"gender_N": "Nicht zutreffend",
|
"gender_none": "Nicht zutreffend",
|
||||||
"gender_U": "Unbekannt",
|
"gender_unknown": "Unbekannt",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"calendar_uri": "Kalender-URL",
|
"calendar_uri": "Kalender-URL",
|
||||||
"scheduling_uri": "Terminplanungs-URL",
|
"scheduling_uri": "Terminplanungs-URL",
|
||||||
@@ -1513,6 +1543,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Neuer Kontakt",
|
"create_title": "Neuer Kontakt",
|
||||||
"edit_title": "Kontakt bearbeiten",
|
"edit_title": "Kontakt bearbeiten",
|
||||||
|
"section_address_book": "Verzeichnis",
|
||||||
|
"select_address_book": "Verzeichnis auswählen...",
|
||||||
"section_identity": "Name & Identität",
|
"section_identity": "Name & Identität",
|
||||||
"section_work": "Beruf & Organisation",
|
"section_work": "Beruf & Organisation",
|
||||||
"prefix": "Anrede",
|
"prefix": "Anrede",
|
||||||
|
|||||||
+40
-8
@@ -34,6 +34,9 @@
|
|||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"sign_in_sso": "Sign in with SSO",
|
"sign_in_sso": "Sign in with SSO",
|
||||||
|
"add_account_title": "Add Account",
|
||||||
|
"add_account_subtitle": "Sign in with another account",
|
||||||
|
"cancel": "Cancel",
|
||||||
"website": "Website",
|
"website": "Website",
|
||||||
"imprint": "Imprint",
|
"imprint": "Imprint",
|
||||||
"privacy_policy": "Privacy Policy",
|
"privacy_policy": "Privacy Policy",
|
||||||
@@ -58,6 +61,11 @@
|
|||||||
"storage_free": "Free",
|
"storage_free": "Free",
|
||||||
"storage_total": "Total",
|
"storage_total": "Total",
|
||||||
"sign_out": "Sign out",
|
"sign_out": "Sign out",
|
||||||
|
"sign_out_of": "Sign out of {account}",
|
||||||
|
"sign_out_all": "Sign out of all accounts",
|
||||||
|
"add_account": "Add account",
|
||||||
|
"set_as_default": "Set as default",
|
||||||
|
"switch_account": "Switch account",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -191,8 +199,12 @@
|
|||||||
"mark_read": "Mark as read",
|
"mark_read": "Mark as read",
|
||||||
"print": "Print",
|
"print": "Print",
|
||||||
"view_source": "View source",
|
"view_source": "View source",
|
||||||
|
"export_email": "Export as .eml",
|
||||||
|
"import_email": "Import .eml",
|
||||||
"keyboard_shortcuts": "Keyboard shortcuts (?)",
|
"keyboard_shortcuts": "Keyboard shortcuts (?)",
|
||||||
"email_source": "Email Source",
|
"email_source": "Email Source",
|
||||||
|
"draft_banner": "This message is a draft",
|
||||||
|
"edit_draft": "Edit",
|
||||||
"copy_source": "Copy to clipboard",
|
"copy_source": "Copy to clipboard",
|
||||||
"source_copied": "Source copied to clipboard",
|
"source_copied": "Source copied to clipboard",
|
||||||
"attachments": "Attachments",
|
"attachments": "Attachments",
|
||||||
@@ -296,7 +308,8 @@
|
|||||||
"unstar": "Unstar (s)",
|
"unstar": "Unstar (s)",
|
||||||
"compose": "Compose (c)",
|
"compose": "Compose (c)",
|
||||||
"previous": "Previous email",
|
"previous": "Previous email",
|
||||||
"next": "Next email"
|
"next": "Next email",
|
||||||
|
"edit_draft": "Edit draft"
|
||||||
},
|
},
|
||||||
"spam": {
|
"spam": {
|
||||||
"button_title": "Report spam",
|
"button_title": "Report spam",
|
||||||
@@ -514,6 +527,7 @@
|
|||||||
"identity_created": "Identity created successfully",
|
"identity_created": "Identity created successfully",
|
||||||
"identity_updated": "Identity updated successfully",
|
"identity_updated": "Identity updated successfully",
|
||||||
"identity_deleted": "Identity deleted",
|
"identity_deleted": "Identity deleted",
|
||||||
|
"identity_set_primary": "Primary identity updated",
|
||||||
"identity_create_failed": "Failed to create identity: {error}",
|
"identity_create_failed": "Failed to create identity: {error}",
|
||||||
"identity_update_failed": "Failed to update identity: {error}",
|
"identity_update_failed": "Failed to update identity: {error}",
|
||||||
"identity_delete_failed": "Failed to delete identity: {error}",
|
"identity_delete_failed": "Failed to delete identity: {error}",
|
||||||
@@ -527,7 +541,10 @@
|
|||||||
"templates_exported": "Templates exported successfully",
|
"templates_exported": "Templates exported successfully",
|
||||||
"templates_imported": "{count, plural, one {# template} other {# templates}} imported",
|
"templates_imported": "{count, plural, one {# template} other {# templates}} imported",
|
||||||
"templates_import_errors": "Some templates could not be imported",
|
"templates_import_errors": "Some templates could not be imported",
|
||||||
"templates_import_empty": "No templates found in the file"
|
"templates_import_empty": "No templates found in the file",
|
||||||
|
"export_email_error": "Failed to export email",
|
||||||
|
"import_email_success": "Email imported successfully",
|
||||||
|
"import_email_error": "Failed to import email"
|
||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
@@ -1293,7 +1310,8 @@
|
|||||||
"not_spam": "Not spam",
|
"not_spam": "Not spam",
|
||||||
"color_tag": "Label",
|
"color_tag": "Label",
|
||||||
"remove_color": "Remove Label",
|
"remove_color": "Remove Label",
|
||||||
"items_selected": "{count} emails selected"
|
"items_selected": "{count} emails selected",
|
||||||
|
"edit_draft": "Edit Draft"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Keyboard Shortcuts",
|
"title": "Keyboard Shortcuts",
|
||||||
@@ -1358,6 +1376,7 @@
|
|||||||
"delete_confirm": "Delete this identity? This cannot be undone.",
|
"delete_confirm": "Delete this identity? This cannot be undone.",
|
||||||
"cannot_delete": "This identity cannot be deleted",
|
"cannot_delete": "This identity cannot be deleted",
|
||||||
"primary_identity": "Primary",
|
"primary_identity": "Primary",
|
||||||
|
"set_as_primary": "Set as primary",
|
||||||
"no_identities": "No identities found",
|
"no_identities": "No identities found",
|
||||||
"display": {
|
"display": {
|
||||||
"reply_to": "Reply-To:",
|
"reply_to": "Reply-To:",
|
||||||
@@ -1458,6 +1477,17 @@
|
|||||||
"all": "All",
|
"all": "All",
|
||||||
"groups": "Groups"
|
"groups": "Groups"
|
||||||
},
|
},
|
||||||
|
"shared": {
|
||||||
|
"title": "Shared"
|
||||||
|
},
|
||||||
|
"address_books": {
|
||||||
|
"title": "My Address Books",
|
||||||
|
"shared_prefix": "Shared: {name}",
|
||||||
|
"moved": "Contact moved to {name}",
|
||||||
|
"moved_plural": "{count} contacts moved to {name}",
|
||||||
|
"move_failed": "Failed to move contact",
|
||||||
|
"address_book": "Address Book"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Email Addresses",
|
"emails": "Email Addresses",
|
||||||
"phones": "Phone Numbers",
|
"phones": "Phone Numbers",
|
||||||
@@ -1500,11 +1530,11 @@
|
|||||||
"personal_interest": "Interest",
|
"personal_interest": "Interest",
|
||||||
"personal_other": "Other",
|
"personal_other": "Other",
|
||||||
"gender": "Gender",
|
"gender": "Gender",
|
||||||
"gender_M": "Male",
|
"gender_masculine": "Male",
|
||||||
"gender_F": "Female",
|
"gender_feminine": "Female",
|
||||||
"gender_O": "Other",
|
"gender_other": "Other",
|
||||||
"gender_N": "Not applicable",
|
"gender_none": "Not applicable",
|
||||||
"gender_U": "Unknown",
|
"gender_unknown": "Unknown",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"calendar_uri": "Calendar URL",
|
"calendar_uri": "Calendar URL",
|
||||||
"scheduling_uri": "Scheduling URL",
|
"scheduling_uri": "Scheduling URL",
|
||||||
@@ -1513,6 +1543,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "New Contact",
|
"create_title": "New Contact",
|
||||||
"edit_title": "Edit Contact",
|
"edit_title": "Edit Contact",
|
||||||
|
"section_address_book": "Directory",
|
||||||
|
"select_address_book": "Select a directory...",
|
||||||
"section_identity": "Name & Identity",
|
"section_identity": "Name & Identity",
|
||||||
"section_work": "Work & Organization",
|
"section_work": "Work & Organization",
|
||||||
"prefix": "Prefix",
|
"prefix": "Prefix",
|
||||||
|
|||||||
+40
-8
@@ -34,6 +34,9 @@
|
|||||||
"dismiss": "Cerrar",
|
"dismiss": "Cerrar",
|
||||||
"or": "o",
|
"or": "o",
|
||||||
"sign_in_sso": "Iniciar sesión con SSO",
|
"sign_in_sso": "Iniciar sesión con SSO",
|
||||||
|
"add_account_title": "Agregar cuenta",
|
||||||
|
"add_account_subtitle": "Iniciar sesión con otra cuenta",
|
||||||
|
"cancel": "Cancelar",
|
||||||
"website": "Sitio web",
|
"website": "Sitio web",
|
||||||
"imprint": "Aviso legal",
|
"imprint": "Aviso legal",
|
||||||
"privacy_policy": "Política de privacidad",
|
"privacy_policy": "Política de privacidad",
|
||||||
@@ -58,6 +61,11 @@
|
|||||||
"storage_free": "Libre",
|
"storage_free": "Libre",
|
||||||
"storage_total": "Total",
|
"storage_total": "Total",
|
||||||
"sign_out": "Cerrar sesión",
|
"sign_out": "Cerrar sesión",
|
||||||
|
"sign_out_of": "Cerrar sesión de {account}",
|
||||||
|
"sign_out_all": "Cerrar sesión de todas las cuentas",
|
||||||
|
"add_account": "Agregar cuenta",
|
||||||
|
"set_as_default": "Establecer como predeterminada",
|
||||||
|
"switch_account": "Cambiar cuenta",
|
||||||
"contacts": "Contactos",
|
"contacts": "Contactos",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
@@ -191,8 +199,12 @@
|
|||||||
"mark_read": "Marcar como leído",
|
"mark_read": "Marcar como leído",
|
||||||
"print": "Imprimir",
|
"print": "Imprimir",
|
||||||
"view_source": "Ver código fuente",
|
"view_source": "Ver código fuente",
|
||||||
|
"export_email": "Exportar como .eml",
|
||||||
|
"import_email": "Importar .eml",
|
||||||
"keyboard_shortcuts": "Atajos de teclado (?)",
|
"keyboard_shortcuts": "Atajos de teclado (?)",
|
||||||
"email_source": "Código Fuente del Correo",
|
"email_source": "Código Fuente del Correo",
|
||||||
|
"draft_banner": "Este mensaje es un borrador",
|
||||||
|
"edit_draft": "Editar",
|
||||||
"copy_source": "Copiar al portapapeles",
|
"copy_source": "Copiar al portapapeles",
|
||||||
"source_copied": "Código fuente copiado al portapapeles",
|
"source_copied": "Código fuente copiado al portapapeles",
|
||||||
"attachments": "Archivos adjuntos",
|
"attachments": "Archivos adjuntos",
|
||||||
@@ -294,7 +306,8 @@
|
|||||||
"unstar": "Quitar estrella (s)",
|
"unstar": "Quitar estrella (s)",
|
||||||
"compose": "Redactar (c)",
|
"compose": "Redactar (c)",
|
||||||
"previous": "Correo anterior",
|
"previous": "Correo anterior",
|
||||||
"next": "Correo siguiente"
|
"next": "Correo siguiente",
|
||||||
|
"edit_draft": "Editar borrador"
|
||||||
},
|
},
|
||||||
"spam": {
|
"spam": {
|
||||||
"button_title": "Reportar spam",
|
"button_title": "Reportar spam",
|
||||||
@@ -514,6 +527,7 @@
|
|||||||
"identity_created": "Identidad creada exitosamente",
|
"identity_created": "Identidad creada exitosamente",
|
||||||
"identity_updated": "Identidad actualizada exitosamente",
|
"identity_updated": "Identidad actualizada exitosamente",
|
||||||
"identity_deleted": "Identidad eliminada",
|
"identity_deleted": "Identidad eliminada",
|
||||||
|
"identity_set_primary": "Identidad principal actualizada",
|
||||||
"identity_create_failed": "Error al crear identidad: {error}",
|
"identity_create_failed": "Error al crear identidad: {error}",
|
||||||
"identity_update_failed": "Error al actualizar identidad: {error}",
|
"identity_update_failed": "Error al actualizar identidad: {error}",
|
||||||
"identity_delete_failed": "Error al eliminar identidad: {error}",
|
"identity_delete_failed": "Error al eliminar identidad: {error}",
|
||||||
@@ -527,7 +541,10 @@
|
|||||||
"templates_exported": "Plantillas exportadas correctamente",
|
"templates_exported": "Plantillas exportadas correctamente",
|
||||||
"templates_imported": "{count, plural, one {# plantilla importada} other {# plantillas importadas}}",
|
"templates_imported": "{count, plural, one {# plantilla importada} other {# plantillas importadas}}",
|
||||||
"templates_import_errors": "Algunas plantillas no se pudieron importar",
|
"templates_import_errors": "Algunas plantillas no se pudieron importar",
|
||||||
"templates_import_empty": "No se encontraron plantillas en el archivo"
|
"templates_import_empty": "No se encontraron plantillas en el archivo",
|
||||||
|
"export_email_error": "Error al exportar el correo electrónico",
|
||||||
|
"import_email_success": "Correo electrónico importado correctamente",
|
||||||
|
"import_email_error": "Error al importar el correo electrónico"
|
||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"today": "Hoy",
|
"today": "Hoy",
|
||||||
@@ -1293,7 +1310,8 @@
|
|||||||
"not_spam": "No es spam",
|
"not_spam": "No es spam",
|
||||||
"color_tag": "Etiqueta",
|
"color_tag": "Etiqueta",
|
||||||
"remove_color": "Eliminar etiqueta",
|
"remove_color": "Eliminar etiqueta",
|
||||||
"items_selected": "{count} correos seleccionados"
|
"items_selected": "{count} correos seleccionados",
|
||||||
|
"edit_draft": "Editar borrador"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Atajos de Teclado",
|
"title": "Atajos de Teclado",
|
||||||
@@ -1358,6 +1376,7 @@
|
|||||||
"delete_confirm": "¿Eliminar esta identidad? Esto no se puede deshacer.",
|
"delete_confirm": "¿Eliminar esta identidad? Esto no se puede deshacer.",
|
||||||
"cannot_delete": "Esta identidad no se puede eliminar",
|
"cannot_delete": "Esta identidad no se puede eliminar",
|
||||||
"primary_identity": "Principal",
|
"primary_identity": "Principal",
|
||||||
|
"set_as_primary": "Establecer como principal",
|
||||||
"no_identities": "No se encontraron identidades",
|
"no_identities": "No se encontraron identidades",
|
||||||
"display": {
|
"display": {
|
||||||
"reply_to": "Responder a:",
|
"reply_to": "Responder a:",
|
||||||
@@ -1458,6 +1477,17 @@
|
|||||||
"all": "Todos",
|
"all": "Todos",
|
||||||
"groups": "Grupos"
|
"groups": "Grupos"
|
||||||
},
|
},
|
||||||
|
"shared": {
|
||||||
|
"title": "Compartidos"
|
||||||
|
},
|
||||||
|
"address_books": {
|
||||||
|
"title": "Mis Libretas de Direcciones",
|
||||||
|
"shared_prefix": "Compartido: {name}",
|
||||||
|
"moved": "Contacto movido a {name}",
|
||||||
|
"moved_plural": "{count} contactos movidos a {name}",
|
||||||
|
"move_failed": "Error al mover el contacto",
|
||||||
|
"address_book": "Libreta de direcciones"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Direcciones de correo",
|
"emails": "Direcciones de correo",
|
||||||
"phones": "Números de teléfono",
|
"phones": "Números de teléfono",
|
||||||
@@ -1491,11 +1521,11 @@
|
|||||||
"personal_interest": "Interés",
|
"personal_interest": "Interés",
|
||||||
"personal_other": "Otro",
|
"personal_other": "Otro",
|
||||||
"gender": "Género",
|
"gender": "Género",
|
||||||
"gender_M": "Masculino",
|
"gender_masculine": "Masculino",
|
||||||
"gender_F": "Femenino",
|
"gender_feminine": "Femenino",
|
||||||
"gender_O": "Otro",
|
"gender_other": "Otro",
|
||||||
"gender_N": "No aplicable",
|
"gender_none": "No aplicable",
|
||||||
"gender_U": "Desconocido",
|
"gender_unknown": "Desconocido",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"calendar_uri": "URL del calendario",
|
"calendar_uri": "URL del calendario",
|
||||||
"scheduling_uri": "URL de programación",
|
"scheduling_uri": "URL de programación",
|
||||||
@@ -1513,6 +1543,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Nuevo contacto",
|
"create_title": "Nuevo contacto",
|
||||||
"edit_title": "Editar contacto",
|
"edit_title": "Editar contacto",
|
||||||
|
"section_address_book": "Directorio",
|
||||||
|
"select_address_book": "Seleccionar un directorio...",
|
||||||
"section_identity": "Nombre e identidad",
|
"section_identity": "Nombre e identidad",
|
||||||
"section_work": "Trabajo y organización",
|
"section_work": "Trabajo y organización",
|
||||||
"prefix": "Prefijo",
|
"prefix": "Prefijo",
|
||||||
|
|||||||
+40
-8
@@ -34,6 +34,9 @@
|
|||||||
"dismiss": "Fermer",
|
"dismiss": "Fermer",
|
||||||
"or": "ou",
|
"or": "ou",
|
||||||
"sign_in_sso": "Se connecter avec SSO",
|
"sign_in_sso": "Se connecter avec SSO",
|
||||||
|
"add_account_title": "Ajouter un compte",
|
||||||
|
"add_account_subtitle": "Se connecter avec un autre compte",
|
||||||
|
"cancel": "Annuler",
|
||||||
"website": "Site web",
|
"website": "Site web",
|
||||||
"imprint": "Mentions légales",
|
"imprint": "Mentions légales",
|
||||||
"privacy_policy": "Politique de confidentialité",
|
"privacy_policy": "Politique de confidentialité",
|
||||||
@@ -58,6 +61,11 @@
|
|||||||
"storage_free": "Libre",
|
"storage_free": "Libre",
|
||||||
"storage_total": "Total",
|
"storage_total": "Total",
|
||||||
"sign_out": "Se déconnecter",
|
"sign_out": "Se déconnecter",
|
||||||
|
"sign_out_of": "Se déconnecter de {account}",
|
||||||
|
"sign_out_all": "Se déconnecter de tous les comptes",
|
||||||
|
"add_account": "Ajouter un compte",
|
||||||
|
"set_as_default": "Définir par défaut",
|
||||||
|
"switch_account": "Changer de compte",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
"calendar": "Calendrier",
|
"calendar": "Calendrier",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
@@ -191,8 +199,12 @@
|
|||||||
"mark_read": "Marquer comme lu",
|
"mark_read": "Marquer comme lu",
|
||||||
"print": "Imprimer",
|
"print": "Imprimer",
|
||||||
"view_source": "Voir la source",
|
"view_source": "Voir la source",
|
||||||
|
"export_email": "Exporter en .eml",
|
||||||
|
"import_email": "Importer un .eml",
|
||||||
"keyboard_shortcuts": "Raccourcis clavier (?)",
|
"keyboard_shortcuts": "Raccourcis clavier (?)",
|
||||||
"email_source": "Source de l'email",
|
"email_source": "Source de l'email",
|
||||||
|
"draft_banner": "Ce message est un brouillon",
|
||||||
|
"edit_draft": "Modifier",
|
||||||
"copy_source": "Copier dans le presse-papiers",
|
"copy_source": "Copier dans le presse-papiers",
|
||||||
"source_copied": "Source copiée dans le presse-papiers",
|
"source_copied": "Source copiée dans le presse-papiers",
|
||||||
"attachments": "Pièces jointes",
|
"attachments": "Pièces jointes",
|
||||||
@@ -294,7 +306,8 @@
|
|||||||
"unstar": "Ne plus suivre (s)",
|
"unstar": "Ne plus suivre (s)",
|
||||||
"compose": "Rédiger (c)",
|
"compose": "Rédiger (c)",
|
||||||
"previous": "E-mail précédent",
|
"previous": "E-mail précédent",
|
||||||
"next": "E-mail suivant"
|
"next": "E-mail suivant",
|
||||||
|
"edit_draft": "Modifier le brouillon"
|
||||||
},
|
},
|
||||||
"spam": {
|
"spam": {
|
||||||
"button_title": "Signaler comme spam",
|
"button_title": "Signaler comme spam",
|
||||||
@@ -514,6 +527,7 @@
|
|||||||
"identity_created": "Identité créée avec succès",
|
"identity_created": "Identité créée avec succès",
|
||||||
"identity_updated": "Identité mise à jour avec succès",
|
"identity_updated": "Identité mise à jour avec succès",
|
||||||
"identity_deleted": "Identité supprimée",
|
"identity_deleted": "Identité supprimée",
|
||||||
|
"identity_set_primary": "Identité principale mise à jour",
|
||||||
"identity_create_failed": "Échec de la création de l'identité: {error}",
|
"identity_create_failed": "Échec de la création de l'identité: {error}",
|
||||||
"identity_update_failed": "Échec de la mise à jour de l'identité: {error}",
|
"identity_update_failed": "Échec de la mise à jour de l'identité: {error}",
|
||||||
"identity_delete_failed": "Échec de la suppression de l'identité: {error}",
|
"identity_delete_failed": "Échec de la suppression de l'identité: {error}",
|
||||||
@@ -527,7 +541,10 @@
|
|||||||
"templates_exported": "Modèles exportés avec succès",
|
"templates_exported": "Modèles exportés avec succès",
|
||||||
"templates_imported": "{count, plural, one {# modèle importé} other {# modèles importés}}",
|
"templates_imported": "{count, plural, one {# modèle importé} other {# modèles importés}}",
|
||||||
"templates_import_errors": "Certains modèles n'ont pas pu être importés",
|
"templates_import_errors": "Certains modèles n'ont pas pu être importés",
|
||||||
"templates_import_empty": "Aucun modèle trouvé dans le fichier"
|
"templates_import_empty": "Aucun modèle trouvé dans le fichier",
|
||||||
|
"export_email_error": "Échec de l'exportation de l'e-mail",
|
||||||
|
"import_email_success": "E-mail importé avec succès",
|
||||||
|
"import_email_error": "Échec de l'importation de l'e-mail"
|
||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"today": "Aujourd'hui",
|
"today": "Aujourd'hui",
|
||||||
@@ -1293,7 +1310,8 @@
|
|||||||
"not_spam": "Pas un spam",
|
"not_spam": "Pas un spam",
|
||||||
"color_tag": "Étiquette",
|
"color_tag": "Étiquette",
|
||||||
"remove_color": "Supprimer l'étiquette",
|
"remove_color": "Supprimer l'étiquette",
|
||||||
"items_selected": "{count} emails sélectionnés"
|
"items_selected": "{count} emails sélectionnés",
|
||||||
|
"edit_draft": "Modifier le brouillon"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Raccourcis clavier",
|
"title": "Raccourcis clavier",
|
||||||
@@ -1358,6 +1376,7 @@
|
|||||||
"delete_confirm": "Supprimer cette identité ? Cette action est irréversible.",
|
"delete_confirm": "Supprimer cette identité ? Cette action est irréversible.",
|
||||||
"cannot_delete": "Cette identité ne peut pas être supprimée",
|
"cannot_delete": "Cette identité ne peut pas être supprimée",
|
||||||
"primary_identity": "Principale",
|
"primary_identity": "Principale",
|
||||||
|
"set_as_primary": "Définir comme principale",
|
||||||
"no_identities": "Aucune identité trouvée",
|
"no_identities": "Aucune identité trouvée",
|
||||||
"display": {
|
"display": {
|
||||||
"reply_to": "Répondre à :",
|
"reply_to": "Répondre à :",
|
||||||
@@ -1458,6 +1477,17 @@
|
|||||||
"all": "Tous",
|
"all": "Tous",
|
||||||
"groups": "Groupes"
|
"groups": "Groupes"
|
||||||
},
|
},
|
||||||
|
"shared": {
|
||||||
|
"title": "Partagés"
|
||||||
|
},
|
||||||
|
"address_books": {
|
||||||
|
"title": "Mes Carnets d'adresses",
|
||||||
|
"shared_prefix": "Partagé : {name}",
|
||||||
|
"moved": "Contact déplacé vers {name}",
|
||||||
|
"moved_plural": "{count} contacts déplacés vers {name}",
|
||||||
|
"move_failed": "Échec du déplacement du contact",
|
||||||
|
"address_book": "Carnet d'adresses"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Adresses e-mail",
|
"emails": "Adresses e-mail",
|
||||||
"phones": "Numéros de téléphone",
|
"phones": "Numéros de téléphone",
|
||||||
@@ -1491,11 +1521,11 @@
|
|||||||
"personal_interest": "Intérêt",
|
"personal_interest": "Intérêt",
|
||||||
"personal_other": "Autre",
|
"personal_other": "Autre",
|
||||||
"gender": "Genre",
|
"gender": "Genre",
|
||||||
"gender_M": "Masculin",
|
"gender_masculine": "Masculin",
|
||||||
"gender_F": "Féminin",
|
"gender_feminine": "Féminin",
|
||||||
"gender_O": "Autre",
|
"gender_other": "Autre",
|
||||||
"gender_N": "Non applicable",
|
"gender_none": "Non applicable",
|
||||||
"gender_U": "Inconnu",
|
"gender_unknown": "Inconnu",
|
||||||
"calendar": "Calendrier",
|
"calendar": "Calendrier",
|
||||||
"calendar_uri": "URL du calendrier",
|
"calendar_uri": "URL du calendrier",
|
||||||
"scheduling_uri": "URL de planification",
|
"scheduling_uri": "URL de planification",
|
||||||
@@ -1513,6 +1543,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Nouveau contact",
|
"create_title": "Nouveau contact",
|
||||||
"edit_title": "Modifier le contact",
|
"edit_title": "Modifier le contact",
|
||||||
|
"section_address_book": "Répertoire",
|
||||||
|
"select_address_book": "Sélectionner un répertoire...",
|
||||||
"section_identity": "Nom et identité",
|
"section_identity": "Nom et identité",
|
||||||
"section_work": "Travail et organisation",
|
"section_work": "Travail et organisation",
|
||||||
"prefix": "Préfixe",
|
"prefix": "Préfixe",
|
||||||
|
|||||||
+40
-8
@@ -34,6 +34,9 @@
|
|||||||
"dismiss": "Chiudi",
|
"dismiss": "Chiudi",
|
||||||
"or": "o",
|
"or": "o",
|
||||||
"sign_in_sso": "Accedi con SSO",
|
"sign_in_sso": "Accedi con SSO",
|
||||||
|
"add_account_title": "Aggiungi account",
|
||||||
|
"add_account_subtitle": "Accedi con un altro account",
|
||||||
|
"cancel": "Annulla",
|
||||||
"website": "Sito web",
|
"website": "Sito web",
|
||||||
"imprint": "Note legali",
|
"imprint": "Note legali",
|
||||||
"privacy_policy": "Informativa sulla privacy",
|
"privacy_policy": "Informativa sulla privacy",
|
||||||
@@ -58,6 +61,11 @@
|
|||||||
"storage_free": "Libero",
|
"storage_free": "Libero",
|
||||||
"storage_total": "Totale",
|
"storage_total": "Totale",
|
||||||
"sign_out": "Esci",
|
"sign_out": "Esci",
|
||||||
|
"sign_out_of": "Disconnetti da {account}",
|
||||||
|
"sign_out_all": "Disconnetti da tutti gli account",
|
||||||
|
"add_account": "Aggiungi account",
|
||||||
|
"set_as_default": "Imposta come predefinito",
|
||||||
|
"switch_account": "Cambia account",
|
||||||
"contacts": "Contatti",
|
"contacts": "Contatti",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"settings": "Impostazioni",
|
"settings": "Impostazioni",
|
||||||
@@ -191,8 +199,12 @@
|
|||||||
"mark_read": "Segna come letto",
|
"mark_read": "Segna come letto",
|
||||||
"print": "Stampa",
|
"print": "Stampa",
|
||||||
"view_source": "Visualizza sorgente",
|
"view_source": "Visualizza sorgente",
|
||||||
|
"export_email": "Esporta come .eml",
|
||||||
|
"import_email": "Importa .eml",
|
||||||
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
|
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
|
||||||
"email_source": "Sorgente del messaggio",
|
"email_source": "Sorgente del messaggio",
|
||||||
|
"draft_banner": "Questo messaggio è una bozza",
|
||||||
|
"edit_draft": "Modifica",
|
||||||
"copy_source": "Copia negli appunti",
|
"copy_source": "Copia negli appunti",
|
||||||
"source_copied": "Sorgente copiata negli appunti",
|
"source_copied": "Sorgente copiata negli appunti",
|
||||||
"attachments": "Allegati",
|
"attachments": "Allegati",
|
||||||
@@ -294,7 +306,8 @@
|
|||||||
"unstar": "Rimuovi stella (s)",
|
"unstar": "Rimuovi stella (s)",
|
||||||
"compose": "Scrivi (c)",
|
"compose": "Scrivi (c)",
|
||||||
"previous": "Email precedente",
|
"previous": "Email precedente",
|
||||||
"next": "Email successiva"
|
"next": "Email successiva",
|
||||||
|
"edit_draft": "Modifica bozza"
|
||||||
},
|
},
|
||||||
"spam": {
|
"spam": {
|
||||||
"button_title": "Segnala come spam",
|
"button_title": "Segnala come spam",
|
||||||
@@ -514,6 +527,7 @@
|
|||||||
"identity_created": "Identità creata con successo",
|
"identity_created": "Identità creata con successo",
|
||||||
"identity_updated": "Identità aggiornata con successo",
|
"identity_updated": "Identità aggiornata con successo",
|
||||||
"identity_deleted": "Identità eliminata",
|
"identity_deleted": "Identità eliminata",
|
||||||
|
"identity_set_primary": "Identità principale aggiornata",
|
||||||
"identity_create_failed": "Impossibile creare l'identità: {error}",
|
"identity_create_failed": "Impossibile creare l'identità: {error}",
|
||||||
"identity_update_failed": "Impossibile aggiornare l'identità: {error}",
|
"identity_update_failed": "Impossibile aggiornare l'identità: {error}",
|
||||||
"identity_delete_failed": "Impossibile eliminare l'identità: {error}",
|
"identity_delete_failed": "Impossibile eliminare l'identità: {error}",
|
||||||
@@ -527,7 +541,10 @@
|
|||||||
"templates_exported": "Modelli esportati con successo",
|
"templates_exported": "Modelli esportati con successo",
|
||||||
"templates_imported": "{count, plural, one {# modello importato} other {# modelli importati}}",
|
"templates_imported": "{count, plural, one {# modello importato} other {# modelli importati}}",
|
||||||
"templates_import_errors": "Alcuni modelli non sono stati importati",
|
"templates_import_errors": "Alcuni modelli non sono stati importati",
|
||||||
"templates_import_empty": "Nessun modello trovato nel file"
|
"templates_import_empty": "Nessun modello trovato nel file",
|
||||||
|
"export_email_error": "Impossibile esportare l'e-mail",
|
||||||
|
"import_email_success": "E-mail importata con successo",
|
||||||
|
"import_email_error": "Impossibile importare l'e-mail"
|
||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"today": "Oggi",
|
"today": "Oggi",
|
||||||
@@ -1293,7 +1310,8 @@
|
|||||||
"not_spam": "Non spam",
|
"not_spam": "Non spam",
|
||||||
"color_tag": "Etichetta",
|
"color_tag": "Etichetta",
|
||||||
"remove_color": "Rimuovi etichetta",
|
"remove_color": "Rimuovi etichetta",
|
||||||
"items_selected": "{count} messaggi selezionati"
|
"items_selected": "{count} messaggi selezionati",
|
||||||
|
"edit_draft": "Modifica bozza"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Scorciatoie da tastiera",
|
"title": "Scorciatoie da tastiera",
|
||||||
@@ -1358,6 +1376,7 @@
|
|||||||
"delete_confirm": "Eliminare questa identità? Questa azione non può essere annullata.",
|
"delete_confirm": "Eliminare questa identità? Questa azione non può essere annullata.",
|
||||||
"cannot_delete": "Questa identità non può essere eliminata",
|
"cannot_delete": "Questa identità non può essere eliminata",
|
||||||
"primary_identity": "Principale",
|
"primary_identity": "Principale",
|
||||||
|
"set_as_primary": "Imposta come principale",
|
||||||
"no_identities": "Nessuna identità trovata",
|
"no_identities": "Nessuna identità trovata",
|
||||||
"display": {
|
"display": {
|
||||||
"reply_to": "Rispondi a:",
|
"reply_to": "Rispondi a:",
|
||||||
@@ -1458,6 +1477,17 @@
|
|||||||
"all": "Tutti",
|
"all": "Tutti",
|
||||||
"groups": "Gruppi"
|
"groups": "Gruppi"
|
||||||
},
|
},
|
||||||
|
"shared": {
|
||||||
|
"title": "Condivisi"
|
||||||
|
},
|
||||||
|
"address_books": {
|
||||||
|
"title": "Le mie Rubriche",
|
||||||
|
"shared_prefix": "Condiviso: {name}",
|
||||||
|
"moved": "Contatto spostato in {name}",
|
||||||
|
"moved_plural": "{count} contatti spostati in {name}",
|
||||||
|
"move_failed": "Impossibile spostare il contatto",
|
||||||
|
"address_book": "Rubrica"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Indirizzi email",
|
"emails": "Indirizzi email",
|
||||||
"phones": "Numeri di telefono",
|
"phones": "Numeri di telefono",
|
||||||
@@ -1491,11 +1521,11 @@
|
|||||||
"personal_interest": "Interesse",
|
"personal_interest": "Interesse",
|
||||||
"personal_other": "Altro",
|
"personal_other": "Altro",
|
||||||
"gender": "Genere",
|
"gender": "Genere",
|
||||||
"gender_M": "Maschile",
|
"gender_masculine": "Maschile",
|
||||||
"gender_F": "Femminile",
|
"gender_feminine": "Femminile",
|
||||||
"gender_O": "Altro",
|
"gender_other": "Altro",
|
||||||
"gender_N": "Non applicabile",
|
"gender_none": "Non applicabile",
|
||||||
"gender_U": "Sconosciuto",
|
"gender_unknown": "Sconosciuto",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"calendar_uri": "URL del calendario",
|
"calendar_uri": "URL del calendario",
|
||||||
"scheduling_uri": "URL di pianificazione",
|
"scheduling_uri": "URL di pianificazione",
|
||||||
@@ -1513,6 +1543,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Nuovo contatto",
|
"create_title": "Nuovo contatto",
|
||||||
"edit_title": "Modifica contatto",
|
"edit_title": "Modifica contatto",
|
||||||
|
"section_address_book": "Rubrica",
|
||||||
|
"select_address_book": "Seleziona una rubrica...",
|
||||||
"section_identity": "Nome e identità",
|
"section_identity": "Nome e identità",
|
||||||
"section_work": "Lavoro e organizzazione",
|
"section_work": "Lavoro e organizzazione",
|
||||||
"prefix": "Prefisso",
|
"prefix": "Prefisso",
|
||||||
|
|||||||
+40
-8
@@ -34,6 +34,9 @@
|
|||||||
"dismiss": "閉じる",
|
"dismiss": "閉じる",
|
||||||
"or": "または",
|
"or": "または",
|
||||||
"sign_in_sso": "SSOでサインイン",
|
"sign_in_sso": "SSOでサインイン",
|
||||||
|
"add_account_title": "アカウントを追加",
|
||||||
|
"add_account_subtitle": "別のアカウントでサインイン",
|
||||||
|
"cancel": "キャンセル",
|
||||||
"website": "ウェブサイト",
|
"website": "ウェブサイト",
|
||||||
"imprint": "サイト運営者情報",
|
"imprint": "サイト運営者情報",
|
||||||
"privacy_policy": "プライバシーポリシー",
|
"privacy_policy": "プライバシーポリシー",
|
||||||
@@ -58,6 +61,11 @@
|
|||||||
"storage_free": "空き",
|
"storage_free": "空き",
|
||||||
"storage_total": "合計",
|
"storage_total": "合計",
|
||||||
"sign_out": "サインアウト",
|
"sign_out": "サインアウト",
|
||||||
|
"sign_out_of": "{account} からサインアウト",
|
||||||
|
"sign_out_all": "すべてのアカウントからサインアウト",
|
||||||
|
"add_account": "アカウントを追加",
|
||||||
|
"set_as_default": "デフォルトに設定",
|
||||||
|
"switch_account": "アカウントを切り替え",
|
||||||
"contacts": "連絡先",
|
"contacts": "連絡先",
|
||||||
"calendar": "カレンダー",
|
"calendar": "カレンダー",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
@@ -191,8 +199,12 @@
|
|||||||
"mark_read": "既読にする",
|
"mark_read": "既読にする",
|
||||||
"print": "印刷",
|
"print": "印刷",
|
||||||
"view_source": "ソースを表示",
|
"view_source": "ソースを表示",
|
||||||
|
"export_email": ".emlとしてエクスポート",
|
||||||
|
"import_email": ".emlをインポート",
|
||||||
"keyboard_shortcuts": "キーボードショートカット (?)",
|
"keyboard_shortcuts": "キーボードショートカット (?)",
|
||||||
"email_source": "メールソース",
|
"email_source": "メールソース",
|
||||||
|
"draft_banner": "このメッセージは下書きです",
|
||||||
|
"edit_draft": "編集",
|
||||||
"copy_source": "クリップボードにコピー",
|
"copy_source": "クリップボードにコピー",
|
||||||
"source_copied": "ソースをクリップボードにコピーしました",
|
"source_copied": "ソースをクリップボードにコピーしました",
|
||||||
"attachments": "添付ファイル",
|
"attachments": "添付ファイル",
|
||||||
@@ -294,7 +306,8 @@
|
|||||||
"unstar": "スター解除 (s)",
|
"unstar": "スター解除 (s)",
|
||||||
"compose": "新規作成 (c)",
|
"compose": "新規作成 (c)",
|
||||||
"previous": "前のメール",
|
"previous": "前のメール",
|
||||||
"next": "次のメール"
|
"next": "次のメール",
|
||||||
|
"edit_draft": "下書きを編集"
|
||||||
},
|
},
|
||||||
"spam": {
|
"spam": {
|
||||||
"button_title": "迷惑メールを報告",
|
"button_title": "迷惑メールを報告",
|
||||||
@@ -514,6 +527,7 @@
|
|||||||
"identity_created": "送信者情報を作成しました",
|
"identity_created": "送信者情報を作成しました",
|
||||||
"identity_updated": "送信者情報を更新しました",
|
"identity_updated": "送信者情報を更新しました",
|
||||||
"identity_deleted": "送信者情報を削除しました",
|
"identity_deleted": "送信者情報を削除しました",
|
||||||
|
"identity_set_primary": "プライマリ送信者情報を更新しました",
|
||||||
"identity_create_failed": "送信者情報の作成に失敗しました: {error}",
|
"identity_create_failed": "送信者情報の作成に失敗しました: {error}",
|
||||||
"identity_update_failed": "送信者情報の更新に失敗しました: {error}",
|
"identity_update_failed": "送信者情報の更新に失敗しました: {error}",
|
||||||
"identity_delete_failed": "送信者情報の削除に失敗しました: {error}",
|
"identity_delete_failed": "送信者情報の削除に失敗しました: {error}",
|
||||||
@@ -527,7 +541,10 @@
|
|||||||
"templates_exported": "テンプレートをエクスポートしました",
|
"templates_exported": "テンプレートをエクスポートしました",
|
||||||
"templates_imported": "{count}件のテンプレートをインポートしました",
|
"templates_imported": "{count}件のテンプレートをインポートしました",
|
||||||
"templates_import_errors": "一部のテンプレートをインポートできませんでした",
|
"templates_import_errors": "一部のテンプレートをインポートできませんでした",
|
||||||
"templates_import_empty": "ファイルにテンプレートが見つかりません"
|
"templates_import_empty": "ファイルにテンプレートが見つかりません",
|
||||||
|
"export_email_error": "メールのエクスポートに失敗しました",
|
||||||
|
"import_email_success": "メールを正常にインポートしました",
|
||||||
|
"import_email_error": "メールのインポートに失敗しました"
|
||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"today": "今日",
|
"today": "今日",
|
||||||
@@ -1293,7 +1310,8 @@
|
|||||||
"not_spam": "迷惑メールでない",
|
"not_spam": "迷惑メールでない",
|
||||||
"color_tag": "ラベル",
|
"color_tag": "ラベル",
|
||||||
"remove_color": "ラベルを削除",
|
"remove_color": "ラベルを削除",
|
||||||
"items_selected": "{count}件のメールを選択"
|
"items_selected": "{count}件のメールを選択",
|
||||||
|
"edit_draft": "下書きを編集"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "キーボードショートカット",
|
"title": "キーボードショートカット",
|
||||||
@@ -1358,6 +1376,7 @@
|
|||||||
"delete_confirm": "この送信者情報を削除しますか?この操作は元に戻せません。",
|
"delete_confirm": "この送信者情報を削除しますか?この操作は元に戻せません。",
|
||||||
"cannot_delete": "この送信者情報は削除できません",
|
"cannot_delete": "この送信者情報は削除できません",
|
||||||
"primary_identity": "プライマリ",
|
"primary_identity": "プライマリ",
|
||||||
|
"set_as_primary": "プライマリに設定",
|
||||||
"no_identities": "送信者情報が見つかりません",
|
"no_identities": "送信者情報が見つかりません",
|
||||||
"display": {
|
"display": {
|
||||||
"reply_to": "返信先:",
|
"reply_to": "返信先:",
|
||||||
@@ -1458,6 +1477,17 @@
|
|||||||
"all": "すべて",
|
"all": "すべて",
|
||||||
"groups": "グループ"
|
"groups": "グループ"
|
||||||
},
|
},
|
||||||
|
"shared": {
|
||||||
|
"title": "共有"
|
||||||
|
},
|
||||||
|
"address_books": {
|
||||||
|
"title": "マイアドレス帳",
|
||||||
|
"shared_prefix": "共有: {name}",
|
||||||
|
"moved": "連絡先を {name} に移動しました",
|
||||||
|
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
|
||||||
|
"move_failed": "連絡先の移動に失敗しました",
|
||||||
|
"address_book": "アドレス帳"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "メールアドレス",
|
"emails": "メールアドレス",
|
||||||
"phones": "電話番号",
|
"phones": "電話番号",
|
||||||
@@ -1491,11 +1521,11 @@
|
|||||||
"personal_interest": "興味",
|
"personal_interest": "興味",
|
||||||
"personal_other": "その他",
|
"personal_other": "その他",
|
||||||
"gender": "性別",
|
"gender": "性別",
|
||||||
"gender_M": "男性",
|
"gender_masculine": "男性",
|
||||||
"gender_F": "女性",
|
"gender_feminine": "女性",
|
||||||
"gender_O": "その他",
|
"gender_other": "その他",
|
||||||
"gender_N": "該当なし",
|
"gender_none": "該当なし",
|
||||||
"gender_U": "不明",
|
"gender_unknown": "不明",
|
||||||
"calendar": "カレンダー",
|
"calendar": "カレンダー",
|
||||||
"calendar_uri": "カレンダーURL",
|
"calendar_uri": "カレンダーURL",
|
||||||
"scheduling_uri": "スケジュールURL",
|
"scheduling_uri": "スケジュールURL",
|
||||||
@@ -1513,6 +1543,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "新しい連絡先",
|
"create_title": "新しい連絡先",
|
||||||
"edit_title": "連絡先を編集",
|
"edit_title": "連絡先を編集",
|
||||||
|
"section_address_book": "ディレクトリ",
|
||||||
|
"select_address_book": "ディレクトリを選択...",
|
||||||
"section_identity": "名前と識別情報",
|
"section_identity": "名前と識別情報",
|
||||||
"section_work": "職業と組織",
|
"section_work": "職業と組織",
|
||||||
"prefix": "敬称",
|
"prefix": "敬称",
|
||||||
|
|||||||
+40
-8
@@ -34,6 +34,9 @@
|
|||||||
"dismiss": "Sluiten",
|
"dismiss": "Sluiten",
|
||||||
"or": "of",
|
"or": "of",
|
||||||
"sign_in_sso": "Inloggen met SSO",
|
"sign_in_sso": "Inloggen met SSO",
|
||||||
|
"add_account_title": "Account toevoegen",
|
||||||
|
"add_account_subtitle": "Inloggen met een ander account",
|
||||||
|
"cancel": "Annuleren",
|
||||||
"website": "Website",
|
"website": "Website",
|
||||||
"imprint": "Colofon",
|
"imprint": "Colofon",
|
||||||
"privacy_policy": "Privacybeleid",
|
"privacy_policy": "Privacybeleid",
|
||||||
@@ -58,6 +61,11 @@
|
|||||||
"storage_free": "Vrij",
|
"storage_free": "Vrij",
|
||||||
"storage_total": "Totaal",
|
"storage_total": "Totaal",
|
||||||
"sign_out": "Afmelden",
|
"sign_out": "Afmelden",
|
||||||
|
"sign_out_of": "Uitloggen van {account}",
|
||||||
|
"sign_out_all": "Uitloggen van alle accounts",
|
||||||
|
"add_account": "Account toevoegen",
|
||||||
|
"set_as_default": "Als standaard instellen",
|
||||||
|
"switch_account": "Account wisselen",
|
||||||
"contacts": "Contacten",
|
"contacts": "Contacten",
|
||||||
"calendar": "Agenda",
|
"calendar": "Agenda",
|
||||||
"settings": "Instellingen",
|
"settings": "Instellingen",
|
||||||
@@ -191,8 +199,12 @@
|
|||||||
"mark_read": "Markeren als gelezen",
|
"mark_read": "Markeren als gelezen",
|
||||||
"print": "Afdrukken",
|
"print": "Afdrukken",
|
||||||
"view_source": "Bron bekijken",
|
"view_source": "Bron bekijken",
|
||||||
|
"export_email": "Exporteren als .eml",
|
||||||
|
"import_email": ".eml importeren",
|
||||||
"keyboard_shortcuts": "Sneltoetsen (?)",
|
"keyboard_shortcuts": "Sneltoetsen (?)",
|
||||||
"email_source": "E-mailbron",
|
"email_source": "E-mailbron",
|
||||||
|
"draft_banner": "Dit bericht is een concept",
|
||||||
|
"edit_draft": "Bewerken",
|
||||||
"copy_source": "Kopiëren naar klembord",
|
"copy_source": "Kopiëren naar klembord",
|
||||||
"source_copied": "Bron gekopieerd naar klembord",
|
"source_copied": "Bron gekopieerd naar klembord",
|
||||||
"attachments": "Bijlagen",
|
"attachments": "Bijlagen",
|
||||||
@@ -294,7 +306,8 @@
|
|||||||
"unstar": "Ster verwijderen (s)",
|
"unstar": "Ster verwijderen (s)",
|
||||||
"compose": "Opstellen (c)",
|
"compose": "Opstellen (c)",
|
||||||
"previous": "Vorige e-mail",
|
"previous": "Vorige e-mail",
|
||||||
"next": "Volgende e-mail"
|
"next": "Volgende e-mail",
|
||||||
|
"edit_draft": "Concept bewerken"
|
||||||
},
|
},
|
||||||
"spam": {
|
"spam": {
|
||||||
"button_title": "Spam melden",
|
"button_title": "Spam melden",
|
||||||
@@ -514,6 +527,7 @@
|
|||||||
"identity_created": "Identiteit succesvol aangemaakt",
|
"identity_created": "Identiteit succesvol aangemaakt",
|
||||||
"identity_updated": "Identiteit succesvol bijgewerkt",
|
"identity_updated": "Identiteit succesvol bijgewerkt",
|
||||||
"identity_deleted": "Identiteit verwijderd",
|
"identity_deleted": "Identiteit verwijderd",
|
||||||
|
"identity_set_primary": "Primaire identiteit bijgewerkt",
|
||||||
"identity_create_failed": "Kan identiteit niet aanmaken: {error}",
|
"identity_create_failed": "Kan identiteit niet aanmaken: {error}",
|
||||||
"identity_update_failed": "Kan identiteit niet bijwerken: {error}",
|
"identity_update_failed": "Kan identiteit niet bijwerken: {error}",
|
||||||
"identity_delete_failed": "Kan identiteit niet verwijderen: {error}",
|
"identity_delete_failed": "Kan identiteit niet verwijderen: {error}",
|
||||||
@@ -527,7 +541,10 @@
|
|||||||
"templates_exported": "Sjablonen succesvol geëxporteerd",
|
"templates_exported": "Sjablonen succesvol geëxporteerd",
|
||||||
"templates_imported": "{count, plural, one {# sjabloon geïmporteerd} other {# sjablonen geïmporteerd}}",
|
"templates_imported": "{count, plural, one {# sjabloon geïmporteerd} other {# sjablonen geïmporteerd}}",
|
||||||
"templates_import_errors": "Sommige sjablonen konden niet worden geïmporteerd",
|
"templates_import_errors": "Sommige sjablonen konden niet worden geïmporteerd",
|
||||||
"templates_import_empty": "Geen sjablonen gevonden in het bestand"
|
"templates_import_empty": "Geen sjablonen gevonden in het bestand",
|
||||||
|
"export_email_error": "Kan e-mail niet exporteren",
|
||||||
|
"import_email_success": "E-mail succesvol geïmporteerd",
|
||||||
|
"import_email_error": "Kan e-mail niet importeren"
|
||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"today": "Vandaag",
|
"today": "Vandaag",
|
||||||
@@ -1293,7 +1310,8 @@
|
|||||||
"not_spam": "Geen spam",
|
"not_spam": "Geen spam",
|
||||||
"color_tag": "Label",
|
"color_tag": "Label",
|
||||||
"remove_color": "Label verwijderen",
|
"remove_color": "Label verwijderen",
|
||||||
"items_selected": "{count} e-mails geselecteerd"
|
"items_selected": "{count} e-mails geselecteerd",
|
||||||
|
"edit_draft": "Concept bewerken"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Sneltoetsen",
|
"title": "Sneltoetsen",
|
||||||
@@ -1358,6 +1376,7 @@
|
|||||||
"delete_confirm": "Deze identiteit verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
"delete_confirm": "Deze identiteit verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||||
"cannot_delete": "Deze identiteit kan niet worden verwijderd",
|
"cannot_delete": "Deze identiteit kan niet worden verwijderd",
|
||||||
"primary_identity": "Primair",
|
"primary_identity": "Primair",
|
||||||
|
"set_as_primary": "Instellen als primair",
|
||||||
"no_identities": "Geen identiteiten gevonden",
|
"no_identities": "Geen identiteiten gevonden",
|
||||||
"display": {
|
"display": {
|
||||||
"reply_to": "Antwoord naar:",
|
"reply_to": "Antwoord naar:",
|
||||||
@@ -1458,6 +1477,17 @@
|
|||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"groups": "Groepen"
|
"groups": "Groepen"
|
||||||
},
|
},
|
||||||
|
"shared": {
|
||||||
|
"title": "Gedeeld"
|
||||||
|
},
|
||||||
|
"address_books": {
|
||||||
|
"title": "Mijn Adresboeken",
|
||||||
|
"shared_prefix": "Gedeeld: {name}",
|
||||||
|
"moved": "Contact verplaatst naar {name}",
|
||||||
|
"moved_plural": "{count} contacten verplaatst naar {name}",
|
||||||
|
"move_failed": "Verplaatsen van contact mislukt",
|
||||||
|
"address_book": "Adresboek"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-mailadressen",
|
"emails": "E-mailadressen",
|
||||||
"phones": "Telefoonnummers",
|
"phones": "Telefoonnummers",
|
||||||
@@ -1491,11 +1521,11 @@
|
|||||||
"personal_interest": "Interesse",
|
"personal_interest": "Interesse",
|
||||||
"personal_other": "Overig",
|
"personal_other": "Overig",
|
||||||
"gender": "Geslacht",
|
"gender": "Geslacht",
|
||||||
"gender_M": "Man",
|
"gender_masculine": "Man",
|
||||||
"gender_F": "Vrouw",
|
"gender_feminine": "Vrouw",
|
||||||
"gender_O": "Anders",
|
"gender_other": "Anders",
|
||||||
"gender_N": "Niet van toepassing",
|
"gender_none": "Niet van toepassing",
|
||||||
"gender_U": "Onbekend",
|
"gender_unknown": "Onbekend",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"calendar_uri": "Kalender-URL",
|
"calendar_uri": "Kalender-URL",
|
||||||
"scheduling_uri": "Planning-URL",
|
"scheduling_uri": "Planning-URL",
|
||||||
@@ -1513,6 +1543,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Nieuw contact",
|
"create_title": "Nieuw contact",
|
||||||
"edit_title": "Contact bewerken",
|
"edit_title": "Contact bewerken",
|
||||||
|
"section_address_book": "Adresboek",
|
||||||
|
"select_address_book": "Selecteer een adresboek...",
|
||||||
"section_identity": "Naam en identiteit",
|
"section_identity": "Naam en identiteit",
|
||||||
"section_work": "Werk en organisatie",
|
"section_work": "Werk en organisatie",
|
||||||
"prefix": "Voorvoegsel",
|
"prefix": "Voorvoegsel",
|
||||||
|
|||||||
+40
-8
@@ -34,6 +34,9 @@
|
|||||||
"dismiss": "Fechar",
|
"dismiss": "Fechar",
|
||||||
"or": "ou",
|
"or": "ou",
|
||||||
"sign_in_sso": "Entrar com SSO",
|
"sign_in_sso": "Entrar com SSO",
|
||||||
|
"add_account_title": "Adicionar conta",
|
||||||
|
"add_account_subtitle": "Entrar com outra conta",
|
||||||
|
"cancel": "Cancelar",
|
||||||
"website": "Site",
|
"website": "Site",
|
||||||
"imprint": "Informações legais",
|
"imprint": "Informações legais",
|
||||||
"privacy_policy": "Política de privacidade",
|
"privacy_policy": "Política de privacidade",
|
||||||
@@ -58,6 +61,11 @@
|
|||||||
"storage_free": "Livre",
|
"storage_free": "Livre",
|
||||||
"storage_total": "Total",
|
"storage_total": "Total",
|
||||||
"sign_out": "Sair",
|
"sign_out": "Sair",
|
||||||
|
"sign_out_of": "Sair de {account}",
|
||||||
|
"sign_out_all": "Sair de todas as contas",
|
||||||
|
"add_account": "Adicionar conta",
|
||||||
|
"set_as_default": "Definir como padrão",
|
||||||
|
"switch_account": "Trocar conta",
|
||||||
"contacts": "Contatos",
|
"contacts": "Contatos",
|
||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
@@ -191,8 +199,12 @@
|
|||||||
"mark_read": "Marcar como lido",
|
"mark_read": "Marcar como lido",
|
||||||
"print": "Imprimir",
|
"print": "Imprimir",
|
||||||
"view_source": "Ver código-fonte",
|
"view_source": "Ver código-fonte",
|
||||||
|
"export_email": "Exportar como .eml",
|
||||||
|
"import_email": "Importar .eml",
|
||||||
"keyboard_shortcuts": "Atalhos de teclado (?)",
|
"keyboard_shortcuts": "Atalhos de teclado (?)",
|
||||||
"email_source": "Código-fonte do E-mail",
|
"email_source": "Código-fonte do E-mail",
|
||||||
|
"draft_banner": "Esta mensagem é um rascunho",
|
||||||
|
"edit_draft": "Editar",
|
||||||
"copy_source": "Copiar para a área de transferência",
|
"copy_source": "Copiar para a área de transferência",
|
||||||
"source_copied": "Código-fonte copiado para a área de transferência",
|
"source_copied": "Código-fonte copiado para a área de transferência",
|
||||||
"attachments": "Anexos",
|
"attachments": "Anexos",
|
||||||
@@ -294,7 +306,8 @@
|
|||||||
"unstar": "Remover favorito (s)",
|
"unstar": "Remover favorito (s)",
|
||||||
"compose": "Compor (c)",
|
"compose": "Compor (c)",
|
||||||
"previous": "E-mail anterior",
|
"previous": "E-mail anterior",
|
||||||
"next": "Próximo e-mail"
|
"next": "Próximo e-mail",
|
||||||
|
"edit_draft": "Editar rascunho"
|
||||||
},
|
},
|
||||||
"spam": {
|
"spam": {
|
||||||
"button_title": "Reportar spam",
|
"button_title": "Reportar spam",
|
||||||
@@ -514,6 +527,7 @@
|
|||||||
"identity_created": "Identidade criada com sucesso",
|
"identity_created": "Identidade criada com sucesso",
|
||||||
"identity_updated": "Identidade atualizada com sucesso",
|
"identity_updated": "Identidade atualizada com sucesso",
|
||||||
"identity_deleted": "Identidade excluída",
|
"identity_deleted": "Identidade excluída",
|
||||||
|
"identity_set_primary": "Identidade principal atualizada",
|
||||||
"identity_create_failed": "Falha ao criar identidade: {error}",
|
"identity_create_failed": "Falha ao criar identidade: {error}",
|
||||||
"identity_update_failed": "Falha ao atualizar identidade: {error}",
|
"identity_update_failed": "Falha ao atualizar identidade: {error}",
|
||||||
"identity_delete_failed": "Falha ao excluir identidade: {error}",
|
"identity_delete_failed": "Falha ao excluir identidade: {error}",
|
||||||
@@ -527,7 +541,10 @@
|
|||||||
"templates_exported": "Modelos exportados com sucesso",
|
"templates_exported": "Modelos exportados com sucesso",
|
||||||
"templates_imported": "{count, plural, one {# modelo importado} other {# modelos importados}}",
|
"templates_imported": "{count, plural, one {# modelo importado} other {# modelos importados}}",
|
||||||
"templates_import_errors": "Alguns modelos não puderam ser importados",
|
"templates_import_errors": "Alguns modelos não puderam ser importados",
|
||||||
"templates_import_empty": "Nenhum modelo encontrado no arquivo"
|
"templates_import_empty": "Nenhum modelo encontrado no arquivo",
|
||||||
|
"export_email_error": "Falha ao exportar o e-mail",
|
||||||
|
"import_email_success": "E-mail importado com sucesso",
|
||||||
|
"import_email_error": "Falha ao importar o e-mail"
|
||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"today": "Hoje",
|
"today": "Hoje",
|
||||||
@@ -1293,7 +1310,8 @@
|
|||||||
"not_spam": "Não é spam",
|
"not_spam": "Não é spam",
|
||||||
"color_tag": "Etiqueta",
|
"color_tag": "Etiqueta",
|
||||||
"remove_color": "Remover etiqueta",
|
"remove_color": "Remover etiqueta",
|
||||||
"items_selected": "{count} e-mails selecionados"
|
"items_selected": "{count} e-mails selecionados",
|
||||||
|
"edit_draft": "Editar rascunho"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"title": "Atalhos de Teclado",
|
"title": "Atalhos de Teclado",
|
||||||
@@ -1358,6 +1376,7 @@
|
|||||||
"delete_confirm": "Excluir esta identidade? Isso não pode ser desfeito.",
|
"delete_confirm": "Excluir esta identidade? Isso não pode ser desfeito.",
|
||||||
"cannot_delete": "Esta identidade não pode ser excluída",
|
"cannot_delete": "Esta identidade não pode ser excluída",
|
||||||
"primary_identity": "Principal",
|
"primary_identity": "Principal",
|
||||||
|
"set_as_primary": "Definir como principal",
|
||||||
"no_identities": "Nenhuma identidade encontrada",
|
"no_identities": "Nenhuma identidade encontrada",
|
||||||
"display": {
|
"display": {
|
||||||
"reply_to": "Responder para:",
|
"reply_to": "Responder para:",
|
||||||
@@ -1458,6 +1477,17 @@
|
|||||||
"all": "Todos",
|
"all": "Todos",
|
||||||
"groups": "Grupos"
|
"groups": "Grupos"
|
||||||
},
|
},
|
||||||
|
"shared": {
|
||||||
|
"title": "Compartilhados"
|
||||||
|
},
|
||||||
|
"address_books": {
|
||||||
|
"title": "Meus Catálogos de Endereços",
|
||||||
|
"shared_prefix": "Compartilhado: {name}",
|
||||||
|
"moved": "Contato movido para {name}",
|
||||||
|
"moved_plural": "{count} contatos movidos para {name}",
|
||||||
|
"move_failed": "Falha ao mover o contato",
|
||||||
|
"address_book": "Catálogo de endereços"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Endereços de e-mail",
|
"emails": "Endereços de e-mail",
|
||||||
"phones": "Números de telefone",
|
"phones": "Números de telefone",
|
||||||
@@ -1491,11 +1521,11 @@
|
|||||||
"personal_interest": "Interesse",
|
"personal_interest": "Interesse",
|
||||||
"personal_other": "Outro",
|
"personal_other": "Outro",
|
||||||
"gender": "Gênero",
|
"gender": "Gênero",
|
||||||
"gender_M": "Masculino",
|
"gender_masculine": "Masculino",
|
||||||
"gender_F": "Feminino",
|
"gender_feminine": "Feminino",
|
||||||
"gender_O": "Outro",
|
"gender_other": "Outro",
|
||||||
"gender_N": "Não aplicável",
|
"gender_none": "Não aplicável",
|
||||||
"gender_U": "Desconhecido",
|
"gender_unknown": "Desconhecido",
|
||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
"calendar_uri": "URL do calendário",
|
"calendar_uri": "URL do calendário",
|
||||||
"scheduling_uri": "URL de agendamento",
|
"scheduling_uri": "URL de agendamento",
|
||||||
@@ -1513,6 +1543,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Novo contato",
|
"create_title": "Novo contato",
|
||||||
"edit_title": "Editar contato",
|
"edit_title": "Editar contato",
|
||||||
|
"section_address_book": "Diretório",
|
||||||
|
"select_address_book": "Selecionar um diretório...",
|
||||||
"section_identity": "Nome e identidade",
|
"section_identity": "Nome e identidade",
|
||||||
"section_work": "Trabalho e organização",
|
"section_work": "Trabalho e organização",
|
||||||
"prefix": "Prefixo",
|
"prefix": "Prefixo",
|
||||||
|
|||||||
Generated
+196
-9
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.0",
|
"version": "1.4.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.0",
|
"version": "1.4.3",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"asn1js": "^3.0.7",
|
"asn1js": "^3.0.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.3",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "^16.1.5",
|
"next": "^16.1.5",
|
||||||
"next-intl": "^4.5.8",
|
"next-intl": "^4.5.8",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"webcrypto-liner": "^1.4.3",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2375,6 +2376,29 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@peculiar/asn1-schema": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1js": "^3.0.6",
|
||||||
|
"pvtsutils": "^1.3.6",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/json-schema": {
|
||||||
|
"version": "1.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz",
|
||||||
|
"integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
@@ -2761,6 +2785,44 @@
|
|||||||
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/binary": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/int": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@stablelib/hash": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/hash/-/hash-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-eTPJc/stDkdtOcrNMZ6mcMK1e6yBbqRBaNW55XA1jU8w/7QdnCF0CmMmOD1m7VSkBR44PWrMHU2l6r8YEQHMgg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@stablelib/int": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@stablelib/sha3": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/sha3/-/sha3-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-82OHZcxWsJAS34L64VItIbqZdcdYgBJmeToYaou9lUA+iMjajdfOVZDDrditfV8C8yXUDrlS3BuMRWmKf9NQhQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/binary": "^1.0.1",
|
||||||
|
"@stablelib/hash": "^1.0.1",
|
||||||
|
"@stablelib/wipe": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@stablelib/wipe": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -4075,6 +4137,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asmcrypto.js": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/asn1js": {
|
"node_modules/asn1js": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
|
||||||
@@ -4154,6 +4222,12 @@
|
|||||||
"require-from-string": "^2.0.2"
|
"require-from-string": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||||
|
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||||
@@ -4177,6 +4251,12 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/brorand": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
@@ -4376,6 +4456,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||||
|
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4600,6 +4691,16 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/des.js": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -4630,9 +4731,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
@@ -4660,6 +4761,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/elliptic": {
|
||||||
|
"version": "6.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz",
|
||||||
|
"integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.11.9",
|
||||||
|
"brorand": "^1.1.0",
|
||||||
|
"hash.js": "^1.0.0",
|
||||||
|
"hmac-drbg": "^1.0.1",
|
||||||
|
"inherits": "^2.0.4",
|
||||||
|
"minimalistic-assert": "^1.0.1",
|
||||||
|
"minimalistic-crypto-utils": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.20.0",
|
"version": "5.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||||
@@ -5663,6 +5779,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hash.js": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"minimalistic-assert": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -5693,6 +5819,17 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hmac-drbg": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hash.js": "^1.0.3",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"minimalistic-crypto-utils": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-encoding-sniffer": {
|
"node_modules/html-encoding-sniffer": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||||
@@ -5812,6 +5949,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -6779,6 +6922,18 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/minimalistic-crypto-utils": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.4",
|
"version": "10.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||||
@@ -8502,9 +8657,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.22.0",
|
"version": "7.24.4",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
|
||||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
"integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8805,6 +8960,38 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webcrypto-core": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.3.13",
|
||||||
|
"@peculiar/json-schema": "^1.1.12",
|
||||||
|
"asn1js": "^3.0.5",
|
||||||
|
"pvtsutils": "^1.3.5",
|
||||||
|
"tslib": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webcrypto-liner": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/webcrypto-liner/-/webcrypto-liner-1.4.3.tgz",
|
||||||
|
"integrity": "sha512-gzlk7ciS5zqc8QZMwpzpRxxwkcQKDJDndhr/hHWQe18Rzafhji3a7CaSxIeA2jcL0bLcAK+P77K3lWS1QXMMYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.3.8",
|
||||||
|
"@peculiar/json-schema": "^1.1.12",
|
||||||
|
"@stablelib/sha3": "^1.0.1",
|
||||||
|
"asmcrypto.js": "^2.3.2",
|
||||||
|
"asn1js": "^3.0.5",
|
||||||
|
"core-js": "^3.35.1",
|
||||||
|
"des.js": "^1.1.0",
|
||||||
|
"elliptic": "git+https://github.com/mahrud/elliptic.git",
|
||||||
|
"pvtsutils": "^1.3.5",
|
||||||
|
"tslib": "^2.6.2",
|
||||||
|
"webcrypto-core": "^1.7.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.0",
|
"version": "1.4.3",
|
||||||
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"asn1js": "^3.0.7",
|
"asn1js": "^3.0.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.3",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "^16.1.5",
|
"next": "^16.1.5",
|
||||||
"next-intl": "^4.5.8",
|
"next-intl": "^4.5.8",
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"webcrypto-liner": "^1.4.3",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -73,5 +74,9 @@
|
|||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"elliptic": "^6.6.1",
|
||||||
|
"undici": "^7.24.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
/**
|
|
||||||
* Debug script for TNEF parser — dumps raw attribute structure.
|
|
||||||
*/
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
|
|
||||||
const inputPath = process.argv[2];
|
|
||||||
if (!inputPath) {
|
|
||||||
console.error('Usage: npx tsx scripts/debug-tnef.ts <path-to-winmail.dat>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = new Uint8Array(readFileSync(resolve(inputPath)));
|
|
||||||
|
|
||||||
// TNEF attribute ID names
|
|
||||||
const ATTR_NAMES: Record<number, string> = {
|
|
||||||
0x00069003: 'attMAPIProps',
|
|
||||||
0x0002800C: 'attBody',
|
|
||||||
0x00069002: 'attAttachRenddata',
|
|
||||||
0x0006800F: 'attAttachData',
|
|
||||||
0x00018010: 'attAttachTitle',
|
|
||||||
0x00069005: 'attAttachment (MAPI)',
|
|
||||||
0x00028005: 'attSubject',
|
|
||||||
0x00068007: 'attMessageClass',
|
|
||||||
0x00078006: 'attDateSent',
|
|
||||||
0x00078008: 'attDateModified',
|
|
||||||
0x0006900B: 'attRecipTable',
|
|
||||||
0x00069001: 'attOwner',
|
|
||||||
0x00060001: 'attFrom',
|
|
||||||
0x00078004: 'attDateStart',
|
|
||||||
0x0001800A: 'attMessageID',
|
|
||||||
0x00050008: 'attPriority',
|
|
||||||
0x00040009: 'attAidOwner',
|
|
||||||
0x00010004: 'attConversationID',
|
|
||||||
0x0001800D: 'attParentID',
|
|
||||||
0x00018011: 'attAttachCreateDate',
|
|
||||||
0x00018012: 'attAttachModifyDate',
|
|
||||||
0x00060002: 'attDateRecd',
|
|
||||||
0x00060003: 'attAssignedTo',
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAPI_PROP_NAMES: Record<number, string> = {
|
|
||||||
0x0037: 'PR_SUBJECT',
|
|
||||||
0x1000: 'PR_BODY',
|
|
||||||
0x1009: 'PR_RTF_COMPRESSED',
|
|
||||||
0x1013: 'PR_BODY_HTML',
|
|
||||||
0x1014: 'PR_BODY_CONTENT_ID',
|
|
||||||
0x0E1F: 'PR_RTF_IN_SYNC',
|
|
||||||
0x3701: 'PR_ATTACH_DATA_BIN',
|
|
||||||
0x3702: 'PR_ATTACH_ENCODING',
|
|
||||||
0x3703: 'PR_ATTACH_EXTENSION',
|
|
||||||
0x3704: 'PR_ATTACH_FILENAME',
|
|
||||||
0x3707: 'PR_ATTACH_LONG_FILENAME',
|
|
||||||
0x370E: 'PR_ATTACH_MIME_TAG',
|
|
||||||
0x3712: 'PR_ATTACH_CONTENT_ID',
|
|
||||||
0x0FF9: 'PR_RECORD_KEY',
|
|
||||||
0x0FFE: 'PR_OBJECT_TYPE',
|
|
||||||
0x3001: 'PR_DISPLAY_NAME',
|
|
||||||
0x3002: 'PR_ADDRTYPE',
|
|
||||||
0x3003: 'PR_EMAIL_ADDRESS',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PROP_TYPE_NAMES: Record<number, string> = {
|
|
||||||
0x0002: 'PT_SHORT',
|
|
||||||
0x0003: 'PT_LONG',
|
|
||||||
0x000B: 'PT_BOOLEAN',
|
|
||||||
0x001E: 'PT_STRING8',
|
|
||||||
0x001F: 'PT_UNICODE',
|
|
||||||
0x0040: 'PT_SYSTIME',
|
|
||||||
0x0048: 'PT_CLSID',
|
|
||||||
0x0102: 'PT_BINARY',
|
|
||||||
0x0014: 'PT_I8',
|
|
||||||
};
|
|
||||||
|
|
||||||
function pad4(len: number): number {
|
|
||||||
return (4 - (len % 4)) % 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
function readU8() { return view.getUint8(offset++); }
|
|
||||||
function readU16() { const v = view.getUint16(offset, true); offset += 2; return v; }
|
|
||||||
function readU32() { const v = view.getUint32(offset, true); offset += 4; return v; }
|
|
||||||
function readBytes(n: number) { const s = data.slice(offset, offset + n); offset += n; return s; }
|
|
||||||
|
|
||||||
const sig = readU32();
|
|
||||||
console.log(`Signature: 0x${sig.toString(16)} (expected 0x223e9f78: ${sig === 0x223e9f78 ? 'OK' : 'MISMATCH'})`);
|
|
||||||
const key = readU16();
|
|
||||||
console.log(`Key: ${key}\n`);
|
|
||||||
|
|
||||||
let attrIndex = 0;
|
|
||||||
while (offset + 11 <= data.byteLength) {
|
|
||||||
const level = readU8();
|
|
||||||
const attrId = readU32();
|
|
||||||
const attrLen = readU32();
|
|
||||||
|
|
||||||
if (attrLen > data.byteLength - offset - 2) {
|
|
||||||
console.log(`[${attrIndex}] TRUNCATED — level=${level} id=0x${attrId.toString(16)} len=${attrLen} (remaining=${data.byteLength - offset})`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attrData = readBytes(attrLen);
|
|
||||||
const checksum = readU16();
|
|
||||||
|
|
||||||
const levelStr = level === 1 ? 'MESSAGE' : level === 2 ? 'ATTACHMENT' : `LEVEL(${level})`;
|
|
||||||
const attrName = ATTR_NAMES[attrId] || `0x${attrId.toString(16).padStart(8, '0')}`;
|
|
||||||
|
|
||||||
console.log(`[${attrIndex}] ${levelStr} | ${attrName} | ${attrLen} bytes | checksum=0x${checksum.toString(16)}`);
|
|
||||||
|
|
||||||
// Dump MAPI props if this is a MAPI attr
|
|
||||||
if (attrId === 0x00069003 || attrId === 0x00069005) {
|
|
||||||
const propView = new DataView(attrData.buffer, attrData.byteOffset, attrData.byteLength);
|
|
||||||
let pOff = 0;
|
|
||||||
if (attrData.byteLength >= 4) {
|
|
||||||
const count = propView.getUint32(pOff, true); pOff += 4;
|
|
||||||
console.log(` MAPI props count: ${count}`);
|
|
||||||
|
|
||||||
for (let i = 0; i < count && pOff + 4 <= attrData.byteLength; i++) {
|
|
||||||
const propType = propView.getUint16(pOff, true); pOff += 2;
|
|
||||||
const propId = propView.getUint16(pOff, true); pOff += 2;
|
|
||||||
|
|
||||||
const baseType = propType & 0x0FFF;
|
|
||||||
const isMulti = (propType & 0x1000) !== 0;
|
|
||||||
const propName = MAPI_PROP_NAMES[propId] || `0x${propId.toString(16).padStart(4, '0')}`;
|
|
||||||
const typeName = PROP_TYPE_NAMES[baseType] || `0x${baseType.toString(16).padStart(4, '0')}`;
|
|
||||||
|
|
||||||
// Named props
|
|
||||||
if (propId >= 0x8000) {
|
|
||||||
if (pOff + 20 > attrData.byteLength) { console.log(` [${i}] ${propName} (${typeName}) — TRUNCATED (named prop)`); break; }
|
|
||||||
pOff += 16; // GUID
|
|
||||||
const kind = propView.getUint32(pOff, true); pOff += 4;
|
|
||||||
if (kind === 0) {
|
|
||||||
if (pOff + 4 > attrData.byteLength) break;
|
|
||||||
pOff += 4;
|
|
||||||
} else {
|
|
||||||
if (pOff + 4 > attrData.byteLength) break;
|
|
||||||
const nl = propView.getUint32(pOff, true); pOff += 4;
|
|
||||||
if (pOff + nl > attrData.byteLength) break;
|
|
||||||
pOff += nl + pad4(nl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMulti) {
|
|
||||||
if (pOff + 4 > attrData.byteLength) break;
|
|
||||||
const vc = propView.getUint32(pOff, true); pOff += 4;
|
|
||||||
console.log(` [${i}] ${propName} (${typeName} MV x${vc})`);
|
|
||||||
for (let j = 0; j < vc; j++) {
|
|
||||||
// skip values
|
|
||||||
if (baseType === 0x001E || baseType === 0x001F || baseType === 0x0102) {
|
|
||||||
if (pOff + 4 > attrData.byteLength) break;
|
|
||||||
const vl = propView.getUint32(pOff, true); pOff += 4;
|
|
||||||
pOff += vl + pad4(vl);
|
|
||||||
} else if (baseType === 0x0040 || baseType === 0x0014) {
|
|
||||||
pOff += 8;
|
|
||||||
} else if (baseType === 0x0048) {
|
|
||||||
pOff += 16;
|
|
||||||
} else if (baseType === 0x0002) {
|
|
||||||
pOff += 4;
|
|
||||||
} else {
|
|
||||||
pOff += 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let valuePreview = '';
|
|
||||||
const savedOff = pOff;
|
|
||||||
|
|
||||||
if (baseType === 0x0002) {
|
|
||||||
if (pOff + 4 <= attrData.byteLength) {
|
|
||||||
valuePreview = `value=${propView.getUint16(pOff, true)}`;
|
|
||||||
pOff += 4; // padded
|
|
||||||
}
|
|
||||||
} else if (baseType === 0x0003 || baseType === 0x000B) {
|
|
||||||
if (pOff + 4 <= attrData.byteLength) {
|
|
||||||
valuePreview = `value=${propView.getUint32(pOff, true)}`;
|
|
||||||
pOff += 4;
|
|
||||||
}
|
|
||||||
} else if (baseType === 0x0014 || baseType === 0x0040) {
|
|
||||||
pOff += 8;
|
|
||||||
valuePreview = '(8 bytes)';
|
|
||||||
} else if (baseType === 0x0048) {
|
|
||||||
pOff += 16;
|
|
||||||
valuePreview = '(GUID)';
|
|
||||||
} else if (baseType === 0x001E || baseType === 0x001F || baseType === 0x0102) {
|
|
||||||
if (pOff + 4 <= attrData.byteLength) {
|
|
||||||
const vl = propView.getUint32(pOff, true); pOff += 4;
|
|
||||||
if (pOff + vl <= attrData.byteLength) {
|
|
||||||
const raw = attrData.slice(pOff, pOff + vl);
|
|
||||||
if (baseType === 0x001F) {
|
|
||||||
try { valuePreview = `"${new TextDecoder('utf-16le').decode(raw).slice(0, 120)}"`; } catch { valuePreview = `(${vl} bytes)`; }
|
|
||||||
} else if (baseType === 0x001E) {
|
|
||||||
try { valuePreview = `"${new TextDecoder('utf-8').decode(raw).slice(0, 120)}"`; } catch { valuePreview = `(${vl} bytes)`; }
|
|
||||||
} else {
|
|
||||||
valuePreview = `(${vl} bytes binary)`;
|
|
||||||
if (propId === 0x1013) {
|
|
||||||
try { valuePreview += ` preview="${new TextDecoder('utf-8').decode(raw).slice(0, 200)}"`; } catch { /* ignore decode errors */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pOff += vl + pad4(vl);
|
|
||||||
} else {
|
|
||||||
valuePreview = `(${vl} bytes — exceeds data)`;
|
|
||||||
pOff = savedOff + 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (pOff + 4 <= attrData.byteLength) {
|
|
||||||
pOff += 4;
|
|
||||||
valuePreview = '(4 bytes fixed)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(` [${i}] ${propName} (${typeName}) ${valuePreview}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preview plain text body/attach title
|
|
||||||
if (attrId === 0x0002800C || attrId === 0x00018010) {
|
|
||||||
try {
|
|
||||||
const preview = new TextDecoder('utf-8').decode(attrData.slice(0, Math.min(200, attrData.byteLength)));
|
|
||||||
console.log(` Preview: "${preview}"`);
|
|
||||||
} catch { /* ignore decode errors */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
attrIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nTotal attributes: ${attrIndex}`);
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generate a self-signed S/MIME test certificate (.p12) using pkijs.
|
|
||||||
* Usage: npx tsx scripts/generate-test-cert.ts
|
|
||||||
*/
|
|
||||||
import * as pkijs from 'pkijs';
|
|
||||||
import * as asn1js from 'asn1js';
|
|
||||||
import { writeFileSync } from 'fs';
|
|
||||||
import { join, dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const cryptoEngine = new pkijs.CryptoEngine({
|
|
||||||
crypto: crypto,
|
|
||||||
subtle: crypto.subtle,
|
|
||||||
name: 'webcrypto',
|
|
||||||
});
|
|
||||||
pkijs.setEngine('gen', crypto, cryptoEngine);
|
|
||||||
|
|
||||||
function stringToAB(str: string): ArrayBuffer {
|
|
||||||
const buf = new ArrayBuffer(str.length);
|
|
||||||
const view = new Uint8Array(buf);
|
|
||||||
for (let i = 0; i < str.length; i++) view[i] = str.charCodeAt(i);
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const email = process.argv[2] || 'test@example.com';
|
|
||||||
const cn = email.split('@')[0];
|
|
||||||
const p12Password = 'test';
|
|
||||||
|
|
||||||
console.log(`Generating S/MIME certificate for ${email}...`);
|
|
||||||
|
|
||||||
// Generate RSA key pair for signing
|
|
||||||
const signKeyPair = await crypto.subtle.generateKey(
|
|
||||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
|
||||||
true,
|
|
||||||
['sign', 'verify'],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build self-signed certificate
|
|
||||||
const cert = new pkijs.Certificate();
|
|
||||||
cert.version = 2;
|
|
||||||
cert.serialNumber = new asn1js.Integer({ value: Date.now() });
|
|
||||||
|
|
||||||
// Issuer = Subject (self-signed)
|
|
||||||
for (const name of [cert.issuer, cert.subject]) {
|
|
||||||
name.typesAndValues.push(
|
|
||||||
new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', value: new asn1js.Utf8String({ value: cn }) }),
|
|
||||||
);
|
|
||||||
name.typesAndValues.push(
|
|
||||||
new pkijs.AttributeTypeAndValue({ type: '2.5.4.10', value: new asn1js.Utf8String({ value: 'Test Org' }) }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Email in subject
|
|
||||||
cert.subject.typesAndValues.push(
|
|
||||||
new pkijs.AttributeTypeAndValue({ type: '1.2.840.113549.1.9.1', value: new asn1js.IA5String({ value: email }) }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Validity: 1 year
|
|
||||||
cert.notBefore.value = new Date();
|
|
||||||
const notAfter = new Date();
|
|
||||||
notAfter.setFullYear(notAfter.getFullYear() + 1);
|
|
||||||
cert.notAfter.value = notAfter;
|
|
||||||
|
|
||||||
// Import public key and sign
|
|
||||||
await cert.subjectPublicKeyInfo.importKey(signKeyPair.publicKey, cryptoEngine);
|
|
||||||
await cert.sign(signKeyPair.privateKey, 'SHA-256', cryptoEngine);
|
|
||||||
|
|
||||||
// Export private key as PKCS#8
|
|
||||||
const pkcs8Bytes = await crypto.subtle.exportKey('pkcs8', signKeyPair.privateKey);
|
|
||||||
|
|
||||||
// Build PKCS#12
|
|
||||||
const passwordBuf = stringToAB(p12Password);
|
|
||||||
|
|
||||||
const keyBag = new pkijs.PKCS8ShroudedKeyBag({
|
|
||||||
parsedValue: pkijs.PrivateKeyInfo.fromBER(pkcs8Bytes),
|
|
||||||
});
|
|
||||||
|
|
||||||
await keyBag.makeInternalValues({
|
|
||||||
password: passwordBuf,
|
|
||||||
contentEncryptionAlgorithm: {
|
|
||||||
name: 'AES-CBC',
|
|
||||||
length: 256,
|
|
||||||
} as Parameters<typeof keyBag.makeInternalValues>[0]['contentEncryptionAlgorithm'],
|
|
||||||
hmacHashAlgorithm: 'SHA-256',
|
|
||||||
iterationCount: 100_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const keyBagSafe = new pkijs.SafeBag({
|
|
||||||
bagId: '1.2.840.113549.1.12.10.1.2',
|
|
||||||
bagValue: keyBag,
|
|
||||||
bagAttributes: [
|
|
||||||
new pkijs.Attribute({
|
|
||||||
type: '1.2.840.113549.1.9.20', // friendlyName
|
|
||||||
values: [new asn1js.BmpString({ value: cn })],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const certBagSafe = new pkijs.SafeBag({
|
|
||||||
bagId: '1.2.840.113549.1.12.10.1.3',
|
|
||||||
bagValue: new pkijs.CertBag({ parsedValue: cert }),
|
|
||||||
bagAttributes: [
|
|
||||||
new pkijs.Attribute({
|
|
||||||
type: '1.2.840.113549.1.9.20',
|
|
||||||
values: [new asn1js.BmpString({ value: cn })],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const authenticatedSafe = new pkijs.AuthenticatedSafe({
|
|
||||||
parsedValue: {
|
|
||||||
safeContents: [
|
|
||||||
{ privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [keyBagSafe] }) },
|
|
||||||
{ privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [certBagSafe] }) },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await authenticatedSafe.makeInternalValues({ safeContents: [{}, {}] });
|
|
||||||
|
|
||||||
const pfx = new pkijs.PFX({
|
|
||||||
parsedValue: {
|
|
||||||
integrityMode: 0,
|
|
||||||
authenticatedSafe,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await pfx.makeInternalValues({
|
|
||||||
password: passwordBuf,
|
|
||||||
iterations: 100_000,
|
|
||||||
pbkdf2HashAlgorithm: 'SHA-256',
|
|
||||||
hmacHashAlgorithm: 'SHA-256',
|
|
||||||
});
|
|
||||||
|
|
||||||
const p12Bytes = pfx.toSchema().toBER(false);
|
|
||||||
|
|
||||||
// Also export the public cert as PEM
|
|
||||||
const certDer = cert.toSchema(true).toBER(false);
|
|
||||||
const certB64 = Buffer.from(certDer).toString('base64');
|
|
||||||
const certPem = `-----BEGIN CERTIFICATE-----\n${certB64.match(/.{1,64}/g)!.join('\n')}\n-----END CERTIFICATE-----\n`;
|
|
||||||
|
|
||||||
const slug = email.replace(/[@.]/g, '-');
|
|
||||||
const outDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'local-data');
|
|
||||||
const p12Path = join(outDir, `${slug}.p12`);
|
|
||||||
const pemPath = join(outDir, `${slug}-cert.pem`);
|
|
||||||
|
|
||||||
writeFileSync(p12Path, Buffer.from(p12Bytes));
|
|
||||||
writeFileSync(pemPath, certPem);
|
|
||||||
|
|
||||||
console.log(`\nFiles written:`);
|
|
||||||
console.log(` ${p12Path}`);
|
|
||||||
console.log(` ${pemPath}`);
|
|
||||||
console.log(`\nCredentials:`);
|
|
||||||
console.log(` Email: ${email}`);
|
|
||||||
console.log(` CN: ${cn}`);
|
|
||||||
console.log(` Password: ${p12Password}`);
|
|
||||||
console.log(` Valid until: ${notAfter.toISOString().split('T')[0]}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test script for TNEF (winmail.dat) parser.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* npx tsx scripts/test-tnef.ts <path-to-winmail.dat>
|
|
||||||
*
|
|
||||||
* Outputs:
|
|
||||||
* - tnef-output.html (HTML body or formatted plain text)
|
|
||||||
* - Any extracted attachments saved alongside
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFileSync, writeFileSync } from 'fs';
|
|
||||||
import { resolve, basename } from 'path';
|
|
||||||
import { parseTnef } from '../lib/tnef';
|
|
||||||
|
|
||||||
const inputPath = process.argv[2];
|
|
||||||
if (!inputPath) {
|
|
||||||
console.error('Usage: npx tsx scripts/test-tnef.ts <path-to-winmail.dat>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullPath = resolve(inputPath);
|
|
||||||
console.log(`Reading: ${fullPath}`);
|
|
||||||
|
|
||||||
const data = new Uint8Array(readFileSync(fullPath));
|
|
||||||
console.log(`File size: ${data.byteLength} bytes`);
|
|
||||||
|
|
||||||
const result = parseTnef(data);
|
|
||||||
|
|
||||||
console.log(`\n=== TNEF Parse Results ===`);
|
|
||||||
console.log(`Plain text body: ${result.body ? `${result.body.length} chars` : 'none'}`);
|
|
||||||
console.log(`HTML body: ${result.htmlBody ? `${result.htmlBody.length} chars` : 'none'}`);
|
|
||||||
console.log(`Attachments: ${result.attachments.length}`);
|
|
||||||
|
|
||||||
if (result.attachments.length > 0) {
|
|
||||||
console.log(`\nAttachments:`);
|
|
||||||
result.attachments.forEach((att, i) => {
|
|
||||||
console.log(` [${i + 1}] ${att.name} (${att.mimeType}, ${att.data.byteLength} bytes)`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build output HTML
|
|
||||||
let htmlContent: string;
|
|
||||||
|
|
||||||
const attachmentsList = result.attachments.length > 0
|
|
||||||
? `<h3>Extracted Attachments (${result.attachments.length})</h3>
|
|
||||||
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;font-family:sans-serif;">
|
|
||||||
<tr style="background:#f0f0f0;"><th>#</th><th>Name</th><th>MIME Type</th><th>Size</th></tr>
|
|
||||||
${result.attachments.map((att, i) => `<tr><td>${i+1}</td><td>${att.name}</td><td>${att.mimeType}</td><td>${att.data.byteLength} bytes</td></tr>`).join('\n')}
|
|
||||||
</table>`
|
|
||||||
: '<p>No attachments found.</p>';
|
|
||||||
|
|
||||||
if (result.htmlBody) {
|
|
||||||
htmlContent = `<!DOCTYPE html>
|
|
||||||
<html><head><meta charset="utf-8"><title>TNEF Output</title></head>
|
|
||||||
<body style="font-family:sans-serif;max-width:900px;margin:20px auto;">
|
|
||||||
<h2 style="color:#333;border-bottom:2px solid #0078d4;padding-bottom:8px;">TNEF Parse Results</h2>
|
|
||||||
<p><strong>Source:</strong> ${inputPath} (${data.byteLength} bytes)</p>
|
|
||||||
${attachmentsList}
|
|
||||||
<h3>HTML Body</h3>
|
|
||||||
<div style="border:1px solid #ccc;padding:16px;border-radius:4px;background:#fff;">
|
|
||||||
${result.htmlBody}
|
|
||||||
</div>
|
|
||||||
</body></html>`;
|
|
||||||
} else if (result.body) {
|
|
||||||
const escaped = result.body
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
htmlContent = `<!DOCTYPE html>
|
|
||||||
<html><head><meta charset="utf-8"><title>TNEF Output</title></head>
|
|
||||||
<body style="font-family:sans-serif;max-width:900px;margin:20px auto;">
|
|
||||||
<h2 style="color:#333;border-bottom:2px solid #0078d4;padding-bottom:8px;">TNEF Parse Results</h2>
|
|
||||||
<p><strong>Source:</strong> ${inputPath} (${data.byteLength} bytes)</p>
|
|
||||||
${attachmentsList}
|
|
||||||
<h3>Plain Text Body</h3>
|
|
||||||
<pre style="font-family:Consolas,monospace;white-space:pre-wrap;line-height:1.6;border:1px solid #ccc;padding:16px;border-radius:4px;background:#fff;">${escaped}</pre>
|
|
||||||
</body></html>`;
|
|
||||||
} else {
|
|
||||||
htmlContent = `<!DOCTYPE html>
|
|
||||||
<html><head><meta charset="utf-8"><title>TNEF Output</title></head>
|
|
||||||
<body style="font-family:sans-serif;max-width:900px;margin:20px auto;">
|
|
||||||
<h2 style="color:#333;border-bottom:2px solid #0078d4;padding-bottom:8px;">TNEF Parse Results</h2>
|
|
||||||
<p><strong>Source:</strong> ${inputPath} (${data.byteLength} bytes)</p>
|
|
||||||
<p style="color:#666;"><em>No body content found in this TNEF file. The email body is likely in the regular MIME text/plain part.</em></p>
|
|
||||||
${attachmentsList}
|
|
||||||
</body></html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputHtml = resolve('tnef-output.html');
|
|
||||||
writeFileSync(outputHtml, htmlContent, 'utf-8');
|
|
||||||
console.log(`\nSaved HTML: ${outputHtml}`);
|
|
||||||
|
|
||||||
// Save extracted attachments
|
|
||||||
result.attachments.forEach((att, i) => {
|
|
||||||
const attPath = resolve(`tnef-attachment-${i + 1}-${att.name}`);
|
|
||||||
writeFileSync(attPath, att.data);
|
|
||||||
console.log(`Saved attachment: ${attPath}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\nDone.');
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { generateAccountId, generateAvatarColor, MAX_ACCOUNTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
|
export interface AccountEntry {
|
||||||
|
/** Unique key: `${username}@${serverHostname}` */
|
||||||
|
id: string;
|
||||||
|
/** Display label (defaults to email, user-editable) */
|
||||||
|
label: string;
|
||||||
|
/** Full server URL */
|
||||||
|
serverUrl: string;
|
||||||
|
/** Username / email used to authenticate */
|
||||||
|
username: string;
|
||||||
|
/** Authentication mode */
|
||||||
|
authMode: 'basic' | 'oauth';
|
||||||
|
/** Cookie slot index (0–4) for session/token cookies */
|
||||||
|
cookieSlot: number;
|
||||||
|
/** Whether "Remember Me" was checked (basic auth only) */
|
||||||
|
rememberMe: boolean;
|
||||||
|
/** Cached display info */
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
avatarColor: string;
|
||||||
|
/** Timestamp of last successful login */
|
||||||
|
lastLoginAt: number;
|
||||||
|
/** Whether this account is currently connected */
|
||||||
|
isConnected: boolean;
|
||||||
|
/** Whether this account had a connection error */
|
||||||
|
hasError: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
/** Whether this is the default account (loaded on app start) */
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountState {
|
||||||
|
accounts: AccountEntry[];
|
||||||
|
activeAccountId: string | null;
|
||||||
|
defaultAccountId: string | null;
|
||||||
|
|
||||||
|
addAccount: (entry: Omit<AccountEntry, 'id' | 'cookieSlot' | 'avatarColor'>) => string;
|
||||||
|
removeAccount: (accountId: string) => void;
|
||||||
|
setActiveAccount: (accountId: string) => void;
|
||||||
|
setDefaultAccount: (accountId: string) => void;
|
||||||
|
getDefaultAccount: () => AccountEntry | null;
|
||||||
|
updateAccount: (accountId: string, updates: Partial<AccountEntry>) => void;
|
||||||
|
getActiveAccount: () => AccountEntry | null;
|
||||||
|
getAccountById: (accountId: string) => AccountEntry | undefined;
|
||||||
|
getNextCookieSlot: () => number;
|
||||||
|
hasAccount: (username: string, serverUrl: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAccountStore = create<AccountState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
accounts: [],
|
||||||
|
activeAccountId: null,
|
||||||
|
defaultAccountId: null,
|
||||||
|
|
||||||
|
addAccount: (entry) => {
|
||||||
|
const state = get();
|
||||||
|
if (state.accounts.length >= MAX_ACCOUNTS) {
|
||||||
|
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = generateAccountId(entry.username, entry.serverUrl);
|
||||||
|
if (state.accounts.some((a) => a.id === id)) {
|
||||||
|
return id; // already exists, return existing id
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieSlot = state.getNextCookieSlot();
|
||||||
|
const avatarColor = generateAvatarColor(entry.email || entry.username);
|
||||||
|
const isDefault = state.accounts.length === 0; // first account is default
|
||||||
|
|
||||||
|
const account: AccountEntry = {
|
||||||
|
...entry,
|
||||||
|
id,
|
||||||
|
cookieSlot,
|
||||||
|
avatarColor,
|
||||||
|
isDefault,
|
||||||
|
};
|
||||||
|
|
||||||
|
set((s) => ({
|
||||||
|
accounts: [...s.accounts, account],
|
||||||
|
// If there is no active account, activate this one
|
||||||
|
activeAccountId: s.activeAccountId ?? id,
|
||||||
|
defaultAccountId: isDefault ? id : s.defaultAccountId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAccount: (accountId) => {
|
||||||
|
set((s) => {
|
||||||
|
const remaining = s.accounts.filter((a) => a.id !== accountId);
|
||||||
|
const wasDefault = s.defaultAccountId === accountId;
|
||||||
|
const wasActive = s.activeAccountId === accountId;
|
||||||
|
|
||||||
|
let newDefault = s.defaultAccountId;
|
||||||
|
if (wasDefault) {
|
||||||
|
newDefault = remaining[0]?.id ?? null;
|
||||||
|
// Mark new default
|
||||||
|
if (newDefault) {
|
||||||
|
const idx = remaining.findIndex((a) => a.id === newDefault);
|
||||||
|
if (idx >= 0) {
|
||||||
|
remaining[idx] = { ...remaining[idx], isDefault: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts: remaining,
|
||||||
|
activeAccountId: wasActive ? (remaining[0]?.id ?? null) : s.activeAccountId,
|
||||||
|
defaultAccountId: newDefault,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveAccount: (accountId) => {
|
||||||
|
const account = get().accounts.find((a) => a.id === accountId);
|
||||||
|
if (!account) return;
|
||||||
|
set({ activeAccountId: accountId });
|
||||||
|
},
|
||||||
|
|
||||||
|
setDefaultAccount: (accountId) => {
|
||||||
|
const account = get().accounts.find((a) => a.id === accountId);
|
||||||
|
if (!account) return;
|
||||||
|
set((s) => ({
|
||||||
|
defaultAccountId: accountId,
|
||||||
|
accounts: s.accounts.map((a) => ({
|
||||||
|
...a,
|
||||||
|
isDefault: a.id === accountId,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
getDefaultAccount: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.defaultAccountId) {
|
||||||
|
const account = state.accounts.find((a) => a.id === state.defaultAccountId);
|
||||||
|
if (account) return account;
|
||||||
|
}
|
||||||
|
return state.accounts[0] ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAccount: (accountId, updates) => {
|
||||||
|
set((s) => ({
|
||||||
|
accounts: s.accounts.map((a) =>
|
||||||
|
a.id === accountId ? { ...a, ...updates } : a
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
getActiveAccount: () => {
|
||||||
|
const state = get();
|
||||||
|
return state.accounts.find((a) => a.id === state.activeAccountId) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAccountById: (accountId) => {
|
||||||
|
return get().accounts.find((a) => a.id === accountId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getNextCookieSlot: () => {
|
||||||
|
const used = new Set(get().accounts.map((a) => a.cookieSlot));
|
||||||
|
for (let i = 0; i < MAX_ACCOUNTS; i++) {
|
||||||
|
if (!used.has(i)) return i;
|
||||||
|
}
|
||||||
|
return 0; // fallback, shouldn't happen if max is enforced
|
||||||
|
},
|
||||||
|
|
||||||
|
hasAccount: (username, serverUrl) => {
|
||||||
|
const id = generateAccountId(username, serverUrl);
|
||||||
|
return get().accounts.some((a) => a.id === id);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'account-registry',
|
||||||
|
partialize: (state) => ({
|
||||||
|
accounts: state.accounts,
|
||||||
|
activeAccountId: state.activeAccountId,
|
||||||
|
defaultAccountId: state.defaultAccountId,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
+605
-70
@@ -1,15 +1,17 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { JMAPClient } from '@/lib/jmap/client';
|
import { JMAPClient } from '@/lib/jmap/client';
|
||||||
import { useEmailStore } from './email-store';
|
|
||||||
import { useIdentityStore } from './identity-store';
|
import { useIdentityStore } from './identity-store';
|
||||||
import { useContactStore } from './contact-store';
|
import { useContactStore } from './contact-store';
|
||||||
import { useVacationStore } from './vacation-store';
|
import { useVacationStore } from './vacation-store';
|
||||||
import { useCalendarStore } from './calendar-store';
|
import { useCalendarStore } from './calendar-store';
|
||||||
import { useFilterStore } from './filter-store';
|
import { useFilterStore } from './filter-store';
|
||||||
import { useSettingsStore } from './settings-store';
|
import { useSettingsStore } from './settings-store';
|
||||||
|
import { useAccountStore } from './account-store';
|
||||||
import { fetchConfig } from '@/hooks/use-config';
|
import { fetchConfig } from '@/hooks/use-config';
|
||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
|
import { generateAccountId } from '@/lib/account-utils';
|
||||||
|
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager';
|
||||||
import type { Identity } from '@/lib/jmap/types';
|
import type { Identity } from '@/lib/jmap/types';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -26,14 +28,18 @@ interface AuthState {
|
|||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
tokenExpiresAt: number | null;
|
tokenExpiresAt: number | null;
|
||||||
connectionLost: boolean;
|
connectionLost: boolean;
|
||||||
|
activeAccountId: string | null;
|
||||||
|
|
||||||
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
||||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
||||||
refreshAccessToken: () => Promise<string | null>;
|
refreshAccessToken: () => Promise<string | null>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
logoutAll: () => void;
|
||||||
|
switchAccount: (accountId: string) => Promise<void>;
|
||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
syncIdentities: () => void;
|
syncIdentities: () => void;
|
||||||
|
getClientForAccount: (accountId: string) => JMAPClient | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
||||||
@@ -52,12 +58,41 @@ function classifyLoginError(error: unknown): string {
|
|||||||
return 'generic';
|
return 'generic';
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
|
function emailMatchesUsername(email: string, username: string): boolean {
|
||||||
const identities = [...rawIdentities].sort((a, b) => {
|
if (email === username) return true;
|
||||||
const aMatch = a.email === username ? -1 : 0;
|
// Handle local-part login: username "user" should match "user@domain.tld"
|
||||||
const bMatch = b.email === username ? -1 : 0;
|
if (!username.includes('@') && email.split('@')[0] === username) return true;
|
||||||
return aMatch - bMatch;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortIdentities(rawIdentities: Identity[], username: string): Identity[] {
|
||||||
|
return [...rawIdentities].sort((a, b) => {
|
||||||
|
const aMatch = emailMatchesUsername(a.email, username);
|
||||||
|
const bMatch = emailMatchesUsername(b.email, username);
|
||||||
|
if (aMatch && !bMatch) return -1;
|
||||||
|
if (!aMatch && bMatch) return 1;
|
||||||
|
// Among matching identities, prefer canonical (non-deletable) over aliases
|
||||||
|
if (aMatch && bMatch) {
|
||||||
|
if (!a.mayDelete && b.mayDelete) return -1;
|
||||||
|
if (a.mayDelete && !b.mayDelete) return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
|
||||||
|
const preferredPrimaryId = useIdentityStore.getState().preferredPrimaryId;
|
||||||
|
const identities = sortIdentities(rawIdentities, username);
|
||||||
|
|
||||||
|
// If user has a preferred primary, move it to front
|
||||||
|
if (preferredPrimaryId) {
|
||||||
|
const idx = identities.findIndex((id) => id.id === preferredPrimaryId);
|
||||||
|
if (idx > 0) {
|
||||||
|
const [preferred] = identities.splice(idx, 1);
|
||||||
|
identities.unshift(preferred);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const primaryIdentity = identities[0] ?? null;
|
const primaryIdentity = identities[0] ?? null;
|
||||||
useIdentityStore.getState().setIdentities(identities);
|
useIdentityStore.getState().setIdentities(identities);
|
||||||
return { identities, primaryIdentity };
|
return { identities, primaryIdentity };
|
||||||
@@ -101,22 +136,55 @@ function initializeFeatureStores(client: JMAPClient): void {
|
|||||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let refreshPromise: Promise<string | null> | null = null;
|
let refreshPromise: Promise<string | null> | null = null;
|
||||||
|
|
||||||
function scheduleRefresh(expiresIn: number, refreshFn: () => Promise<string | null>): void {
|
// Multi-account state: per-account JMAP clients and refresh timers
|
||||||
if (refreshTimer) clearTimeout(refreshTimer);
|
const clients = new Map<string, JMAPClient>();
|
||||||
const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000);
|
const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
refreshTimer = setTimeout(() => {
|
const refreshPromises = new Map<string, Promise<string | null>>();
|
||||||
refreshFn().catch((err) => {
|
|
||||||
debug.error('Scheduled token refresh failed:', err);
|
function scheduleRefresh(expiresIn: number, refreshFn: () => Promise<string | null>, accountId?: string): void {
|
||||||
});
|
if (accountId) {
|
||||||
}, refreshAt);
|
const existing = refreshTimers.get(accountId);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000);
|
||||||
|
refreshTimers.set(accountId, setTimeout(() => {
|
||||||
|
refreshFn().catch((err) => {
|
||||||
|
debug.error(`Scheduled token refresh failed for ${accountId}:`, err);
|
||||||
|
});
|
||||||
|
}, refreshAt));
|
||||||
|
} else {
|
||||||
|
if (refreshTimer) clearTimeout(refreshTimer);
|
||||||
|
const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000);
|
||||||
|
refreshTimer = setTimeout(() => {
|
||||||
|
refreshFn().catch((err) => {
|
||||||
|
debug.error('Scheduled token refresh failed:', err);
|
||||||
|
});
|
||||||
|
}, refreshAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRefreshTimer(): void {
|
function clearRefreshTimer(accountId?: string): void {
|
||||||
if (refreshTimer) {
|
if (accountId) {
|
||||||
clearTimeout(refreshTimer);
|
const timer = refreshTimers.get(accountId);
|
||||||
refreshTimer = null;
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
refreshTimers.delete(accountId);
|
||||||
|
}
|
||||||
|
refreshPromises.delete(accountId);
|
||||||
|
} else {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
refreshPromise = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllRefreshTimers(): void {
|
||||||
|
if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; }
|
||||||
refreshPromise = null;
|
refreshPromise = null;
|
||||||
|
for (const timer of refreshTimers.values()) clearTimeout(timer);
|
||||||
|
refreshTimers.clear();
|
||||||
|
refreshPromises.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
@@ -135,6 +203,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
accessToken: null,
|
accessToken: null,
|
||||||
tokenExpiresAt: null,
|
tokenExpiresAt: null,
|
||||||
connectionLost: false,
|
connectionLost: false,
|
||||||
|
activeAccountId: null,
|
||||||
|
|
||||||
login: async (serverUrl, username, password, totp, rememberMe) => {
|
login: async (serverUrl, username, password, totp, rememberMe) => {
|
||||||
const effectivePassword = totp ? `${password}$${totp}` : password;
|
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||||
@@ -150,6 +219,37 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||||
initializeFeatureStores(client);
|
initializeFeatureStores(client);
|
||||||
|
|
||||||
|
// Register in account store
|
||||||
|
const accountStore = useAccountStore.getState();
|
||||||
|
const accountId = generateAccountId(username, serverUrl);
|
||||||
|
const cookieSlot = accountStore.hasAccount(username, serverUrl)
|
||||||
|
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
|
||||||
|
: accountStore.getNextCookieSlot();
|
||||||
|
|
||||||
|
// Snapshot current account if switching away
|
||||||
|
const prevAccountId = get().activeAccountId;
|
||||||
|
if (prevAccountId && prevAccountId !== accountId) {
|
||||||
|
snapshotAccount(prevAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store client in multi-account map
|
||||||
|
clients.set(accountId, client);
|
||||||
|
|
||||||
|
accountStore.addAccount({
|
||||||
|
label: primaryIdentity?.name || username,
|
||||||
|
serverUrl,
|
||||||
|
username,
|
||||||
|
authMode: 'basic',
|
||||||
|
rememberMe: !!rememberMe,
|
||||||
|
displayName: primaryIdentity?.name || username,
|
||||||
|
email: primaryIdentity?.email || username,
|
||||||
|
lastLoginAt: Date.now(),
|
||||||
|
isConnected: true,
|
||||||
|
hasError: false,
|
||||||
|
isDefault: accountStore.accounts.length === 0,
|
||||||
|
});
|
||||||
|
accountStore.setActiveAccount(accountId);
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -163,6 +263,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
tokenExpiresAt: null,
|
tokenExpiresAt: null,
|
||||||
connectionLost: false,
|
connectionLost: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
activeAccountId: accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync settings from server (only if enabled)
|
// Sync settings from server (only if enabled)
|
||||||
@@ -175,10 +276,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
if (rememberMe) {
|
if (rememberMe) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/session', {
|
const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ serverUrl, username, password: effectivePassword }),
|
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
set({ rememberMe: true });
|
set({ rememberMe: true });
|
||||||
@@ -207,10 +308,17 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenRes = await fetch('/api/auth/token', {
|
// Determine slot for this account (use slot from sessionStorage if re-adding)
|
||||||
|
const accountStore = useAccountStore.getState();
|
||||||
|
const pendingSlot = typeof window !== 'undefined'
|
||||||
|
? parseInt(sessionStorage.getItem('oauth_cookie_slot') || '0', 10)
|
||||||
|
: 0;
|
||||||
|
const slot = pendingSlot >= 0 && pendingSlot <= 4 ? pendingSlot : accountStore.getNextCookieSlot();
|
||||||
|
|
||||||
|
const tokenRes = await fetch(`/api/auth/token?slot=${slot}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri }),
|
body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri, slot }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tokenRes.ok) {
|
if (!tokenRes.ok) {
|
||||||
@@ -230,6 +338,32 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||||
initializeFeatureStores(client);
|
initializeFeatureStores(client);
|
||||||
|
|
||||||
|
// Register in account store
|
||||||
|
const accountId = generateAccountId(username, serverUrl);
|
||||||
|
|
||||||
|
// Snapshot current account if switching away
|
||||||
|
const prevAccountId = get().activeAccountId;
|
||||||
|
if (prevAccountId && prevAccountId !== accountId) {
|
||||||
|
snapshotAccount(prevAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.set(accountId, client);
|
||||||
|
|
||||||
|
accountStore.addAccount({
|
||||||
|
label: primaryIdentity?.name || username,
|
||||||
|
serverUrl,
|
||||||
|
username,
|
||||||
|
authMode: 'oauth',
|
||||||
|
rememberMe: true,
|
||||||
|
displayName: primaryIdentity?.name || username,
|
||||||
|
email: primaryIdentity?.email || username,
|
||||||
|
lastLoginAt: Date.now(),
|
||||||
|
isConnected: true,
|
||||||
|
hasError: false,
|
||||||
|
isDefault: accountStore.accounts.length === 0,
|
||||||
|
});
|
||||||
|
accountStore.setActiveAccount(accountId);
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -243,9 +377,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
tokenExpiresAt: Date.now() + expires_in * 1000,
|
tokenExpiresAt: Date.now() + expires_in * 1000,
|
||||||
connectionLost: false,
|
connectionLost: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
activeAccountId: accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
scheduleRefresh(expires_in, get().refreshAccessToken);
|
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||||
|
|
||||||
// Sync settings from server (only if enabled)
|
// Sync settings from server (only if enabled)
|
||||||
fetchConfig().then(config => {
|
fetchConfig().then(config => {
|
||||||
@@ -255,6 +390,11 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Clean up sessionStorage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
sessionStorage.removeItem('oauth_cookie_slot');
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('OAuth login error:', error);
|
debug.error('OAuth login error:', error);
|
||||||
@@ -271,9 +411,17 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
refreshAccessToken: async () => {
|
refreshAccessToken: async () => {
|
||||||
if (refreshPromise) return refreshPromise;
|
if (refreshPromise) return refreshPromise;
|
||||||
|
|
||||||
refreshPromise = (async () => {
|
const accountId = get().activeAccountId;
|
||||||
|
if (accountId && refreshPromises.has(accountId)) {
|
||||||
|
return refreshPromises.get(accountId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = accountId ? useAccountStore.getState().getAccountById(accountId) : null;
|
||||||
|
const slot = account?.cookieSlot ?? 0;
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/token', { method: 'PUT' });
|
const res = await fetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' });
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
markSessionExpired();
|
markSessionExpired();
|
||||||
@@ -290,7 +438,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
tokenExpiresAt: Date.now() + expires_in * 1000,
|
tokenExpiresAt: Date.now() + expires_in * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
scheduleRefresh(expires_in, get().refreshAccessToken);
|
scheduleRefresh(expires_in, get().refreshAccessToken, accountId ?? undefined);
|
||||||
return access_token;
|
return access_token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Token refresh failed:', error);
|
debug.error('Token refresh failed:', error);
|
||||||
@@ -299,21 +447,136 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
refreshPromise = null;
|
refreshPromise = null;
|
||||||
|
if (accountId) refreshPromises.delete(accountId);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return refreshPromise;
|
refreshPromise = promise;
|
||||||
|
if (accountId) refreshPromises.set(accountId, promise);
|
||||||
|
|
||||||
|
return promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const wasOAuth = state.authMode === 'oauth';
|
const wasOAuth = state.authMode === 'oauth';
|
||||||
|
const accountId = state.activeAccountId;
|
||||||
|
const accountStore = useAccountStore.getState();
|
||||||
|
const account = accountId ? accountStore.getAccountById(accountId) : null;
|
||||||
|
const slot = account?.cookieSlot ?? 0;
|
||||||
|
|
||||||
clearRefreshTimer();
|
clearRefreshTimer(accountId ?? undefined);
|
||||||
state.client?.disconnect();
|
state.client?.disconnect();
|
||||||
|
|
||||||
|
// Remove client from multi-account map
|
||||||
|
if (accountId) {
|
||||||
|
clients.delete(accountId);
|
||||||
|
evictAccount(accountId);
|
||||||
|
accountStore.removeAccount(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
useSettingsStore.getState().disableSync();
|
useSettingsStore.getState().disableSync();
|
||||||
|
|
||||||
|
// Check if there are remaining accounts to switch to
|
||||||
|
const remainingAccounts = accountStore.accounts;
|
||||||
|
if (remainingAccounts.length > 0) {
|
||||||
|
// Switch to the next account
|
||||||
|
const nextAccount = remainingAccounts[0];
|
||||||
|
// Clean current stores, then switch
|
||||||
|
clearAllStores();
|
||||||
|
|
||||||
|
// Restore next account
|
||||||
|
const nextClient = clients.get(nextAccount.id);
|
||||||
|
if (nextClient) {
|
||||||
|
const restored = restoreAccount(nextAccount.id);
|
||||||
|
accountStore.setActiveAccount(nextAccount.id);
|
||||||
|
|
||||||
|
set({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
serverUrl: nextAccount.serverUrl,
|
||||||
|
username: nextAccount.username,
|
||||||
|
client: nextClient,
|
||||||
|
authMode: nextAccount.authMode,
|
||||||
|
connectionLost: false,
|
||||||
|
error: null,
|
||||||
|
activeAccountId: nextAccount.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!restored) {
|
||||||
|
initializeFeatureStores(nextClient);
|
||||||
|
nextClient.getIdentities().then((rawIds) => {
|
||||||
|
const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username);
|
||||||
|
set({ identities, primaryIdentity });
|
||||||
|
}).catch((err) => debug.error('Failed to load identities after switch:', err));
|
||||||
|
} else {
|
||||||
|
const identityState = useIdentityStore.getState();
|
||||||
|
set({
|
||||||
|
identities: identityState.identities,
|
||||||
|
primaryIdentity: identityState.identities[0] ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No accounts remaining — full logout
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
serverUrl: null,
|
||||||
|
username: null,
|
||||||
|
client: null,
|
||||||
|
identities: [],
|
||||||
|
primaryIdentity: null,
|
||||||
|
authMode: 'basic',
|
||||||
|
rememberMe: false,
|
||||||
|
accessToken: null,
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
connectionLost: false,
|
||||||
|
error: null,
|
||||||
|
activeAccountId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.removeItem('auth-storage');
|
||||||
|
clearAllStores();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up cookies for the removed account
|
||||||
|
fetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE' }).catch((err) => {
|
||||||
|
debug.error('Failed to clear session cookie:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (wasOAuth) {
|
||||||
|
fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE' })
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`Revocation failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data.end_session_url && remainingAccounts.length === 0) {
|
||||||
|
const locale = window.location.pathname.split('/')[1] || 'en';
|
||||||
|
const redirectUri = `${window.location.origin}/${locale}/login`;
|
||||||
|
const url = new URL(data.end_session_url);
|
||||||
|
url.searchParams.set('post_logout_redirect_uri', redirectUri);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
debug.error('OAuth logout cleanup failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logoutAll: () => {
|
||||||
|
// Disconnect all clients
|
||||||
|
for (const client of clients.values()) {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
clients.clear();
|
||||||
|
clearAllRefreshTimers();
|
||||||
|
evictAll();
|
||||||
|
|
||||||
|
useSettingsStore.getState().disableSync();
|
||||||
|
useAccountStore.getState().accounts.forEach(() => {});
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
serverUrl: null,
|
serverUrl: null,
|
||||||
@@ -327,55 +590,283 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
tokenExpiresAt: null,
|
tokenExpiresAt: null,
|
||||||
connectionLost: false,
|
connectionLost: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
activeAccountId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
localStorage.removeItem('auth-storage');
|
localStorage.removeItem('auth-storage');
|
||||||
|
clearAllStores();
|
||||||
|
|
||||||
useEmailStore.setState({
|
// Clear all accounts from registry
|
||||||
emails: [],
|
const accountStore = useAccountStore.getState();
|
||||||
mailboxes: [],
|
const allAccounts = [...accountStore.accounts];
|
||||||
selectedEmail: null,
|
for (const account of allAccounts) {
|
||||||
selectedMailbox: "",
|
accountStore.removeAccount(account.id);
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
searchQuery: "",
|
|
||||||
quota: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
useIdentityStore.getState().clearIdentities();
|
|
||||||
useContactStore.getState().clearContacts();
|
|
||||||
useVacationStore.getState().clearState();
|
|
||||||
useCalendarStore.getState().clearState();
|
|
||||||
useFilterStore.getState().clearState();
|
|
||||||
|
|
||||||
fetch('/api/auth/session', { method: 'DELETE' }).catch((err) => {
|
|
||||||
debug.error('Failed to clear session cookie:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (wasOAuth) {
|
|
||||||
fetch('/api/auth/token', { method: 'DELETE' })
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) throw new Error(`Revocation failed: ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
if (data.end_session_url) {
|
|
||||||
const locale = window.location.pathname.split('/')[1] || 'en';
|
|
||||||
const redirectUri = `${window.location.origin}/${locale}/login`;
|
|
||||||
const url = new URL(data.end_session_url);
|
|
||||||
url.searchParams.set('post_logout_redirect_uri', redirectUri);
|
|
||||||
window.location.href = url.toString();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
debug.error('OAuth logout cleanup failed:', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete all cookies
|
||||||
|
fetch('/api/auth/session?all=true', { method: 'DELETE' }).catch(() => {});
|
||||||
|
fetch('/api/auth/token?all=true', { method: 'DELETE' }).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
switchAccount: async (accountId: string) => {
|
||||||
|
const state = get();
|
||||||
|
if (state.activeAccountId === accountId) return;
|
||||||
|
|
||||||
|
const accountStore = useAccountStore.getState();
|
||||||
|
const targetAccount = accountStore.getAccountById(accountId);
|
||||||
|
if (!targetAccount) return;
|
||||||
|
|
||||||
|
set({ isLoading: true });
|
||||||
|
|
||||||
|
// Snapshot current account
|
||||||
|
if (state.activeAccountId) {
|
||||||
|
snapshotAccount(state.activeAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear current stores
|
||||||
|
clearAllStores();
|
||||||
|
useSettingsStore.getState().disableSync();
|
||||||
|
|
||||||
|
// Get or create client for target account
|
||||||
|
let targetClient = clients.get(accountId);
|
||||||
|
|
||||||
|
if (!targetClient) {
|
||||||
|
// Client not connected — try to restore
|
||||||
|
try {
|
||||||
|
if (targetAccount.authMode === 'oauth') {
|
||||||
|
const res = await fetch(`/api/auth/token?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
|
||||||
|
if (res.ok) {
|
||||||
|
const { access_token, expires_in } = await res.json();
|
||||||
|
const refreshFn = get().refreshAccessToken;
|
||||||
|
targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => refreshFn());
|
||||||
|
targetClient.onConnectionChange((connected) => {
|
||||||
|
if (get().activeAccountId === accountId) {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
}
|
||||||
|
accountStore.updateAccount(accountId, { isConnected: connected });
|
||||||
|
});
|
||||||
|
await targetClient.connect();
|
||||||
|
clients.set(accountId, targetClient);
|
||||||
|
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||||
|
}
|
||||||
|
} else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) {
|
||||||
|
const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const { serverUrl, username, password } = await res.json();
|
||||||
|
targetClient = new JMAPClient(serverUrl, username, password);
|
||||||
|
targetClient.onConnectionChange((connected) => {
|
||||||
|
if (get().activeAccountId === accountId) {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
}
|
||||||
|
accountStore.updateAccount(accountId, { isConnected: connected });
|
||||||
|
});
|
||||||
|
await targetClient.connect();
|
||||||
|
clients.set(accountId, targetClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debug.error(`Failed to restore client for ${accountId}:`, err);
|
||||||
|
accountStore.updateAccount(accountId, {
|
||||||
|
isConnected: false,
|
||||||
|
hasError: true,
|
||||||
|
errorMessage: err instanceof Error ? err.message : 'Connection failed',
|
||||||
|
});
|
||||||
|
set({ isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetClient) {
|
||||||
|
set({ isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore cached state or fetch fresh
|
||||||
|
const restored = restoreAccount(accountId);
|
||||||
|
accountStore.setActiveAccount(accountId);
|
||||||
|
accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
|
||||||
|
|
||||||
|
set({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
serverUrl: targetAccount.serverUrl,
|
||||||
|
username: targetAccount.username,
|
||||||
|
client: targetClient,
|
||||||
|
authMode: targetAccount.authMode,
|
||||||
|
connectionLost: false,
|
||||||
|
error: null,
|
||||||
|
activeAccountId: accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!restored) {
|
||||||
|
// Fetch fresh data
|
||||||
|
try {
|
||||||
|
const { identities, primaryIdentity } = loadIdentities(await targetClient.getIdentities(), targetAccount.username);
|
||||||
|
set({ identities, primaryIdentity });
|
||||||
|
initializeFeatureStores(targetClient);
|
||||||
|
} catch (err) {
|
||||||
|
debug.error(`Failed to load data for ${accountId}:`, err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const identityState = useIdentityStore.getState();
|
||||||
|
set({
|
||||||
|
identities: identityState.identities,
|
||||||
|
primaryIdentity: identityState.identities[0] ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync settings
|
||||||
|
fetchConfig().then(config => {
|
||||||
|
if (!config.settingsSyncEnabled) return;
|
||||||
|
useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
|
||||||
|
useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
checkAuth: async () => {
|
checkAuth: async () => {
|
||||||
const state = get();
|
const accountStore = useAccountStore.getState();
|
||||||
|
const accounts = accountStore.accounts;
|
||||||
|
|
||||||
|
// Multi-account restoration: restore all registered accounts
|
||||||
|
if (accounts.length > 0) {
|
||||||
|
set({ isLoading: true });
|
||||||
|
|
||||||
|
// Determine which account to activate first
|
||||||
|
const defaultAccount = accountStore.getDefaultAccount();
|
||||||
|
const activeId = get().activeAccountId;
|
||||||
|
const targetId = activeId || defaultAccount?.id || accounts[0].id;
|
||||||
|
|
||||||
|
// Try to connect all accounts
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (clients.has(account.id)) continue; // Already connected
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (account.authMode === 'oauth') {
|
||||||
|
const res = await fetch(`/api/auth/token?slot=${account.cookieSlot}`, { method: 'PUT' });
|
||||||
|
if (res.ok) {
|
||||||
|
const { access_token, expires_in } = await res.json();
|
||||||
|
const refreshFn = get().refreshAccessToken;
|
||||||
|
const client = JMAPClient.withBearer(account.serverUrl, access_token, account.username, () => refreshFn());
|
||||||
|
client.onConnectionChange((connected) => {
|
||||||
|
if (get().activeAccountId === account.id) {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
}
|
||||||
|
accountStore.updateAccount(account.id, { isConnected: connected });
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
clients.set(account.id, client);
|
||||||
|
scheduleRefresh(expires_in, get().refreshAccessToken, account.id);
|
||||||
|
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
||||||
|
} else {
|
||||||
|
throw new Error(`Token refresh failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
} else if (account.authMode === 'basic' && account.rememberMe) {
|
||||||
|
const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const { serverUrl, username, password } = await res.json();
|
||||||
|
const client = new JMAPClient(serverUrl, username, password);
|
||||||
|
client.onConnectionChange((connected) => {
|
||||||
|
if (get().activeAccountId === account.id) {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
}
|
||||||
|
accountStore.updateAccount(account.id, { isConnected: connected });
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
clients.set(account.id, client);
|
||||||
|
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
||||||
|
} else {
|
||||||
|
throw new Error(`Session cookie missing: ${res.status}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Basic auth without rememberMe — can't restore
|
||||||
|
throw new Error('No saved session');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debug.error(`Failed to restore account ${account.id}:`, err);
|
||||||
|
accountStore.updateAccount(account.id, {
|
||||||
|
isConnected: false,
|
||||||
|
hasError: true,
|
||||||
|
errorMessage: err instanceof Error ? err.message : 'Restore failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate the target account
|
||||||
|
const targetClient = clients.get(targetId);
|
||||||
|
const targetAccount = accountStore.getAccountById(targetId);
|
||||||
|
if (targetClient && targetAccount) {
|
||||||
|
accountStore.setActiveAccount(targetId);
|
||||||
|
const { identities, primaryIdentity } = loadIdentities(await targetClient.getIdentities(), targetAccount.username);
|
||||||
|
initializeFeatureStores(targetClient);
|
||||||
|
|
||||||
|
set({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
serverUrl: targetAccount.serverUrl,
|
||||||
|
username: targetAccount.username,
|
||||||
|
client: targetClient,
|
||||||
|
identities,
|
||||||
|
primaryIdentity,
|
||||||
|
authMode: targetAccount.authMode,
|
||||||
|
connectionLost: false,
|
||||||
|
error: null,
|
||||||
|
activeAccountId: targetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchConfig().then(config => {
|
||||||
|
if (!config.settingsSyncEnabled) return;
|
||||||
|
useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
|
||||||
|
useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If target didn't connect, try any connected account
|
||||||
|
for (const [id, client] of clients.entries()) {
|
||||||
|
const acc = accountStore.getAccountById(id);
|
||||||
|
if (acc) {
|
||||||
|
accountStore.setActiveAccount(id);
|
||||||
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), acc.username);
|
||||||
|
initializeFeatureStores(client);
|
||||||
|
|
||||||
|
set({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
serverUrl: acc.serverUrl,
|
||||||
|
username: acc.username,
|
||||||
|
client,
|
||||||
|
identities,
|
||||||
|
primaryIdentity,
|
||||||
|
authMode: acc.authMode,
|
||||||
|
connectionLost: false,
|
||||||
|
error: null,
|
||||||
|
activeAccountId: id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No accounts could be restored
|
||||||
|
markSessionExpired();
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
client: null,
|
||||||
|
serverUrl: null,
|
||||||
|
username: null,
|
||||||
|
authMode: 'basic',
|
||||||
|
rememberMe: false,
|
||||||
|
accessToken: null,
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
activeAccountId: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy single-account fallback (for accounts not yet in registry)
|
||||||
|
const state = get();
|
||||||
if (state.isAuthenticated && !state.client) {
|
if (state.isAuthenticated && !state.client) {
|
||||||
if (state.authMode === 'oauth' && state.serverUrl) {
|
if (state.authMode === 'oauth' && state.serverUrl) {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
@@ -389,6 +880,25 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
|
const accountId = generateAccountId(state.username || '', state.serverUrl);
|
||||||
|
clients.set(accountId, client);
|
||||||
|
|
||||||
|
// Migrate to account registry
|
||||||
|
accountStore.addAccount({
|
||||||
|
label: state.username || '',
|
||||||
|
serverUrl: state.serverUrl,
|
||||||
|
username: state.username || '',
|
||||||
|
authMode: 'oauth',
|
||||||
|
rememberMe: true,
|
||||||
|
displayName: state.username || '',
|
||||||
|
email: state.username || '',
|
||||||
|
lastLoginAt: Date.now(),
|
||||||
|
isConnected: true,
|
||||||
|
hasError: false,
|
||||||
|
isDefault: accountStore.accounts.length === 0,
|
||||||
|
});
|
||||||
|
accountStore.setActiveAccount(accountId);
|
||||||
|
|
||||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), state.username || '');
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), state.username || '');
|
||||||
initializeFeatureStores(client);
|
initializeFeatureStores(client);
|
||||||
|
|
||||||
@@ -399,9 +909,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
accessToken: token,
|
accessToken: token,
|
||||||
|
activeAccountId: accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync settings from server (only if enabled)
|
|
||||||
fetchConfig().then(config => {
|
fetchConfig().then(config => {
|
||||||
if (!config.settingsSyncEnabled) return;
|
if (!config.settingsSyncEnabled) return;
|
||||||
useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl!).finally(() => {
|
useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl!).finally(() => {
|
||||||
@@ -433,6 +943,25 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
|
const accountId = generateAccountId(username, serverUrl);
|
||||||
|
clients.set(accountId, client);
|
||||||
|
|
||||||
|
// Migrate to account registry
|
||||||
|
accountStore.addAccount({
|
||||||
|
label: username,
|
||||||
|
serverUrl,
|
||||||
|
username,
|
||||||
|
authMode: 'basic',
|
||||||
|
rememberMe: state.rememberMe,
|
||||||
|
displayName: username,
|
||||||
|
email: username,
|
||||||
|
lastLoginAt: Date.now(),
|
||||||
|
isConnected: true,
|
||||||
|
hasError: false,
|
||||||
|
isDefault: accountStore.accounts.length === 0,
|
||||||
|
});
|
||||||
|
accountStore.setActiveAccount(accountId);
|
||||||
|
|
||||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||||
initializeFeatureStores(client);
|
initializeFeatureStores(client);
|
||||||
|
|
||||||
@@ -445,9 +974,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
authMode: 'basic',
|
authMode: 'basic',
|
||||||
|
activeAccountId: accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync settings from server (only if enabled)
|
|
||||||
fetchConfig().then(config => {
|
fetchConfig().then(config => {
|
||||||
if (!config.settingsSyncEnabled) return;
|
if (!config.settingsSyncEnabled) return;
|
||||||
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
||||||
@@ -473,6 +1002,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
rememberMe: false,
|
rememberMe: false,
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
tokenExpiresAt: null,
|
tokenExpiresAt: null,
|
||||||
|
activeAccountId: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +1017,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const primaryIdentity = identities[0] ?? null;
|
const primaryIdentity = identities[0] ?? null;
|
||||||
set({ identities, primaryIdentity });
|
set({ identities, primaryIdentity });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getClientForAccount: (accountId: string) => {
|
||||||
|
return clients.get(accountId);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
@@ -498,6 +1032,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
? state.isAuthenticated
|
? state.isAuthenticated
|
||||||
: undefined,
|
: undefined,
|
||||||
rememberMe: state.rememberMe,
|
rememberMe: state.rememberMe,
|
||||||
|
activeAccountId: state.activeAccountId,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+94
-15
@@ -92,7 +92,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
fetchCalendars: async (client) => {
|
fetchCalendars: async (client) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const calendars = await client.getCalendars();
|
const calendars = await client.getAllCalendars();
|
||||||
const { selectedCalendarIds } = get();
|
const { selectedCalendarIds } = get();
|
||||||
const validIds = calendars.map(c => c.id);
|
const validIds = calendars.map(c => c.id);
|
||||||
const stillValid = selectedCalendarIds.filter(id => validIds.includes(id));
|
const stillValid = selectedCalendarIds.filter(id => validIds.includes(id));
|
||||||
@@ -110,7 +110,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
fetchEvents: async (client, start, end) => {
|
fetchEvents: async (client, start, end) => {
|
||||||
set({ isLoadingEvents: true, error: null });
|
set({ isLoadingEvents: true, error: null });
|
||||||
try {
|
try {
|
||||||
const events = await client.queryCalendarEvents({
|
const events = await client.queryAllCalendarEvents({
|
||||||
after: start,
|
after: start,
|
||||||
before: end,
|
before: end,
|
||||||
});
|
});
|
||||||
@@ -124,8 +124,31 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
createEvent: async (client, event, sendSchedulingMessages) => {
|
createEvent: async (client, event, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
const created = await client.createCalendarEvent(event, sendSchedulingMessages);
|
// Resolve shared calendar context from calendarIds
|
||||||
|
let targetAccountId = event.accountId;
|
||||||
|
const cleanEvent = { ...event };
|
||||||
|
if (event.calendarIds) {
|
||||||
|
const calId = Object.keys(event.calendarIds)[0];
|
||||||
|
if (calId) {
|
||||||
|
const cal = get().calendars.find(c => c.id === calId);
|
||||||
|
if (cal?.isShared && cal.originalId) {
|
||||||
|
targetAccountId = cal.accountId;
|
||||||
|
cleanEvent.calendarIds = { [cal.originalId]: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.originalCalendarIds) {
|
||||||
|
cleanEvent.calendarIds = event.originalCalendarIds;
|
||||||
|
}
|
||||||
|
const created = await client.createCalendarEvent(cleanEvent, sendSchedulingMessages, targetAccountId);
|
||||||
set((state) => ({ events: [...state.events, created] }));
|
set((state) => ({ events: [...state.events, created] }));
|
||||||
|
if (sendSchedulingMessages && created.participants) {
|
||||||
|
try {
|
||||||
|
await client.sendImipInvitation(created);
|
||||||
|
} catch (e) {
|
||||||
|
debug.error('Failed to send invitation emails:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
return created;
|
return created;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to create event:', error);
|
debug.error('Failed to create event:', error);
|
||||||
@@ -137,10 +160,34 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
|
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
await client.updateCalendarEvent(id, updates, sendSchedulingMessages);
|
// Resolve shared event IDs
|
||||||
|
const storeEvent = get().events.find(e => e.id === id);
|
||||||
|
const realId = storeEvent?.originalId || id;
|
||||||
|
const targetAccountId = storeEvent?.accountId;
|
||||||
|
// Remap namespaced calendarIds back to original IDs
|
||||||
|
const cleanUpdates = { ...updates };
|
||||||
|
if (cleanUpdates.calendarIds) {
|
||||||
|
const remapped: Record<string, boolean> = {};
|
||||||
|
for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) {
|
||||||
|
const cal = get().calendars.find(c => c.id === calId);
|
||||||
|
remapped[cal?.originalId || calId] = v;
|
||||||
|
}
|
||||||
|
cleanUpdates.calendarIds = remapped;
|
||||||
|
}
|
||||||
|
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
||||||
}));
|
}));
|
||||||
|
if (sendSchedulingMessages) {
|
||||||
|
try {
|
||||||
|
const updatedEvent = await client.getCalendarEvent(realId, targetAccountId);
|
||||||
|
if (updatedEvent?.participants) {
|
||||||
|
await client.sendImipInvitation(updatedEvent);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debug.error('Failed to send update notification emails:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to update event:', error);
|
debug.error('Failed to update event:', error);
|
||||||
set({ error: 'Failed to update event' });
|
set({ error: 'Failed to update event' });
|
||||||
@@ -157,6 +204,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
throw new Error('Invalid participant ID');
|
throw new Error('Invalid participant ID');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// Resolve shared event IDs
|
||||||
|
const storeEvent = get().events.find(e => e.id === eventId);
|
||||||
|
const realId = storeEvent?.originalId || eventId;
|
||||||
|
const targetAccountId = storeEvent?.accountId;
|
||||||
// Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1
|
// Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1
|
||||||
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
|
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||||
const patchKey = `participants/${escapedId}/participationStatus`;
|
const patchKey = `participants/${escapedId}/participationStatus`;
|
||||||
@@ -167,9 +218,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
patch.replyTo = replyTo;
|
patch.replyTo = replyTo;
|
||||||
}
|
}
|
||||||
await client.updateCalendarEvent(
|
await client.updateCalendarEvent(
|
||||||
eventId,
|
realId,
|
||||||
patch as unknown as Partial<CalendarEvent>,
|
patch as unknown as Partial<CalendarEvent>,
|
||||||
true
|
true,
|
||||||
|
targetAccountId
|
||||||
);
|
);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.map(e => {
|
events: state.events.map(e => {
|
||||||
@@ -192,6 +244,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
|
|
||||||
importEvents: async (client, events, calendarId) => {
|
importEvents: async (client, events, calendarId) => {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
|
// Resolve shared calendar IDs
|
||||||
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
|
const realCalendarId = cal?.originalId || calendarId;
|
||||||
|
const targetAccountId = cal?.accountId;
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const src = event as Partial<CalendarEvent>;
|
const src = event as Partial<CalendarEvent>;
|
||||||
try {
|
try {
|
||||||
@@ -229,7 +285,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data: Partial<CalendarEvent> = {
|
const data: Partial<CalendarEvent> = {
|
||||||
calendarIds: { [calendarId]: true },
|
calendarIds: { [realCalendarId]: true },
|
||||||
uid: src.uid,
|
uid: src.uid,
|
||||||
title: src.title,
|
title: src.title,
|
||||||
description: src.description,
|
description: src.description,
|
||||||
@@ -259,7 +315,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
const v = (data as Record<string, unknown>)[k];
|
const v = (data as Record<string, unknown>)[k];
|
||||||
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
|
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
|
||||||
});
|
});
|
||||||
const created = await client.createCalendarEvent(data);
|
const created = await client.createCalendarEvent(data, undefined, targetAccountId);
|
||||||
set((state) => ({ events: [...state.events, created] }));
|
set((state) => ({ events: [...state.events, created] }));
|
||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -272,7 +328,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const all = await client.queryCalendarEvents({});
|
const all = await client.queryCalendarEvents({}, undefined, undefined, targetAccountId);
|
||||||
const matching = all.filter((e) => e.uid === src.uid);
|
const matching = all.filter((e) => e.uid === src.uid);
|
||||||
if (matching.length > 0) {
|
if (matching.length > 0) {
|
||||||
const existingIds = new Set(storeEvents.map((e) => e.id));
|
const existingIds = new Set(storeEvents.map((e) => e.id));
|
||||||
@@ -296,7 +352,21 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
await client.deleteCalendarEvent(id, sendSchedulingMessages);
|
// Resolve shared event IDs
|
||||||
|
const storeEvent = get().events.find(e => e.id === id);
|
||||||
|
const realId = storeEvent?.originalId || id;
|
||||||
|
const targetAccountId = storeEvent?.accountId;
|
||||||
|
if (sendSchedulingMessages) {
|
||||||
|
try {
|
||||||
|
const event = await client.getCalendarEvent(realId, targetAccountId);
|
||||||
|
if (event?.participants) {
|
||||||
|
await client.sendImipCancellation(event);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debug.error('Failed to send cancellation emails:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.filter(e => e.id !== id),
|
events: state.events.filter(e => e.id !== id),
|
||||||
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
||||||
@@ -314,7 +384,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
updateCalendar: async (client, calendarId, updates) => {
|
updateCalendar: async (client, calendarId, updates) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
await client.updateCalendar(calendarId, updates);
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
|
const realId = cal?.originalId || calendarId;
|
||||||
|
const targetAccountId = cal?.accountId;
|
||||||
|
await client.updateCalendar(realId, updates, targetAccountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
calendars: state.calendars.map(c =>
|
calendars: state.calendars.map(c =>
|
||||||
c.id === calendarId ? { ...c, ...updates } : c
|
c.id === calendarId ? { ...c, ...updates } : c
|
||||||
@@ -346,7 +419,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
removeCalendar: async (client, calendarId) => {
|
removeCalendar: async (client, calendarId) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
await client.deleteCalendar(calendarId);
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
|
const realId = cal?.originalId || calendarId;
|
||||||
|
const targetAccountId = cal?.accountId;
|
||||||
|
await client.deleteCalendar(realId, targetAccountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
calendars: state.calendars.filter(c => c.id !== calendarId),
|
calendars: state.calendars.filter(c => c.id !== calendarId),
|
||||||
selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== calendarId),
|
selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== calendarId),
|
||||||
@@ -362,18 +438,21 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
clearCalendarEvents: async (client, calendarId) => {
|
clearCalendarEvents: async (client, calendarId) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
|
const realCalId = cal?.originalId || calendarId;
|
||||||
|
const targetAccountId = cal?.accountId;
|
||||||
let totalDeleted = 0;
|
let totalDeleted = 0;
|
||||||
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
|
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
while (hasMore) {
|
while (hasMore) {
|
||||||
// Query all events and filter client-side by calendarId
|
// Query all events and filter client-side by calendarId
|
||||||
// to avoid relying on server-side inCalendars filter support
|
// to avoid relying on server-side inCalendars filter support
|
||||||
const allEvents = await client.getCalendarEvents();
|
const allEvents = await client.getCalendarEvents(undefined, targetAccountId);
|
||||||
const calendarEvents = allEvents.filter(e => e.calendarIds?.[calendarId]);
|
const calendarEvents = allEvents.filter(e => e.calendarIds?.[realCalId]);
|
||||||
if (calendarEvents.length === 0) break;
|
if (calendarEvents.length === 0) break;
|
||||||
|
|
||||||
const ids = calendarEvents.map(e => e.id);
|
const ids = calendarEvents.map(e => e.id);
|
||||||
const { destroyed } = await client.batchDeleteCalendarEvents(ids);
|
const { destroyed } = await client.batchDeleteCalendarEvents(ids, targetAccountId);
|
||||||
totalDeleted += destroyed.length;
|
totalDeleted += destroyed.length;
|
||||||
|
|
||||||
// If we couldn't destroy any events, stop to avoid infinite loop
|
// If we couldn't destroy any events, stop to avoid infinite loop
|
||||||
|
|||||||
+154
-27
@@ -80,13 +80,46 @@ interface ContactStore {
|
|||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
|
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
|
||||||
bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
||||||
|
moveContactToAddressBook: (client: JMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
||||||
|
|
||||||
importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useContactStore = create<ContactStore>()(
|
export const useContactStore = create<ContactStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => {
|
||||||
|
|
||||||
|
// Clean group member references when contacts are removed
|
||||||
|
function cleanGroupMembers(contacts: ContactCard[], removedIds: Set<string>): ContactCard[] {
|
||||||
|
// Collect uid/id variants of removed contacts for matching
|
||||||
|
const removedKeys = new Set<string>();
|
||||||
|
for (const c of contacts) {
|
||||||
|
if (!removedIds.has(c.id)) continue;
|
||||||
|
removedKeys.add(c.id);
|
||||||
|
if (c.uid) {
|
||||||
|
removedKeys.add(c.uid);
|
||||||
|
const bare = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid;
|
||||||
|
removedKeys.add(bare);
|
||||||
|
}
|
||||||
|
if (c.originalId) removedKeys.add(c.originalId);
|
||||||
|
}
|
||||||
|
return contacts.map(c => {
|
||||||
|
if (c.kind !== 'group' || !c.members) return c;
|
||||||
|
let changed = false;
|
||||||
|
const newMembers: Record<string, boolean> = {};
|
||||||
|
for (const [key, val] of Object.entries(c.members)) {
|
||||||
|
const bareKey = key.startsWith('urn:uuid:') ? key.slice(9) : key;
|
||||||
|
if (removedKeys.has(key) || removedKeys.has(bareKey)) {
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
newMembers[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? { ...c, members: newMembers } : c;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ({
|
||||||
contacts: [],
|
contacts: [],
|
||||||
addressBooks: [],
|
addressBooks: [],
|
||||||
selectedContactId: null,
|
selectedContactId: null,
|
||||||
@@ -101,7 +134,7 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
fetchContacts: async (client) => {
|
fetchContacts: async (client) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const contacts = await client.getContacts();
|
const contacts = await client.getAllContacts();
|
||||||
set({ contacts, isLoading: false });
|
set({ contacts, isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch contacts:', error);
|
console.error('Failed to fetch contacts:', error);
|
||||||
@@ -111,7 +144,7 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
fetchAddressBooks: async (client) => {
|
fetchAddressBooks: async (client) => {
|
||||||
try {
|
try {
|
||||||
const addressBooks = await client.getAddressBooks();
|
const addressBooks = await client.getAllAddressBooks();
|
||||||
set({ addressBooks });
|
set({ addressBooks });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch address books:', error);
|
console.error('Failed to fetch address books:', error);
|
||||||
@@ -122,7 +155,16 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
createContact: async (client, contact) => {
|
createContact: async (client, contact) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const created = await client.createContact(contact);
|
const accountId = contact.isShared ? contact.accountId : undefined;
|
||||||
|
const created = await client.createContact(contact, accountId);
|
||||||
|
// Preserve shared account metadata
|
||||||
|
if (contact.isShared && contact.accountId) {
|
||||||
|
created.accountId = contact.accountId;
|
||||||
|
created.accountName = contact.accountName;
|
||||||
|
created.isShared = true;
|
||||||
|
created.id = `${contact.accountId}:${created.id}`;
|
||||||
|
created.originalId = created.id.includes(':') ? created.id.split(':').slice(1).join(':') : created.id;
|
||||||
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
contacts: [...state.contacts, created],
|
contacts: [...state.contacts, created],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -137,7 +179,10 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
updateContact: async (client, id, updates) => {
|
updateContact: async (client, id, updates) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
await client.updateContact(id, updates);
|
const contact = get().contacts.find(c => c.id === id);
|
||||||
|
const originalId = contact?.originalId || id;
|
||||||
|
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||||
|
await client.updateContact(originalId, updates, accountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.map(c =>
|
contacts: state.contacts.map(c =>
|
||||||
c.id === id ? { ...c, ...updates } : c
|
c.id === id ? { ...c, ...updates } : c
|
||||||
@@ -153,11 +198,18 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
deleteContact: async (client, id) => {
|
deleteContact: async (client, id) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
await client.deleteContact(id);
|
const contact = get().contacts.find(c => c.id === id);
|
||||||
set((state) => ({
|
const originalId = contact?.originalId || id;
|
||||||
contacts: state.contacts.filter(c => c.id !== id),
|
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
await client.deleteContact(originalId, accountId);
|
||||||
}));
|
set((state) => {
|
||||||
|
const removedIds = new Set([id]);
|
||||||
|
const cleaned = cleanGroupMembers(state.contacts, removedIds);
|
||||||
|
return {
|
||||||
|
contacts: cleaned.filter(c => c.id !== id),
|
||||||
|
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
|
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
|
||||||
set({ error: msg });
|
set({ error: msg });
|
||||||
@@ -175,10 +227,14 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
deleteLocalContact: (id) => set((state) => ({
|
deleteLocalContact: (id) => set((state) => {
|
||||||
contacts: state.contacts.filter(c => c.id !== id),
|
const removedIds = new Set([id]);
|
||||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
const cleaned = cleanGroupMembers(state.contacts, removedIds);
|
||||||
})),
|
return {
|
||||||
|
contacts: cleaned.filter(c => c.id !== id),
|
||||||
|
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
setSelectedContact: (id) => set({ selectedContactId: id }),
|
setSelectedContact: (id) => set({ selectedContactId: id }),
|
||||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
@@ -296,7 +352,10 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
name: { components: [{ kind: 'given', value: name }], isOrdered: true },
|
name: { components: [{ kind: 'given', value: name }], isOrdered: true },
|
||||||
};
|
};
|
||||||
if (client && get().supportsSync) {
|
if (client && get().supportsSync) {
|
||||||
await client.updateContact(groupId, updates);
|
const group = get().contacts.find(c => c.id === groupId);
|
||||||
|
const originalId = group?.originalId || groupId;
|
||||||
|
const accountId = group?.isShared ? group.accountId : undefined;
|
||||||
|
await client.updateContact(originalId, updates, accountId);
|
||||||
}
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.map(c =>
|
contacts: state.contacts.map(c =>
|
||||||
@@ -313,13 +372,15 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
const newMembers = { ...group.members };
|
const newMembers = { ...group.members };
|
||||||
memberIds.forEach(id => {
|
memberIds.forEach(id => {
|
||||||
const contact = contacts.find(c => c.id === id);
|
const contact = contacts.find(c => c.id === id);
|
||||||
const key = contact?.uid || id;
|
const key = contact?.uid || contact?.originalId || id;
|
||||||
newMembers[key] = true;
|
newMembers[key] = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const updates: Partial<ContactCard> = { members: newMembers };
|
const updates: Partial<ContactCard> = { members: newMembers };
|
||||||
if (client && get().supportsSync) {
|
if (client && get().supportsSync) {
|
||||||
await client.updateContact(groupId, updates);
|
const originalId = group.originalId || groupId;
|
||||||
|
const accountId = group.isShared ? group.accountId : undefined;
|
||||||
|
await client.updateContact(originalId, updates, accountId);
|
||||||
}
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.map(c =>
|
contacts: state.contacts.map(c =>
|
||||||
@@ -359,7 +420,9 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
const updates: Partial<ContactCard> = { members: newMembers };
|
const updates: Partial<ContactCard> = { members: newMembers };
|
||||||
if (client && get().supportsSync) {
|
if (client && get().supportsSync) {
|
||||||
await client.updateContact(groupId, updates);
|
const originalId = group.originalId || groupId;
|
||||||
|
const accountId = group.isShared ? group.accountId : undefined;
|
||||||
|
await client.updateContact(originalId, updates, accountId);
|
||||||
}
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.map(c =>
|
contacts: state.contacts.map(c =>
|
||||||
@@ -370,7 +433,10 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
deleteGroup: async (client, groupId) => {
|
deleteGroup: async (client, groupId) => {
|
||||||
if (client && get().supportsSync) {
|
if (client && get().supportsSync) {
|
||||||
await client.deleteContact(groupId);
|
const group = get().contacts.find(c => c.id === groupId);
|
||||||
|
const originalId = group?.originalId || groupId;
|
||||||
|
const accountId = group?.isShared ? group.accountId : undefined;
|
||||||
|
await client.deleteContact(originalId, accountId);
|
||||||
}
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.filter(c => c.id !== groupId),
|
contacts: state.contacts.filter(c => c.id !== groupId),
|
||||||
@@ -410,13 +476,16 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
bulkDeleteContacts: async (client, ids) => {
|
bulkDeleteContacts: async (client, ids) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
const { supportsSync } = get();
|
const { supportsSync, contacts } = get();
|
||||||
const deletedIds = new Set(ids);
|
const deletedIds = new Set(ids);
|
||||||
|
|
||||||
if (client && supportsSync) {
|
if (client && supportsSync) {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
try {
|
try {
|
||||||
await client.deleteContact(id);
|
const contact = contacts.find(c => c.id === id);
|
||||||
|
const originalId = contact?.originalId || id;
|
||||||
|
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||||
|
await client.deleteContact(originalId, accountId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to delete contact ${id}:`, error);
|
console.error(`Failed to delete contact ${id}:`, error);
|
||||||
deletedIds.delete(id);
|
deletedIds.delete(id);
|
||||||
@@ -427,11 +496,14 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
contacts: state.contacts.filter(c => !deletedIds.has(c.id)),
|
const cleaned = cleanGroupMembers(state.contacts, deletedIds);
|
||||||
selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId,
|
return {
|
||||||
selectedContactIds: new Set<string>(),
|
contacts: cleaned.filter(c => !deletedIds.has(c.id)),
|
||||||
}));
|
selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId,
|
||||||
|
selectedContactIds: new Set<string>(),
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
bulkAddToGroup: async (client, groupId, contactIds) => {
|
bulkAddToGroup: async (client, groupId, contactIds) => {
|
||||||
@@ -439,6 +511,60 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
set({ selectedContactIds: new Set<string>() });
|
set({ selectedContactIds: new Set<string>() });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
moveContactToAddressBook: async (client, contactIds, addressBook) => {
|
||||||
|
set({ error: null });
|
||||||
|
const { contacts } = get();
|
||||||
|
const targetBookOriginalId = addressBook.originalId || addressBook.id;
|
||||||
|
const targetAccountId = addressBook.accountId;
|
||||||
|
const primaryAccountId = client.getContactsAccountId();
|
||||||
|
|
||||||
|
for (const id of contactIds) {
|
||||||
|
const contact = contacts.find(c => c.id === id);
|
||||||
|
if (!contact) continue;
|
||||||
|
|
||||||
|
const originalId = contact.originalId || id;
|
||||||
|
const sourceAccountId = contact.isShared ? contact.accountId : undefined;
|
||||||
|
|
||||||
|
// Same account: just update the addressBookIds
|
||||||
|
if ((sourceAccountId || primaryAccountId) === (targetAccountId || primaryAccountId)) {
|
||||||
|
await client.updateContact(originalId, { addressBookIds: { [targetBookOriginalId]: true } }, sourceAccountId);
|
||||||
|
const isTargetPrimary = !targetAccountId || targetAccountId === primaryAccountId;
|
||||||
|
const localBookId = isTargetPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`;
|
||||||
|
set((state) => ({
|
||||||
|
contacts: state.contacts.map(c =>
|
||||||
|
c.id === id ? { ...c, addressBookIds: { [localBookId]: true } } : c
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Cross-account: create in target, delete from source
|
||||||
|
const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, id: _id, ...contactData } = contact;
|
||||||
|
const newContact = await client.createContact(
|
||||||
|
{ ...contactData, addressBookIds: { [targetBookOriginalId]: true } },
|
||||||
|
targetAccountId
|
||||||
|
);
|
||||||
|
await client.deleteContact(originalId, sourceAccountId);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
const isPrimary = !targetAccountId || targetAccountId === primaryAccountId;
|
||||||
|
const localBookId = isPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`;
|
||||||
|
set((state) => ({
|
||||||
|
contacts: state.contacts.map(c => {
|
||||||
|
if (c.id !== id) return c;
|
||||||
|
return {
|
||||||
|
...newContact,
|
||||||
|
id: isPrimary ? newContact.id : `${targetAccountId}:${newContact.id}`,
|
||||||
|
originalId: newContact.id,
|
||||||
|
accountId: targetAccountId,
|
||||||
|
accountName: addressBook.accountName || targetAccountId,
|
||||||
|
isShared: !isPrimary,
|
||||||
|
addressBookIds: { [localBookId]: true },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
importContacts: async (client, contacts) => {
|
importContacts: async (client, contacts) => {
|
||||||
const { supportsSync } = get();
|
const { supportsSync } = get();
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
@@ -464,7 +590,8 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
return imported;
|
return imported;
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'contact-storage',
|
name: 'contact-storage',
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ interface EmailStore {
|
|||||||
loadMoreEmails: (client: JMAPClient) => Promise<void>;
|
loadMoreEmails: (client: JMAPClient) => Promise<void>;
|
||||||
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
|
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
|
||||||
fetchQuota: (client: JMAPClient) => Promise<void>;
|
fetchQuota: (client: JMAPClient) => Promise<void>;
|
||||||
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string) => Promise<void>;
|
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number }>) => Promise<void>;
|
||||||
sendRawEmail: (client: JMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
|
sendRawEmail: (client: JMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
|
||||||
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||||
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
|
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||||
@@ -388,10 +388,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody) => {
|
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody);
|
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments);
|
||||||
// Refresh handled by UI layer for immediate feedback
|
// Refresh handled by UI layer for immediate feedback
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface IdentityStore {
|
|||||||
// Identity state (from server)
|
// Identity state (from server)
|
||||||
identities: Identity[];
|
identities: Identity[];
|
||||||
selectedIdentityId: string | null;
|
selectedIdentityId: string | null;
|
||||||
|
preferredPrimaryId: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ interface IdentityStore {
|
|||||||
updateIdentityLocal: (identityId: string, updates: Partial<Identity>) => void;
|
updateIdentityLocal: (identityId: string, updates: Partial<Identity>) => void;
|
||||||
removeIdentity: (identityId: string) => void;
|
removeIdentity: (identityId: string) => void;
|
||||||
selectIdentity: (identityId: string | null) => void;
|
selectIdentity: (identityId: string | null) => void;
|
||||||
|
setPreferredPrimary: (identityId: string | null) => void;
|
||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
clearIdentities: () => void;
|
clearIdentities: () => void;
|
||||||
@@ -43,6 +45,7 @@ export const useIdentityStore = create<IdentityStore>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
identities: [],
|
identities: [],
|
||||||
selectedIdentityId: null,
|
selectedIdentityId: null,
|
||||||
|
preferredPrimaryId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
subAddress: {
|
subAddress: {
|
||||||
@@ -71,6 +74,8 @@ export const useIdentityStore = create<IdentityStore>()(
|
|||||||
|
|
||||||
selectIdentity: (identityId) => set({ selectedIdentityId: identityId }),
|
selectIdentity: (identityId) => set({ selectedIdentityId: identityId }),
|
||||||
|
|
||||||
|
setPreferredPrimary: (identityId) => set({ preferredPrimaryId: identityId }),
|
||||||
|
|
||||||
setLoading: (loading) => set({ isLoading: loading }),
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
setError: (error) => set({ error }),
|
setError: (error) => set({ error }),
|
||||||
@@ -120,7 +125,8 @@ export const useIdentityStore = create<IdentityStore>()(
|
|||||||
name: 'identity-storage',
|
name: 'identity-storage',
|
||||||
// Only persist sub-addressing data, not identities (they're server-side)
|
// Only persist sub-addressing data, not identities (they're server-side)
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
subAddress: state.subAddress
|
subAddress: state.subAddress,
|
||||||
|
preferredPrimaryId: state.preferredPrimaryId,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+23
-15
@@ -562,26 +562,34 @@ if (typeof window !== 'undefined') {
|
|||||||
applyAnimations(store.animationsEnabled);
|
applyAnimations(store.animationsEnabled);
|
||||||
|
|
||||||
// Shared sync function used by all store subscribers
|
// Shared sync function used by all store subscribers
|
||||||
|
const syncToServer = async (retries = 1): Promise<void> => {
|
||||||
|
const settings = JSON.parse(useSettingsStore.getState().exportSettings());
|
||||||
|
syncLog('Syncing settings to server...');
|
||||||
|
const res = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }),
|
||||||
|
});
|
||||||
|
if (res.status === 404) {
|
||||||
|
syncWarn('Settings sync endpoint returned 404, disabling sync');
|
||||||
|
syncEnabled = false;
|
||||||
|
} else if (res.status >= 500 && retries > 0) {
|
||||||
|
syncWarn('Settings sync got server error, retrying...');
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
return syncToServer(retries - 1);
|
||||||
|
} else if (!res.ok) {
|
||||||
|
syncError('Settings sync failed with status', res.status);
|
||||||
|
} else {
|
||||||
|
syncLog('Settings synced to server successfully');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const triggerSync = () => {
|
const triggerSync = () => {
|
||||||
if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return;
|
if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return;
|
||||||
if (syncTimeout) clearTimeout(syncTimeout);
|
if (syncTimeout) clearTimeout(syncTimeout);
|
||||||
syncTimeout = setTimeout(async () => {
|
syncTimeout = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const settings = JSON.parse(useSettingsStore.getState().exportSettings());
|
await syncToServer();
|
||||||
syncLog('Syncing settings to server...');
|
|
||||||
const res = await fetch('/api/settings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }),
|
|
||||||
});
|
|
||||||
if (res.status === 404) {
|
|
||||||
syncWarn('Settings sync endpoint returned 404, disabling sync');
|
|
||||||
syncEnabled = false;
|
|
||||||
} else if (!res.ok) {
|
|
||||||
syncError('Settings sync failed with status', res.status);
|
|
||||||
} else {
|
|
||||||
syncLog('Settings synced to server successfully');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
syncError('Settings sync error:', error);
|
syncError('Settings sync error:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { CalendarTask } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
||||||
|
|
||||||
|
interface TaskStore {
|
||||||
|
tasks: CalendarTask[];
|
||||||
|
selectedTaskId: string | null;
|
||||||
|
filter: TaskViewFilter;
|
||||||
|
showCompleted: boolean;
|
||||||
|
setTasks: (tasks: CalendarTask[]) => void;
|
||||||
|
setSelectedTaskId: (id: string | null) => void;
|
||||||
|
setFilter: (filter: TaskViewFilter) => void;
|
||||||
|
setShowCompleted: (show: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTaskStore = create<TaskStore>((set) => ({
|
||||||
|
tasks: [],
|
||||||
|
selectedTaskId: null,
|
||||||
|
filter: 'all',
|
||||||
|
showCompleted: false,
|
||||||
|
setTasks: (tasks) => set({ tasks }),
|
||||||
|
setSelectedTaskId: (id) => set({ selectedTaskId: id }),
|
||||||
|
setFilter: (filter) => set({ filter }),
|
||||||
|
setShowCompleted: (show) => set({ showCompleted: show }),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user