Compare commits

..
34 Commits
Author SHA1 Message Date
Linus RathandGitHub 4501b3894b Merge pull request #51 from bulwarkmail/dev
v1.4.3 — Multi-Account Support, Contact Improvements, and Settings Encryption
2026-03-19 10:19:21 +01:00
Linus Rath 2edf2fab89 chore: bump version to 1.4.3 2026-03-19 10:16:47 +01:00
Linus Rath d493bb17dc feat: implement account switcher component and state management
- Add AccountSwitcher component for managing user accounts with UI for switching, adding, and logging out.
- Create account state manager to handle snapshots of account-specific states for efficient switching.
- Introduce utility functions for account management, including ID generation and avatar color assignment.
- Implement Zustand store for account management, supporting addition, removal, and state retrieval of accounts.
2026-03-19 10:08:57 +01:00
Linus Rath 234129397d feat: improve error logging and enhance settings sync functionality 2026-03-19 08:54:33 +01:00
Linus Rath 9b3a47f9be feat: enhance contact management with import functionality and keyword filtering 2026-03-19 08:38:59 +01:00
Linus Rath 0fcc932e66 fix: adjust popover alignment to the right 2026-03-19 07:44:11 +01:00
Linus Rath 0fe8e81dc7 Merge branch 'dev' 2026-03-19 01:43:11 +01:00
Linus Rath 267f7257cf chore: remove scripts directory (moved to local-data) 2026-03-19 01:43:11 +01:00
Linus Rath fb8c9db716 chore: bump version to 1.4.2 2026-03-19 01:38:23 +01:00
Linus Rath 2793d4b4af feat: add calendar task list view and shared calendar grouping
- Add TaskListView component for displaying calendar tasks
- Group shared calendars by account in sidebar panel
- Add task view toggle to calendar toolbar
- Extend calendar store with task-related state
2026-03-19 01:21:43 +01:00
Linus Rath 96c2ee9e13 chore: remove scripts already moved to local-data 2026-03-19 01:21:36 +01:00
Linus Rath af115e3245 feat: add address book directories with drag-and-drop and editor picker
- Show address books in sidebar organized by personal directories and
  shared accounts, replacing the flat shared accounts list
- Make contact list items draggable with multi-select support using
  native HTML5 drag-and-drop (application/x-contact-ids MIME type)
- Add drop targets on sidebar address book items with visual feedback
- Add moveContactToAddressBook store method supporting same-account
  updates and cross-account create+delete moves
- Add address book picker dropdown in contact create/edit form
- Update ContactCategory type from sharedAccountId to addressBookId
- Add address_books translations to all 8 locales
- Fix contact-list-item tests for new selectedContactIds prop
2026-03-19 01:13:34 +01:00
Linus Rath fc79bf4f9b fix: resolve default sender to canonical identity on local-part login
When authenticating with a local-part username (e.g. 'user' instead of
'user@domain.tld') on Stalwart 0.15.x, the default sender could resolve
to an alias identity instead of the canonical mailbox address.

- Add emailMatchesUsername() helper that matches local-part usernames
  against full email addresses (e.g. 'user' matches 'user@domain.tld')
- Prefer canonical identities (mayDelete=false) over aliases as tiebreaker
- Add preferredPrimaryId to identity store (persisted to localStorage)
  so users can explicitly set their default sender
- Add 'Set as Primary' star button in identity manager modal
- Fix sendEmail() fallback identity resolution for local-part usernames
- Add i18n strings for all 8 supported locales

Fixes #43
2026-03-18 20:05:35 +01:00
Linus Rath b844b88733 fix: enhance calendar event handling with IMIP invitation and cancellation support 2026-03-18 19:59:47 +01:00
Linus Rath bcdde9f454 fix: add support for email attachments in sendEmail functionality and update related components 2026-03-18 18:51:20 +01:00
Linus Rath bb72ac92ae fix: implement draft editing functionality across email components and add localization keys 2026-03-18 18:39:46 +01:00
Linus Rath 6457b27125 fix: enhance calendar event creation with double-click support and modal date handling 2026-03-18 18:13:33 +01:00
Linus Rath ef562bcaad fix: add email export/import localization keys for multiple languages 2026-03-18 18:00:51 +01:00
Linus Rath 9fdbb62205 fix: update gender handling to use speakToAs structure and adjust localization keys 2026-03-18 17:55:29 +01:00
Linus Rath 2edbf379e2 fix: implement unwrapping of embedded message/rfc822 attachments and enhance HTML body validation 2026-03-18 17:32:25 +01:00
Linus Rath cdc521b693 fix: add time-based sorting for events in buildWeekSegments function 2026-03-18 17:02:37 +01:00
Linus Rath e7249f8bd3 Merge dev into main 2026-03-18 16:58:00 +01:00
Linus Rath a57492d7c0 fix: refactor overflow handling in EmailViewer component to use hidden priorities and layout effects 2026-03-18 16:56:24 +01:00
Linus Rath 2b4ff2f3de fix: remove debugMode usage from EmailViewer component 2026-03-18 16:33:55 +01:00
Linus Rath 5a3f9faafe fix: update dompurify to version 3.3.3 and elliptic to version 6.6.1, add undici override 2026-03-18 16:10:47 +01:00
Linus Rath a74cd32364 Merge dev into main - version 1.4.1 2026-03-18 15:51:22 +01:00
Linus Rath 38c04099e2 Bump version to 1.4.1 2026-03-18 15:42:37 +01:00
Linus Rath ae14e09f66 fix: enforce image and table styling 2026-03-18 15:40:08 +01:00
Linus Rath ada356e440 fix: menu overvlow fixed with submenu support for move and tag actions 2026-03-18 15:35:17 +01:00
Linus Rath a886edda6f fix: deduplicate toolbar and fix overflow detection
Extract shared renderToolbarItems() function to eliminate ~850 lines of
duplicated toolbar code between 'top' and 'below-subject' positions.

Add overflow support to Reply, Reply All, and Forward buttons with
data-overflow-item attributes and corresponding More menu entries.

Fix overflow detection algorithm: temporarily disable flex-shrink on
child groups during measurement so scrollWidth reflects natural widths
instead of flex-compressed values. Add overflow-hidden to toolbar
container to prevent visual overflow during recalculation.
2026-03-18 15:29:16 +01:00
Linus Rath ff97d8bc4c feat: enhance TNEF parsing with detailed debug logging for better traceability 2026-03-18 15:00:53 +01:00
Linus Rath b1db100c3a feat: enhance email body handling by prioritizing textBody for minimal HTML and expanding JMAPClient properties 2026-03-18 14:53:08 +01:00
Linus Rath 19584cfef6 feat: add email import/export functionality with .eml support 2026-03-18 14:44:12 +01:00
Linus Rath 8a2ef5a6c5 feat: integrate webcrypto-liner for legacy algorithm support in S/MIME handling 2026-03-18 14:29:29 +01:00
72 changed files with 5108 additions and 1902 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.4.0-green.svg)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.4.3-green.svg)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue)](https://ghcr.io/bulwarkmail/webmail) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue)](https://ghcr.io/bulwarkmail/webmail)
</div> </div>
+1 -1
View File
@@ -1 +1 @@
1.4.0 1.4.3
+1
View File
@@ -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');
+10 -4
View File
@@ -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}
+99 -10
View File
@@ -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>
); );
} }
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+2 -2
View File
@@ -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}
+31 -8
View File
@@ -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
View File
@@ -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;
+6 -2
View File
@@ -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)}
+125 -97
View File
@@ -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>
); );
} }
+44 -1
View File
@@ -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>
+203
View File
@@ -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', () => {
+8 -5
View File
@@ -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>
)} )}
+57 -13
View File
@@ -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>
+30 -1
View File
@@ -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",
+1
View File
@@ -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();
+383 -36
View File
@@ -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>
);
}
+23
View File
@@ -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,
}); });
} }
+16
View File
@@ -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 && (
<> <>
+3
View File
@@ -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)}
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -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) {
+47 -4
View File
@@ -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"
+1 -1
View File
@@ -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'
)} )}
+273
View File
@@ -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
)}
</>
);
}
+2 -7
View File
@@ -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>
+3 -9
View File
@@ -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) => {
+4 -3
View File
@@ -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
View File
@@ -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';
+2 -2
View File
@@ -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" },
+116
View File
@@ -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();
}
+59
View File
@@ -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;
+5
View File
@@ -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}`;
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+5
View File
@@ -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}`;
}
+5 -2
View File
@@ -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> {
+76
View File
@@ -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);
}
}
+14 -6
View File
@@ -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;
+3 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+196 -9
View File
@@ -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
View File
@@ -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"
} }
} }
-229
View File
@@ -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}`);
-160
View File
@@ -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);
-101
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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.');
+184
View File
@@ -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 (04) 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
View File
@@ -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
View File
@@ -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
View File
@@ -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) => ({
+3 -3
View File
@@ -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) {
+7 -1
View File
@@ -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
View File
@@ -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);
} }
+26
View File
@@ -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 }),
}));