Merge branch 'dev'

This commit is contained in:
Linus Rath
2026-03-19 01:43:11 +01:00
45 changed files with 2252 additions and 361 deletions
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## 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
+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.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.4.1-green.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.4.2-green.svg)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue)](https://ghcr.io/bulwarkmail/webmail)
</div>
+1 -1
View File
@@ -1 +1 @@
1.4.1
1.4.2
+8 -3
View File
@@ -236,10 +236,12 @@ export default function CalendarPage() {
const openCreateModal = useCallback((date?: Date, endDate?: Date) => {
setEditEvent(null);
setDefaultModalDate(date || selectedDate);
const d = date || selectedDate;
setDefaultModalDate(d);
setDefaultModalEndDate(endDate);
setSelectedDate(d);
setShowEventModal(true);
}, [selectedDate]);
}, [selectedDate, setSelectedDate]);
const openEditModal = useCallback((event: CalendarEvent) => {
setEditEvent(event);
@@ -638,6 +640,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
/>
@@ -690,7 +693,7 @@ export default function CalendarPage() {
return (
<div className="relative flex-1 flex flex-col overflow-hidden">
{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="h-5 w-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
@@ -791,6 +794,7 @@ export default function CalendarPage() {
{!isMobile && showEventModal && (
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
<EventModal
key={editEvent?.id ?? 'new'}
event={editEvent}
calendars={calendars}
defaultDate={defaultModalDate}
@@ -852,6 +856,7 @@ export default function CalendarPage() {
{showEventModal && isMobile && (
<EventModal
key={editEvent?.id ?? 'new'}
event={editEvent}
calendars={calendars}
defaultDate={defaultModalDate}
+42 -5
View File
@@ -25,7 +25,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query";
import type { ContactCard } from "@/lib/jmap/types";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
type View =
| "list"
@@ -46,6 +46,7 @@ export default function ContactsPage() {
const { quota, isPushConnected } = useEmailStore();
const {
contacts,
addressBooks,
selectedContactId,
searchQuery,
supportsSync,
@@ -71,6 +72,7 @@ export default function ContactsPage() {
clearSelection,
bulkDeleteContacts,
bulkAddToGroup,
moveContactToAddressBook,
} = useContactStore();
const [view, setView] = useState<View>("list");
@@ -123,7 +125,21 @@ export default function ContactsPage() {
// Contacts to display based on active category
const displayedContacts = useMemo(() => {
if (activeCategory === "all") return individuals;
if (activeCategory === "all") return individuals.filter(c => !c.isShared);
if ("addressBookId" in activeCategory) {
const bookId = activeCategory.addressBookId;
return individuals.filter(c => {
if (!c.addressBookIds) return false;
// Check both namespaced (accountId:bookId) and raw bookId
if (c.addressBookIds[bookId]) return true;
// For shared contacts, match namespaced id
if (c.isShared && c.accountId) {
const namespacedId = `${c.accountId}:${Object.keys(c.addressBookIds).find(k => c.addressBookIds[k])}`;
return namespacedId === bookId;
}
return false;
});
}
// Show members of the selected group
return getGroupMembers(activeCategory.groupId);
}, [activeCategory, individuals, getGroupMembers]);
@@ -131,20 +147,38 @@ export default function ContactsPage() {
// Label for the current category
const categoryLabel = useMemo(() => {
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");
}
const group = contacts.find(c => c.id === activeCategory.groupId);
return group ? getContactDisplayName(group) : t("tabs.all");
}, [activeCategory, contacts, t]);
}, [activeCategory, contacts, addressBooks, t]);
const handleSelectCategory = useCallback((category: ContactCategory) => {
setActiveCategory(category);
clearSelection();
if (typeof category === "object") {
if (typeof category === "object" && "groupId" in category) {
setSelectedGroupId(category.groupId);
} else {
setSelectedGroupId(null);
}
}, [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 handleSelectContact = (id: string) => {
setSelectedContact(id);
clearSelection();
@@ -357,13 +391,14 @@ export default function ContactsPage() {
const renderRightPanel = () => {
switch (view) {
case "create":
return <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit":
if (!selectedContact) return null;
return (
<ContactForm
contact={selectedContact}
addressBooks={addressBooks}
onSave={handleSaveEdit}
onCancel={handleCancel}
/>
@@ -508,10 +543,12 @@ export default function ContactsPage() {
<ContactsSidebar
groups={groups}
individuals={individuals}
addressBooks={addressBooks}
activeCategory={activeCategory}
onSelectCategory={handleSelectCategory}
onCreateGroup={handleCreateGroup}
onCreateContact={handleCreateNew}
onDropContacts={handleDropContacts}
/>
</div>
<ResizeHandle
+1 -1
View File
@@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.1";
const APP_VERSION = "1.4.2";
const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" },
+33 -1
View File
@@ -437,11 +437,12 @@ export default function Home() {
fromEmail?: string;
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
}) => {
if (!client) return;
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);
// Refresh the current mailbox to update the UI
@@ -468,6 +469,33 @@ export default function Home() {
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 = () => {
setComposerMode('replyAll');
setShowComposer(true);
@@ -1370,6 +1398,9 @@ export default function Home() {
selectEmail(email);
await handleUndoSpam();
}}
onEditDraft={(email) => {
handleEditDraft(email);
}}
className="flex-1 min-h-0"
/>
</ErrorBoundary>
@@ -1543,6 +1574,7 @@ export default function Home() {
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
@@ -22,6 +22,7 @@ interface CalendarMonthViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
}
@@ -34,6 +35,7 @@ export function CalendarMonthView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onCreateAtTime,
firstDayOfWeek = 1,
isMobile,
}: CalendarMonthViewProps) {
@@ -165,6 +167,7 @@ export function CalendarMonthView({
aria-selected={selected}
aria-label={fullDateLabel}
onClick={() => onSelectDate(day)}
onDoubleClick={() => onCreateAtTime?.(day)}
onDragOver={(e) => handleCellDragOver(e, key)}
onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)}
+125 -97
View File
@@ -1,8 +1,8 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useMemo } from "react";
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 type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -42,6 +42,20 @@ export function CalendarSidebarPanel({
const colorPickerRef = 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(() => {
if (!colorPickerId && !contextMenuCalId) return;
const handleClick = (e: MouseEvent) => {
@@ -97,108 +111,122 @@ export function CalendarSidebarPanel({
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 (
<div className="mt-4">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{calendars.map((cal) => {
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>
);
})}
{personalCalendars.map(renderCalendarItem)}
</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>
);
}
+44 -1
View File
@@ -153,7 +153,7 @@ export function CalendarToolbar({
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{calendars.map((cal) => {
{calendars.filter(c => !c.isShared).map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
@@ -179,6 +179,49 @@ export function CalendarToolbar({
);
})}
</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>
+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,
onClick: vi.fn(),
onCheckboxClick: vi.fn(),
selectedContactIds: new Set<string>(),
};
it('renders contact name and email', () => {
+8 -5
View File
@@ -351,13 +351,16 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
</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">
<div className="text-sm">
{contact.gender.sex && <span>{t(`detail.gender_${contact.gender.sex.toUpperCase()}`, { defaultValue: contact.gender.sex })}</span>}
{contact.gender.identity && (
<span className="text-muted-foreground">{contact.gender.sex ? " — " : ""}{contact.gender.identity}</span>
)}
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>}
{contact.speakToAs.pronouns && (() => {
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
return firstPronoun ? (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun}</span>
) : null;
})()}
</div>
</Section>
)}
+57 -13
View File
@@ -1,12 +1,12 @@
"use client";
import { useState } from "react";
import { useState, useMemo } from "react";
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 { Input } from "@/components/ui/input";
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 {
address: string;
@@ -47,6 +47,7 @@ interface AddressEntry {
interface ContactFormProps {
contact?: ContactCard | null;
addressBooks?: AddressBook[];
onSave: (data: Partial<ContactCard>) => Promise<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 isEditing = !!contact;
@@ -235,12 +236,31 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
);
const [genderSex, setGenderSex] = useState(contact?.gender?.sex || "");
const [genderIdentity, setGenderIdentity] = useState(contact?.gender?.identity || "");
const [genderSex, setGenderSex] = useState(contact?.speakToAs?.grammaticalGender || "");
const [genderIdentity, setGenderIdentity] = useState(
contact?.speakToAs?.pronouns ? Object.values(contact.speakToAs.pronouns)[0]?.pronouns || "" : ""
);
const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || "");
const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || "");
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 [error, setError] = useState<string | null>(null);
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
@@ -371,12 +391,16 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
notes: note.trim()
? { n0: { note: note.trim() } }
: undefined,
gender: (genderSex.trim() || genderIdentity.trim())
? { sex: genderSex.trim() || undefined, identity: genderIdentity.trim() || undefined }
speakToAs: (genderSex.trim() || genderIdentity.trim())
? {
grammaticalGender: genderSex.trim() || undefined,
pronouns: genderIdentity.trim() ? { p0: { pronouns: genderIdentity.trim() } } : undefined,
}
: undefined,
calendarUri: calendarUri.trim() || undefined,
schedulingUri: schedulingUri.trim() || undefined,
freeBusyUri: freeBusyUri.trim() || undefined,
...(selectedBookId ? { addressBookIds: { [selectedBookId]: 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">
{/* 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 */}
<div className="md:col-span-2 xl:col-span-3">
<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>
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
<option value=""></option>
<option value="M">{t("gender_male")}</option>
<option value="F">{t("gender_female")}</option>
<option value="O">{t("gender_other")}</option>
<option value="N">{t("gender_none")}</option>
<option value="U">{t("gender_unknown")}</option>
<option value="masculine">{t("gender_male")}</option>
<option value="feminine">{t("gender_female")}</option>
<option value="other">{t("gender_other")}</option>
<option value="none">{t("gender_none")}</option>
<option value="unknown">{t("gender_unknown")}</option>
</Select>
</div>
<div>
+30 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { useCallback, type DragEvent } from "react";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
@@ -13,19 +14,47 @@ interface ContactListItemProps {
isChecked: boolean;
hasSelection: boolean;
density: Density;
selectedContactIds: Set<string>;
onClick: (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 email = getContactPrimaryEmail(contact);
const org = contact.organizations
? Object.values(contact.organizations)[0]?.name
: 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 (
<div
draggable
onDragStart={handleDragStart}
onClick={onClick}
className={cn(
"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)}
hasSelection={hasSelection}
density={density}
selectedContactIds={selectedContactIds}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
+156 -6
View File
@@ -1,32 +1,36 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState, useCallback, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { BookUser, Users, Plus, UserPlus } from "lucide-react";
import { BookUser, Users, Plus, UserPlus, Share2, Book } from "lucide-react";
import { Button } from "@/components/ui/button";
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";
export type ContactCategory = "all" | { groupId: string };
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string };
interface ContactsSidebarProps {
groups: ContactCard[];
individuals: ContactCard[];
addressBooks: AddressBook[];
activeCategory: ContactCategory;
onSelectCategory: (category: ContactCategory) => void;
onCreateGroup: () => void;
onCreateContact: () => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
className?: string;
}
export function ContactsSidebar({
groups,
individuals,
addressBooks,
activeCategory,
onSelectCategory,
onCreateGroup,
onCreateContact,
onDropContacts,
className,
}: ContactsSidebarProps) {
const t = useTranslations("contacts");
@@ -39,6 +43,44 @@ export function ContactsSidebar({
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;
// Build the full namespaced key
const key = contact.isShared && contact.accountId ? `${contact.accountId}:${bookId}` : bookId;
counts[key] = (counts[key] || 0) + 1;
}
}
return counts;
}, [individuals]);
return (
<div className={cn("flex flex-col h-full bg-secondary", className)}>
{/* Header */}
@@ -65,10 +107,31 @@ export function ContactsSidebar({
<BookUser className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{t("tabs.all")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{individuals.length}
{individuals.filter(c => !c.isShared).length}
</span>
</button>
{/* Personal address books */}
{personalBooks.length > 0 && (
<div className="mt-2">
<div className="flex items-center justify-between px-3 py-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</div>
{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">
@@ -82,7 +145,7 @@ export function ContactsSidebar({
</div>
{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
? Object.values(group.members).filter(Boolean).length
: 0;
@@ -128,7 +191,94 @@ export function ContactsSidebar({
</Button>
</div>
)}
{/* Shared accounts with address books */}
{sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2">
<div className="flex items-center justify-between px-3 py-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{group.accountName}
</span>
</div>
{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>
);
}
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 px-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;
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
}) => void | Promise<void>;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
@@ -664,6 +665,22 @@ export function EmailComposer({
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)
const buildSignatureHtml = (): string => {
if (currentIdentity?.htmlSignature) {
@@ -794,6 +811,11 @@ export function EmailComposer({
await sendRawEmail(client, payload, currentIdentity.id);
} else {
// 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?.({
to: toAddresses,
cc: ccAddresses,
@@ -805,6 +827,7 @@ export function EmailComposer({
fromEmail,
fromName: currentIdentity?.name || undefined,
identityId: currentIdentity?.id,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
});
}
+16
View File
@@ -28,6 +28,7 @@ import {
Folder,
ShieldAlert,
ShieldCheck,
EditIcon,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
@@ -60,6 +61,7 @@ interface EmailContextMenuProps {
onMoveToMailbox?: (mailboxId: string) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onEditDraft?: () => void;
// Batch actions
onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void;
@@ -126,12 +128,14 @@ export function EmailContextMenu({
onBatchMoveToMailbox,
onBatchMarkAsSpam,
onBatchUndoSpam,
onEditDraft,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const tColor = useTranslations("email_viewer.color_tag");
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isDraft = email.keywords?.['$draft'] === true;
const currentColor = getCurrentColor(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
@@ -188,6 +192,18 @@ export function EmailContextMenu({
</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 */}
{!showBatchActions && (
<>
+3
View File
@@ -37,6 +37,7 @@ interface EmailListProps {
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
onEditDraft?: (email: Email) => void;
}
export function EmailList({
@@ -57,6 +58,7 @@ export function EmailList({
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
onEditDraft,
}: EmailListProps) {
const t = useTranslations('email_list');
const { client } = useAuthStore();
@@ -467,6 +469,7 @@ export function EmailList({
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
+204 -16
View File
@@ -64,6 +64,7 @@ import {
Upload,
Moon,
HelpCircle,
EditIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -112,6 +113,7 @@ interface EmailViewerProps {
onNavigateNext?: () => void;
onNavigatePrev?: () => void;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
currentUserEmail?: string;
currentUserName?: string;
currentMailboxRole?: string;
@@ -390,6 +392,21 @@ function extractNestedSignedDataCandidate(
};
}
/**
* Check if an HTML body string is effectively empty (just boilerplate/whitespace).
* Outlook often generates HTML bodies with Word CSS + &nbsp; but no real text.
*/
function isHtmlBodyEffectivelyEmpty(html: string): boolean {
const textContent = html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&#160;/g, ' ')
.replace(/\s+/g, '')
.trim();
return textContent.length === 0;
}
function extractMimePartContent(rawText: string, depth = 0): { html: string | null; text: string | null } {
if (depth > 6) {
const trimmed = rawText.trim();
@@ -800,6 +817,7 @@ export function EmailViewer({
onNavigateNext,
onNavigatePrev,
onShowShortcuts,
onEditDraft,
currentUserEmail,
currentUserName,
currentMailboxRole,
@@ -825,6 +843,9 @@ export function EmailViewer({
// Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk';
// Detect if the email is a draft
const isDraft = email?.keywords?.['$draft'] === true;
// Color options for email tags (from user-defined keyword settings)
const colorOptions = emailKeywords.map((kw) => ({
name: kw.label,
@@ -871,6 +892,12 @@ export function EmailViewer({
const [tnefText, setTnefText] = useState<string | null>(null);
const [tnefAttachments, setTnefAttachments] = useState<TnefAttachment[]>([]);
// Embedded message/rfc822 unwrapping (Outlook forward-as-attachment)
const [embeddedEmailHtml, setEmbeddedEmailHtml] = useState<string | null>(null);
const [embeddedEmailText, setEmbeddedEmailText] = useState<string | null>(null);
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>([]);
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
// Ensure S/MIME key records are loaded from IndexedDB
useLayoutEffect(() => {
smimeStore.load();
@@ -1088,6 +1115,10 @@ export function EmailViewer({
setTnefHtml(null);
setTnefText(null);
setTnefAttachments([]);
setEmbeddedEmailHtml(null);
setEmbeddedEmailText(null);
setEmbeddedEmailAttachments([]);
setEmbeddedEmailUnwrapped(false);
}, [email?.id, externalContentPolicy]);
const prepareSmimeUnlock = useCallback((keyRecordId: string) => {
@@ -1675,15 +1706,20 @@ export function EmailViewer({
debug.group('TNEF Processing');
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
// Check if the email already has a usable HTML body
const hasHtmlBody = !!(
email.htmlBody?.[0]?.partId &&
email.bodyValues?.[email.htmlBody[0].partId]?.value?.trim()
);
if (hasHtmlBody) {
debug.log('TNEF: Email already has HTML body, will extract attachments only');
// Check if the email already has a usable HTML body with real content
// Outlook often forwards TNEF emails with an HTML body that's just Word
// boilerplate (CSS + &nbsp;) — treat these as effectively empty.
const htmlPartId = email.htmlBody?.[0]?.partId;
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
let hasRealHtmlBody = !!htmlValue;
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
hasRealHtmlBody = false;
debug.log('TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
}
if (hasRealHtmlBody) {
debug.log('TNEF: Email has real HTML body, will extract attachments only');
} else {
debug.log('TNEF: Email has no HTML body, proceeding with full TNEF extraction');
debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
}
let cancelled = false;
@@ -1719,10 +1755,10 @@ export function EmailViewer({
debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
if (parsed.htmlBody && !hasHtmlBody) {
if (parsed.htmlBody && !hasRealHtmlBody) {
setTnefHtml(parsed.htmlBody);
}
if (parsed.body && !hasHtmlBody) {
if (parsed.body && !hasRealHtmlBody) {
setTnefText(parsed.body);
}
if (parsed.attachments.length > 0) {
@@ -1746,6 +1782,83 @@ export function EmailViewer({
return () => { cancelled = true; };
}, [email, client]);
// Embedded message/rfc822 unwrapping
// When Outlook forwards an email as an attachment, the outer email body is
// often empty Word boilerplate and the real content is inside a message/rfc822
// attachment. Detect this pattern and unwrap the embedded email.
useEffect(() => {
if (!email?.attachments || !client) return;
// Find message/rfc822 attachment
const rfc822Att = email.attachments.find(
att => att.type === 'message/rfc822' && att.blobId
);
if (!rfc822Att?.blobId) return;
// Only unwrap if the outer body is effectively empty
const htmlPartId = email.htmlBody?.[0]?.partId;
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
const textPartId = email.textBody?.[0]?.partId;
const textValue = textPartId ? email.bodyValues?.[textPartId]?.value?.trim() : '';
const hasRealHtml = !!htmlValue && !isHtmlBodyEffectivelyEmpty(htmlValue);
const hasRealText = !!textValue;
if (hasRealHtml || hasRealText) {
debug.log('Embedded RFC822: Outer email has real body content, not unwrapping');
return;
}
debug.group('Embedded RFC822 Unwrapping');
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
debug.log('Outer email body is empty, will unwrap embedded email');
let cancelled = false;
async function unwrapEmbedded() {
try {
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
if (cancelled) { debug.groupEnd(); return; }
if (blobBytes.byteLength === 0) {
debug.warn('Embedded RFC822: Fetched blob is empty');
debug.groupEnd();
return;
}
const { default: PostalMime } = await import('postal-mime');
const parser = new PostalMime();
const parsed = await parser.parse(new Uint8Array(blobBytes));
if (cancelled) { debug.groupEnd(); return; }
debug.log('Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
', attachments:', parsed.attachments?.length ?? 0);
if (parsed.html) {
setEmbeddedEmailHtml(parsed.html);
}
if (parsed.text) {
setEmbeddedEmailText(parsed.text);
}
if (parsed.attachments && parsed.attachments.length > 0) {
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
debug.log('Embedded RFC822 attachments:', parsed.attachments.map(
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
).join(', '));
}
setEmbeddedEmailUnwrapped(true);
debug.groupEnd();
} catch (err) {
debug.error('Embedded RFC822 unwrapping failed:', err);
debug.groupEnd();
}
}
unwrapEmbedded();
return () => { cancelled = true; };
}, [email, client]);
// Fetch inline CID images with authentication to prevent browser auth dialogs
useEffect(() => {
let cancelled = false;
@@ -1829,6 +1942,8 @@ export function EmailViewer({
const jmapAttachments = (email?.attachments ?? [])
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
// Hide message/rfc822 when we have unwrapped the embedded email
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
.map((attachment, index) => ({
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
name: attachment.name || null,
@@ -1847,8 +1962,19 @@ export function EmailViewer({
tnefData: att.data,
}));
return [...jmapAttachments, ...tnefExtracted];
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments]);
// Append attachments extracted from embedded message/rfc822
const embeddedExtracted: EffectiveAttachment[] = embeddedEmailAttachments
.filter(att => !att.contentId) // Skip inline CID images
.map((att, index) => ({
id: `embedded-${index}-${att.filename || att.mimeType}`,
name: att.filename || null,
type: att.mimeType || 'application/octet-stream',
size: getPostalMimeAttachmentSize(att),
decryptedAttachment: att,
}));
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments]);
// Generate email source for viewing
const generateEmailSource = (email: Email): string => {
@@ -2189,8 +2315,21 @@ export function EmailViewer({
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { html: htmlFromText, isHtml: false };
}
// Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) {
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true };
}
if (embeddedEmailText) {
const htmlFromText = embeddedEmailText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { html: htmlFromText, isHtml: false };
}
return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText]);
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
const handleEffectiveAttachmentOpen = useCallback((attachment: EffectiveAttachment) => {
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
@@ -2499,6 +2638,19 @@ export function EmailViewer({
<ChevronLeft className="w-5 h-5" />
</Button>
)}
{isDraft && onEditDraft && (
<Button
variant="default"
size="sm"
onClick={onEditDraft}
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.edit_draft')}
>
<EditIcon className="w-4 h-4" />
<span className="text-sm">{t('edit_draft')}</span>
</Button>
)}
{!isDraft && (<>
<Button
variant="ghost"
size="sm"
@@ -2535,6 +2687,7 @@ export function EmailViewer({
<Forward className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
</Button>
</>)}
</div>
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
@@ -3855,6 +4008,29 @@ export function EmailViewer({
</div>
)}
{/* Draft Banner */}
{isDraft && (
<div className="border-b border-border bg-amber-50 dark:bg-amber-950/30">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-400">
<File className="w-4 h-4" />
<span className="text-sm font-medium">{t('draft_banner')}</span>
</div>
{onEditDraft && (
<Button
size="sm"
variant="outline"
onClick={onEditDraft}
className="gap-1.5"
>
<EditIcon className="w-3.5 h-3.5" />
{t('edit_draft')}
</Button>
)}
</div>
</div>
)}
<SmimePassphraseDialog
isOpen={smimeUnlockDialogOpen}
onClose={() => {
@@ -3944,8 +4120,8 @@ export function EmailViewer({
)}
</div>
{/* Quick Reply Section */}
<div className={cn(
{/* Quick Reply Section - hidden for drafts */}
{!isDraft && (<div className={cn(
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
)}>
@@ -4039,7 +4215,7 @@ export function EmailViewer({
</div>
</div>
</div>
</div>
</div>)}
</div>
</div>
@@ -4107,6 +4283,17 @@ export function EmailViewer({
<ChevronLeft className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('previous')}</span>
</button>
{isDraft && onEditDraft ? (
<button
onClick={onEditDraft}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-primary active:text-primary/80 transition-colors duration-150"
aria-label={t('tooltips.edit_draft')}
>
<EditIcon className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('edit_draft')}</span>
</button>
) : (
<>
<button
onClick={() => onReply?.()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
@@ -4131,6 +4318,7 @@ export function EmailViewer({
<Forward className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('forward')}</span>
</button>
</>)}
<button
onClick={onNavigateNext}
disabled={!onNavigateNext}
+47 -4
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from 'react';
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 { Button } from '@/components/ui/button';
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 { 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 {
name: string;
email: string;
@@ -39,6 +45,8 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const client = useAuthStore((state) => state.client);
const identities = useIdentityStore((state) => state.identities);
const preferredPrimaryId = useIdentityStore((state) => state.preferredPrimaryId);
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
const syncIdentities = useSyncIdentities();
const [editingId, setEditingId] = useState<string | null>(null);
@@ -52,11 +60,26 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
try {
const serverIdentities = await client.getIdentities();
const username = useAuthStore.getState().username;
const preferredPrimaryId = useIdentityStore.getState().preferredPrimaryId;
const sorted = [...serverIdentities].sort((a, b) => {
const aMatch = a.email === username ? -1 : 0;
const bMatch = b.email === username ? -1 : 0;
return aMatch - bMatch;
const aMatch = emailMatchesUsername(a.email, username || '');
const bMatch = emailMatchesUsername(b.email, username || '');
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);
syncIdentities();
} catch (error) {
@@ -168,6 +191,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
}
}, [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;
return (
@@ -281,6 +313,17 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
{/* Actions */}
<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
variant="ghost"
size="sm"
+4 -3
View File
@@ -239,9 +239,10 @@ export function useTimeGridInteractions({
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
const key = format(day, "yyyy-MM-dd");
setQuickCreate({ dayKey: key, day, hour, top: hour * hourHeight });
}, [hourHeight]);
const d = new Date(day);
d.setHours(hour, 0, 0, 0);
onCreateRange(d);
}, [onCreateRange]);
const handleQuickCreateSubmit = useCallback(async (title: string) => {
if (!quickCreate) return;
+1
View File
@@ -1,3 +1,4 @@
// @vitest-environment node
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
+2 -2
View File
@@ -226,7 +226,7 @@ describe("parseVCard", () => {
expect(result).toHaveLength(1);
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({
kind: "logo",
uri: "https://example.com/logo.png",
@@ -331,7 +331,7 @@ describe("generateVCard", () => {
components: [{ kind: "given", value: "Jane" }],
isOrdered: true,
},
gender: { sex: "F", identity: "Female" },
speakToAs: { grammaticalGender: "feminine", pronouns: { p0: { pronouns: "Female" } } },
media: {
m0: { kind: "logo", uri: "https://example.com/logo.png", mediaType: "image/png" },
m1: { kind: "sound", uri: "https://example.com/sound.ogg", mediaType: "audio/ogg" },
+2
View File
@@ -79,6 +79,8 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
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 || "");
});
+621 -91
View File
@@ -1405,9 +1405,10 @@ export class JMAPClient {
fromEmail?: string,
draftId?: string,
fromName?: string,
htmlBody?: string
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>
): Promise<void> {
const emailId = draftId || `draft-${Date.now()}`;
const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) {
@@ -1424,54 +1425,64 @@ export class JMAPClient {
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
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;
}
}
}
// 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[] = [];
if (draftId) {
// Destroy the old draft and create a new email with the final body
methodCalls.push(["Email/set", {
accountId: this.accountId,
update: {
[draftId]: {
"keywords/$draft": false,
"keywords/$seen": true,
mailboxIds: { [sentMailbox.id]: true },
},
},
destroy: [draftId],
}, "0"]);
methodCalls.push(["Email/set", {
accountId: this.accountId,
create: { [emailId]: emailCreate },
}, "1"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: { "1": { emailId: draftId, identityId: finalIdentityId } },
}, "1"]);
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
}, "2"]);
} 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", {
accountId: this.accountId,
create: { [emailId]: emailCreate },
@@ -1491,8 +1502,8 @@ export class JMAPClient {
throw new Error(result.description || `Failed to send email: ${result.type}`);
}
if (result.notCreated || result.notUpdated) {
const errors = result.notCreated || result.notUpdated;
if (result.notCreated) {
const errors = result.notCreated;
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
console.error('Email send error:', firstError);
throw new Error(firstError?.description || firstError?.type || 'Failed to send email');
@@ -1674,6 +1685,290 @@ export class JMAPClient {
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 }> {
if (!this.session) {
throw new Error('Not connected. Call connect() first.');
@@ -2045,6 +2340,38 @@ export class JMAPClient {
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[]> {
try {
const accountId = this.getContactsAccountId();
@@ -2062,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[]> {
try {
const accountId = this.getContactsAccountId();
@@ -2088,12 +2454,55 @@ export class JMAPClient {
}
}
async getContact(contactId: string): Promise<ContactCard | null> {
async getAllContacts(): Promise<ContactCard[]> {
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,
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([
["ContactCard/get", {
accountId,
accountId: targetAccountId,
ids: [contactId],
}, "0"]
], this.contactUsing());
@@ -2109,8 +2518,8 @@ export class JMAPClient {
}
}
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
const accountId = this.getContactsAccountId();
async createContact(contact: Partial<ContactCard>, targetAccountId?: string): Promise<ContactCard> {
const accountId = targetAccountId || this.getContactsAccountId();
let addressBookIds = contact.addressBookIds;
if (!addressBookIds || Object.keys(addressBookIds).length === 0) {
const books = await this.getAddressBooks();
@@ -2120,12 +2529,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([
["ContactCard/set", {
accountId,
create: {
"new-contact": {
...contact,
...contactData,
addressBookIds,
}
}
@@ -2142,7 +2554,7 @@ export class JMAPClient {
const createdId = result.created?.["new-contact"]?.id;
if (createdId) {
const created = await this.getContact(createdId);
const created = await this.getContact(createdId, accountId);
if (created) return created;
}
}
@@ -2150,14 +2562,17 @@ export class JMAPClient {
throw new Error("Failed to create contact");
}
async updateContact(contactId: string, updates: Partial<ContactCard>): Promise<void> {
const accountId = this.getContactsAccountId();
async updateContact(contactId: string, updates: Partial<ContactCard>, targetAccountId?: string): Promise<void> {
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([
["ContactCard/set", {
accountId,
update: {
[contactId]: updates
[contactId]: cleanUpdates
}
}, "0"]
], this.contactUsing());
@@ -2175,8 +2590,8 @@ export class JMAPClient {
throw new Error("Failed to update contact");
}
async deleteContact(contactId: string): Promise<void> {
const accountId = this.getContactsAccountId();
async deleteContact(contactId: string, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
const response = await this.request([
["ContactCard/set", {
@@ -2200,24 +2615,45 @@ export class JMAPClient {
async searchContacts(query: string): Promise<ContactCard[]> {
try {
const accountId = this.getContactsAccountId();
const allResults: ContactCard[] = [];
const primaryId = this.getContactsAccountId();
const accountIds = this.getContactCapableAccountIds();
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());
for (const accountId of accountIds) {
const isPrimary = accountId === primaryId;
const account = this.accounts[accountId];
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
return (response.methodResponses[1][1].list || []) as ContactCard[];
try {
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) {
console.error('Failed to search contacts:', error);
return [];
@@ -2241,8 +2677,47 @@ export class JMAPClient {
}
}
async createCalendar(calendar: Partial<Calendar>): Promise<Calendar> {
const accountId = this.getCalendarsAccountId();
async getAllCalendars(): Promise<Calendar[]> {
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([
["Calendar/set", {
@@ -2263,17 +2738,23 @@ export class JMAPClient {
const createdId = result.created?.["new-calendar"]?.id;
if (createdId) {
const calendars = await this.getCalendars();
const created = calendars.find(c => c.id === createdId);
if (created) return created;
// Fetch from the target account to find the created calendar
const fetchAccountId = targetAccountId || this.getCalendarsAccountId();
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");
}
async updateCalendar(calendarId: string, updates: Partial<Calendar>): Promise<void> {
const accountId = this.getCalendarsAccountId();
async updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const response = await this.request([
["Calendar/set", {
@@ -2297,8 +2778,8 @@ export class JMAPClient {
throw new Error("Failed to update calendar");
}
async deleteCalendar(calendarId: string): Promise<void> {
const accountId = this.getCalendarsAccountId();
async deleteCalendar(calendarId: string, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const response = await this.request([
["Calendar/set", {
@@ -2321,8 +2802,8 @@ export class JMAPClient {
throw new Error("Failed to delete calendar");
}
async getCalendarEvents(calendarIds?: string[]): Promise<CalendarEvent[]> {
const accountId = this.getCalendarsAccountId();
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
if (calendarIds && calendarIds.length > 0) {
@@ -2349,13 +2830,55 @@ export class JMAPClient {
return [];
}
async queryCalendarEvents(
async queryAllCalendarEvents(
filter: CalendarEventFilter,
sort?: Array<{ property: string; isAscending: boolean }>,
limit?: number
): Promise<CalendarEvent[]> {
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> = {
accountId,
@@ -2384,9 +2907,9 @@ export class JMAPClient {
}
}
async getCalendarEvent(id: string): Promise<CalendarEvent | null> {
async getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null> {
try {
const accountId = this.getCalendarsAccountId();
const accountId = targetAccountId || this.getCalendarsAccountId();
const response = await this.request([
["CalendarEvent/get", {
accountId,
@@ -2405,13 +2928,16 @@ export class JMAPClient {
}
}
async createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean): Promise<CalendarEvent> {
const accountId = this.getCalendarsAccountId();
async createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent> {
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> = {
accountId,
create: {
"new-event": event
"new-event": cleanEvent
}
};
if (sendSchedulingMessages !== undefined) {
@@ -2432,7 +2958,7 @@ export class JMAPClient {
const createdId = result.created?.["new-event"]?.id;
if (createdId) {
const created = await this.getCalendarEvent(createdId);
const created = await this.getCalendarEvent(createdId, targetAccountId);
if (created) return created;
}
}
@@ -2443,14 +2969,18 @@ export class JMAPClient {
async updateCalendarEvent(
eventId: string,
updates: Partial<CalendarEvent>,
sendSchedulingMessages?: boolean
sendSchedulingMessages?: boolean,
targetAccountId?: string
): 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> = {
accountId,
update: {
[eventId]: updates
[eventId]: cleanUpdates
}
};
if (sendSchedulingMessages !== undefined) {
@@ -2505,8 +3035,8 @@ export class JMAPClient {
throw new Error("Failed to parse calendar file");
}
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean): Promise<void> {
const accountId = this.getCalendarsAccountId();
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const setArgs: Record<string, unknown> = {
accountId,
@@ -2533,10 +3063,10 @@ export class JMAPClient {
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: [] };
const accountId = this.getCalendarsAccountId();
const accountId = targetAccountId || this.getCalendarsAccountId();
const response = await this.request([
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
], this.calendarUsing());
+47 -1
View File
@@ -160,9 +160,13 @@ export interface Identity {
export interface ContactCard {
id: string;
originalId?: string;
uid?: string;
addressBookIds: Record<string, boolean>;
kind?: 'individual' | 'group' | 'org' | 'location' | 'device' | 'application';
accountId?: string;
accountName?: string;
isShared?: boolean;
language?: string;
name?: ContactName;
nicknames?: Record<string, ContactNickname>;
@@ -183,7 +187,10 @@ export interface ContactCard {
relatedTo?: Record<string, ContactRelation>;
keywords?: 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;
schedulingUri?: string;
freeBusyUri?: string;
@@ -316,12 +323,16 @@ export interface ContactRelation {
export interface AddressBook {
id: string;
originalId?: string;
name: string;
description?: string | null;
sortOrder?: number;
isDefault?: boolean;
isSubscribed?: boolean;
myRights?: AddressBookRights;
accountId?: string;
accountName?: string;
isShared?: boolean;
}
export interface AddressBookRights {
@@ -367,6 +378,7 @@ export interface DeliveryStatus {
export interface Calendar {
id: string;
originalId?: string;
name: string;
description: string | null;
color: string | null;
@@ -380,6 +392,9 @@ export interface Calendar {
timeZone: string | null;
shareWith: Record<string, CalendarRights> | null;
myRights: CalendarRights;
accountId?: string;
accountName?: string;
isShared?: boolean;
}
export interface CalendarRights {
@@ -395,7 +410,12 @@ export interface CalendarRights {
export interface CalendarEvent {
id: string;
originalId?: string;
calendarIds: Record<string, boolean>;
originalCalendarIds?: Record<string, boolean>;
accountId?: string;
accountName?: string;
isShared?: boolean;
isDraft: boolean;
isOrigin: boolean;
utcStart: string | null;
@@ -544,6 +564,32 @@ export interface CalendarRelation {
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 {
id: string;
name: string;
+44 -7
View File
@@ -1,5 +1,29 @@
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 {
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": {
const gParts = val.split(";");
card.gender = {};
if (gParts[0]) card.gender.sex = gParts[0];
if (gParts[1]) card.gender.identity = gParts[1];
const sexCode = gParts[0]?.toUpperCase();
const identityText = gParts[1];
if (sexCode || identityText) {
card.speakToAs = {};
if (sexCode) {
card.speakToAs.grammaticalGender = vcardSexToGrammaticalGender(sexCode);
}
if (identityText) {
card.speakToAs.pronouns = { p0: { pronouns: identityText } };
}
}
break;
}
@@ -684,10 +716,15 @@ function generateSingleVCard(contact: ContactCard): string {
}
}
if (contact.gender) {
const sex = contact.gender.sex || "";
const identity = contact.gender.identity || "";
lines.push(`GENDER:${sex}${identity ? `;${identity}` : ""}`);
if (contact.speakToAs) {
const sex = contact.speakToAs.grammaticalGender
? grammaticalGenderToVcardSex(contact.speakToAs.grammaticalGender)
: "";
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) {
+31 -8
View File
@@ -191,8 +191,12 @@
"mark_read": "Als gelesen markieren",
"print": "Drucken",
"view_source": "Quelltext anzeigen",
"export_email": "Als .eml exportieren",
"import_email": ".eml importieren",
"keyboard_shortcuts": "Tastaturkürzel (?)",
"email_source": "E-Mail-Quelltext",
"draft_banner": "Diese Nachricht ist ein Entwurf",
"edit_draft": "Bearbeiten",
"copy_source": "In Zwischenablage kopieren",
"source_copied": "Quelltext in Zwischenablage kopiert",
"attachments": "Anhänge",
@@ -294,7 +298,8 @@
"unstar": "Markierung entfernen (s)",
"compose": "Verfassen (c)",
"previous": "Vorherige E-Mail",
"next": "Nächste E-Mail"
"next": "Nächste E-Mail",
"edit_draft": "Entwurf bearbeiten"
},
"spam": {
"button_title": "Spam melden",
@@ -514,6 +519,7 @@
"identity_created": "Identität erfolgreich erstellt",
"identity_updated": "Identität erfolgreich aktualisiert",
"identity_deleted": "Identität gelöscht",
"identity_set_primary": "Primäre Identität aktualisiert",
"identity_create_failed": "Identität erstellen fehlgeschlagen: {error}",
"identity_update_failed": "Identität aktualisieren fehlgeschlagen: {error}",
"identity_delete_failed": "Identität löschen fehlgeschlagen: {error}",
@@ -527,7 +533,10 @@
"templates_exported": "Vorlagen erfolgreich exportiert",
"templates_imported": "{count, plural, one {# Vorlage importiert} other {# Vorlagen importiert}}",
"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": {
"today": "Heute",
@@ -1293,7 +1302,8 @@
"not_spam": "Kein Spam",
"color_tag": "Label",
"remove_color": "Label entfernen",
"items_selected": "{count} E-Mails ausgewählt"
"items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten"
},
"shortcuts": {
"title": "Tastaturkürzel",
@@ -1358,6 +1368,7 @@
"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",
"primary_identity": "Primär",
"set_as_primary": "Als primär festlegen",
"no_identities": "Keine Identitäten gefunden",
"display": {
"reply_to": "Antwort an:",
@@ -1458,6 +1469,16 @@
"all": "Alle",
"groups": "Gruppen"
},
"shared": {
"title": "Geteilt"
},
"address_books": {
"title": "Verzeichnisse",
"moved": "Kontakt verschoben nach {name}",
"moved_plural": "{count} Kontakte verschoben nach {name}",
"move_failed": "Kontakt konnte nicht verschoben werden",
"address_book": "Verzeichnis"
},
"detail": {
"emails": "E-Mail-Adressen",
"phones": "Telefonnummern",
@@ -1491,11 +1512,11 @@
"personal_interest": "Interesse",
"personal_other": "Sonstiges",
"gender": "Geschlecht",
"gender_M": "Männlich",
"gender_F": "Weiblich",
"gender_O": "Andere",
"gender_N": "Nicht zutreffend",
"gender_U": "Unbekannt",
"gender_masculine": "Männlich",
"gender_feminine": "Weiblich",
"gender_other": "Andere",
"gender_none": "Nicht zutreffend",
"gender_unknown": "Unbekannt",
"calendar": "Kalender",
"calendar_uri": "Kalender-URL",
"scheduling_uri": "Terminplanungs-URL",
@@ -1513,6 +1534,8 @@
"form": {
"create_title": "Neuer Kontakt",
"edit_title": "Kontakt bearbeiten",
"section_address_book": "Verzeichnis",
"select_address_book": "Verzeichnis auswählen...",
"section_identity": "Name & Identität",
"section_work": "Beruf & Organisation",
"prefix": "Anrede",
+25 -7
View File
@@ -195,6 +195,8 @@
"import_email": "Import .eml",
"keyboard_shortcuts": "Keyboard shortcuts (?)",
"email_source": "Email Source",
"draft_banner": "This message is a draft",
"edit_draft": "Edit",
"copy_source": "Copy to clipboard",
"source_copied": "Source copied to clipboard",
"attachments": "Attachments",
@@ -298,7 +300,8 @@
"unstar": "Unstar (s)",
"compose": "Compose (c)",
"previous": "Previous email",
"next": "Next email"
"next": "Next email",
"edit_draft": "Edit draft"
},
"spam": {
"button_title": "Report spam",
@@ -516,6 +519,7 @@
"identity_created": "Identity created successfully",
"identity_updated": "Identity updated successfully",
"identity_deleted": "Identity deleted",
"identity_set_primary": "Primary identity updated",
"identity_create_failed": "Failed to create identity: {error}",
"identity_update_failed": "Failed to update identity: {error}",
"identity_delete_failed": "Failed to delete identity: {error}",
@@ -1298,7 +1302,8 @@
"not_spam": "Not spam",
"color_tag": "Label",
"remove_color": "Remove Label",
"items_selected": "{count} emails selected"
"items_selected": "{count} emails selected",
"edit_draft": "Edit Draft"
},
"shortcuts": {
"title": "Keyboard Shortcuts",
@@ -1363,6 +1368,7 @@
"delete_confirm": "Delete this identity? This cannot be undone.",
"cannot_delete": "This identity cannot be deleted",
"primary_identity": "Primary",
"set_as_primary": "Set as primary",
"no_identities": "No identities found",
"display": {
"reply_to": "Reply-To:",
@@ -1463,6 +1469,16 @@
"all": "All",
"groups": "Groups"
},
"shared": {
"title": "Shared"
},
"address_books": {
"title": "Directories",
"moved": "Contact moved to {name}",
"moved_plural": "{count} contacts moved to {name}",
"move_failed": "Failed to move contact",
"address_book": "Directory"
},
"detail": {
"emails": "Email Addresses",
"phones": "Phone Numbers",
@@ -1505,11 +1521,11 @@
"personal_interest": "Interest",
"personal_other": "Other",
"gender": "Gender",
"gender_M": "Male",
"gender_F": "Female",
"gender_O": "Other",
"gender_N": "Not applicable",
"gender_U": "Unknown",
"gender_masculine": "Male",
"gender_feminine": "Female",
"gender_other": "Other",
"gender_none": "Not applicable",
"gender_unknown": "Unknown",
"calendar": "Calendar",
"calendar_uri": "Calendar URL",
"scheduling_uri": "Scheduling URL",
@@ -1518,6 +1534,8 @@
"form": {
"create_title": "New Contact",
"edit_title": "Edit Contact",
"section_address_book": "Directory",
"select_address_book": "Select a directory...",
"section_identity": "Name & Identity",
"section_work": "Work & Organization",
"prefix": "Prefix",
+31 -8
View File
@@ -191,8 +191,12 @@
"mark_read": "Marcar como leído",
"print": "Imprimir",
"view_source": "Ver código fuente",
"export_email": "Exportar como .eml",
"import_email": "Importar .eml",
"keyboard_shortcuts": "Atajos de teclado (?)",
"email_source": "Código Fuente del Correo",
"draft_banner": "Este mensaje es un borrador",
"edit_draft": "Editar",
"copy_source": "Copiar al portapapeles",
"source_copied": "Código fuente copiado al portapapeles",
"attachments": "Archivos adjuntos",
@@ -294,7 +298,8 @@
"unstar": "Quitar estrella (s)",
"compose": "Redactar (c)",
"previous": "Correo anterior",
"next": "Correo siguiente"
"next": "Correo siguiente",
"edit_draft": "Editar borrador"
},
"spam": {
"button_title": "Reportar spam",
@@ -514,6 +519,7 @@
"identity_created": "Identidad creada exitosamente",
"identity_updated": "Identidad actualizada exitosamente",
"identity_deleted": "Identidad eliminada",
"identity_set_primary": "Identidad principal actualizada",
"identity_create_failed": "Error al crear identidad: {error}",
"identity_update_failed": "Error al actualizar identidad: {error}",
"identity_delete_failed": "Error al eliminar identidad: {error}",
@@ -527,7 +533,10 @@
"templates_exported": "Plantillas exportadas correctamente",
"templates_imported": "{count, plural, one {# plantilla importada} other {# plantillas importadas}}",
"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": {
"today": "Hoy",
@@ -1293,7 +1302,8 @@
"not_spam": "No es spam",
"color_tag": "Etiqueta",
"remove_color": "Eliminar etiqueta",
"items_selected": "{count} correos seleccionados"
"items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador"
},
"shortcuts": {
"title": "Atajos de Teclado",
@@ -1358,6 +1368,7 @@
"delete_confirm": "¿Eliminar esta identidad? Esto no se puede deshacer.",
"cannot_delete": "Esta identidad no se puede eliminar",
"primary_identity": "Principal",
"set_as_primary": "Establecer como principal",
"no_identities": "No se encontraron identidades",
"display": {
"reply_to": "Responder a:",
@@ -1458,6 +1469,16 @@
"all": "Todos",
"groups": "Grupos"
},
"shared": {
"title": "Compartidos"
},
"address_books": {
"title": "Directorios",
"moved": "Contacto movido a {name}",
"moved_plural": "{count} contactos movidos a {name}",
"move_failed": "Error al mover el contacto",
"address_book": "Directorio"
},
"detail": {
"emails": "Direcciones de correo",
"phones": "Números de teléfono",
@@ -1491,11 +1512,11 @@
"personal_interest": "Interés",
"personal_other": "Otro",
"gender": "Género",
"gender_M": "Masculino",
"gender_F": "Femenino",
"gender_O": "Otro",
"gender_N": "No aplicable",
"gender_U": "Desconocido",
"gender_masculine": "Masculino",
"gender_feminine": "Femenino",
"gender_other": "Otro",
"gender_none": "No aplicable",
"gender_unknown": "Desconocido",
"calendar": "Calendario",
"calendar_uri": "URL del calendario",
"scheduling_uri": "URL de programación",
@@ -1513,6 +1534,8 @@
"form": {
"create_title": "Nuevo contacto",
"edit_title": "Editar contacto",
"section_address_book": "Directorio",
"select_address_book": "Seleccionar un directorio...",
"section_identity": "Nombre e identidad",
"section_work": "Trabajo y organización",
"prefix": "Prefijo",
+31 -8
View File
@@ -191,8 +191,12 @@
"mark_read": "Marquer comme lu",
"print": "Imprimer",
"view_source": "Voir la source",
"export_email": "Exporter en .eml",
"import_email": "Importer un .eml",
"keyboard_shortcuts": "Raccourcis clavier (?)",
"email_source": "Source de l'email",
"draft_banner": "Ce message est un brouillon",
"edit_draft": "Modifier",
"copy_source": "Copier dans le presse-papiers",
"source_copied": "Source copiée dans le presse-papiers",
"attachments": "Pièces jointes",
@@ -294,7 +298,8 @@
"unstar": "Ne plus suivre (s)",
"compose": "Rédiger (c)",
"previous": "E-mail précédent",
"next": "E-mail suivant"
"next": "E-mail suivant",
"edit_draft": "Modifier le brouillon"
},
"spam": {
"button_title": "Signaler comme spam",
@@ -514,6 +519,7 @@
"identity_created": "Identité créée avec succès",
"identity_updated": "Identité mise à jour avec succès",
"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_update_failed": "Échec de la mise à jour de l'identité: {error}",
"identity_delete_failed": "Échec de la suppression de l'identité: {error}",
@@ -527,7 +533,10 @@
"templates_exported": "Modèles exportés avec succè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_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": {
"today": "Aujourd'hui",
@@ -1293,7 +1302,8 @@
"not_spam": "Pas un spam",
"color_tag": "É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": {
"title": "Raccourcis clavier",
@@ -1358,6 +1368,7 @@
"delete_confirm": "Supprimer cette identité ? Cette action est irréversible.",
"cannot_delete": "Cette identité ne peut pas être supprimée",
"primary_identity": "Principale",
"set_as_primary": "Définir comme principale",
"no_identities": "Aucune identité trouvée",
"display": {
"reply_to": "Répondre à :",
@@ -1458,6 +1469,16 @@
"all": "Tous",
"groups": "Groupes"
},
"shared": {
"title": "Partagés"
},
"address_books": {
"title": "Répertoires",
"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": "Répertoire"
},
"detail": {
"emails": "Adresses e-mail",
"phones": "Numéros de téléphone",
@@ -1491,11 +1512,11 @@
"personal_interest": "Intérêt",
"personal_other": "Autre",
"gender": "Genre",
"gender_M": "Masculin",
"gender_F": "Féminin",
"gender_O": "Autre",
"gender_N": "Non applicable",
"gender_U": "Inconnu",
"gender_masculine": "Masculin",
"gender_feminine": "Féminin",
"gender_other": "Autre",
"gender_none": "Non applicable",
"gender_unknown": "Inconnu",
"calendar": "Calendrier",
"calendar_uri": "URL du calendrier",
"scheduling_uri": "URL de planification",
@@ -1513,6 +1534,8 @@
"form": {
"create_title": "Nouveau 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_work": "Travail et organisation",
"prefix": "Préfixe",
+31 -8
View File
@@ -191,8 +191,12 @@
"mark_read": "Segna come letto",
"print": "Stampa",
"view_source": "Visualizza sorgente",
"export_email": "Esporta come .eml",
"import_email": "Importa .eml",
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
"email_source": "Sorgente del messaggio",
"draft_banner": "Questo messaggio è una bozza",
"edit_draft": "Modifica",
"copy_source": "Copia negli appunti",
"source_copied": "Sorgente copiata negli appunti",
"attachments": "Allegati",
@@ -294,7 +298,8 @@
"unstar": "Rimuovi stella (s)",
"compose": "Scrivi (c)",
"previous": "Email precedente",
"next": "Email successiva"
"next": "Email successiva",
"edit_draft": "Modifica bozza"
},
"spam": {
"button_title": "Segnala come spam",
@@ -514,6 +519,7 @@
"identity_created": "Identità creata con successo",
"identity_updated": "Identità aggiornata con successo",
"identity_deleted": "Identità eliminata",
"identity_set_primary": "Identità principale aggiornata",
"identity_create_failed": "Impossibile creare l'identità: {error}",
"identity_update_failed": "Impossibile aggiornare l'identità: {error}",
"identity_delete_failed": "Impossibile eliminare l'identità: {error}",
@@ -527,7 +533,10 @@
"templates_exported": "Modelli esportati con successo",
"templates_imported": "{count, plural, one {# modello importato} other {# modelli 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": {
"today": "Oggi",
@@ -1293,7 +1302,8 @@
"not_spam": "Non spam",
"color_tag": "Etichetta",
"remove_color": "Rimuovi etichetta",
"items_selected": "{count} messaggi selezionati"
"items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza"
},
"shortcuts": {
"title": "Scorciatoie da tastiera",
@@ -1358,6 +1368,7 @@
"delete_confirm": "Eliminare questa identità? Questa azione non può essere annullata.",
"cannot_delete": "Questa identità non può essere eliminata",
"primary_identity": "Principale",
"set_as_primary": "Imposta come principale",
"no_identities": "Nessuna identità trovata",
"display": {
"reply_to": "Rispondi a:",
@@ -1458,6 +1469,16 @@
"all": "Tutti",
"groups": "Gruppi"
},
"shared": {
"title": "Condivisi"
},
"address_books": {
"title": "Rubriche",
"moved": "Contatto spostato in {name}",
"moved_plural": "{count} contatti spostati in {name}",
"move_failed": "Impossibile spostare il contatto",
"address_book": "Rubrica"
},
"detail": {
"emails": "Indirizzi email",
"phones": "Numeri di telefono",
@@ -1491,11 +1512,11 @@
"personal_interest": "Interesse",
"personal_other": "Altro",
"gender": "Genere",
"gender_M": "Maschile",
"gender_F": "Femminile",
"gender_O": "Altro",
"gender_N": "Non applicabile",
"gender_U": "Sconosciuto",
"gender_masculine": "Maschile",
"gender_feminine": "Femminile",
"gender_other": "Altro",
"gender_none": "Non applicabile",
"gender_unknown": "Sconosciuto",
"calendar": "Calendario",
"calendar_uri": "URL del calendario",
"scheduling_uri": "URL di pianificazione",
@@ -1513,6 +1534,8 @@
"form": {
"create_title": "Nuovo contatto",
"edit_title": "Modifica contatto",
"section_address_book": "Rubrica",
"select_address_book": "Seleziona una rubrica...",
"section_identity": "Nome e identità",
"section_work": "Lavoro e organizzazione",
"prefix": "Prefisso",
+31 -8
View File
@@ -191,8 +191,12 @@
"mark_read": "既読にする",
"print": "印刷",
"view_source": "ソースを表示",
"export_email": ".emlとしてエクスポート",
"import_email": ".emlをインポート",
"keyboard_shortcuts": "キーボードショートカット (?)",
"email_source": "メールソース",
"draft_banner": "このメッセージは下書きです",
"edit_draft": "編集",
"copy_source": "クリップボードにコピー",
"source_copied": "ソースをクリップボードにコピーしました",
"attachments": "添付ファイル",
@@ -294,7 +298,8 @@
"unstar": "スター解除 (s)",
"compose": "新規作成 (c)",
"previous": "前のメール",
"next": "次のメール"
"next": "次のメール",
"edit_draft": "下書きを編集"
},
"spam": {
"button_title": "迷惑メールを報告",
@@ -514,6 +519,7 @@
"identity_created": "送信者情報を作成しました",
"identity_updated": "送信者情報を更新しました",
"identity_deleted": "送信者情報を削除しました",
"identity_set_primary": "プライマリ送信者情報を更新しました",
"identity_create_failed": "送信者情報の作成に失敗しました: {error}",
"identity_update_failed": "送信者情報の更新に失敗しました: {error}",
"identity_delete_failed": "送信者情報の削除に失敗しました: {error}",
@@ -527,7 +533,10 @@
"templates_exported": "テンプレートをエクスポートしました",
"templates_imported": "{count}件のテンプレートをインポートしました",
"templates_import_errors": "一部のテンプレートをインポートできませんでした",
"templates_import_empty": "ファイルにテンプレートが見つかりません"
"templates_import_empty": "ファイルにテンプレートが見つかりません",
"export_email_error": "メールのエクスポートに失敗しました",
"import_email_success": "メールを正常にインポートしました",
"import_email_error": "メールのインポートに失敗しました"
},
"date": {
"today": "今日",
@@ -1293,7 +1302,8 @@
"not_spam": "迷惑メールでない",
"color_tag": "ラベル",
"remove_color": "ラベルを削除",
"items_selected": "{count}件のメールを選択"
"items_selected": "{count}件のメールを選択",
"edit_draft": "下書きを編集"
},
"shortcuts": {
"title": "キーボードショートカット",
@@ -1358,6 +1368,7 @@
"delete_confirm": "この送信者情報を削除しますか?この操作は元に戻せません。",
"cannot_delete": "この送信者情報は削除できません",
"primary_identity": "プライマリ",
"set_as_primary": "プライマリに設定",
"no_identities": "送信者情報が見つかりません",
"display": {
"reply_to": "返信先:",
@@ -1458,6 +1469,16 @@
"all": "すべて",
"groups": "グループ"
},
"shared": {
"title": "共有"
},
"address_books": {
"title": "ディレクトリ",
"moved": "連絡先を {name} に移動しました",
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
"move_failed": "連絡先の移動に失敗しました",
"address_book": "ディレクトリ"
},
"detail": {
"emails": "メールアドレス",
"phones": "電話番号",
@@ -1491,11 +1512,11 @@
"personal_interest": "興味",
"personal_other": "その他",
"gender": "性別",
"gender_M": "男性",
"gender_F": "女性",
"gender_O": "その他",
"gender_N": "該当なし",
"gender_U": "不明",
"gender_masculine": "男性",
"gender_feminine": "女性",
"gender_other": "その他",
"gender_none": "該当なし",
"gender_unknown": "不明",
"calendar": "カレンダー",
"calendar_uri": "カレンダーURL",
"scheduling_uri": "スケジュールURL",
@@ -1513,6 +1534,8 @@
"form": {
"create_title": "新しい連絡先",
"edit_title": "連絡先を編集",
"section_address_book": "ディレクトリ",
"select_address_book": "ディレクトリを選択...",
"section_identity": "名前と識別情報",
"section_work": "職業と組織",
"prefix": "敬称",
+31 -8
View File
@@ -191,8 +191,12 @@
"mark_read": "Markeren als gelezen",
"print": "Afdrukken",
"view_source": "Bron bekijken",
"export_email": "Exporteren als .eml",
"import_email": ".eml importeren",
"keyboard_shortcuts": "Sneltoetsen (?)",
"email_source": "E-mailbron",
"draft_banner": "Dit bericht is een concept",
"edit_draft": "Bewerken",
"copy_source": "Kopiëren naar klembord",
"source_copied": "Bron gekopieerd naar klembord",
"attachments": "Bijlagen",
@@ -294,7 +298,8 @@
"unstar": "Ster verwijderen (s)",
"compose": "Opstellen (c)",
"previous": "Vorige e-mail",
"next": "Volgende e-mail"
"next": "Volgende e-mail",
"edit_draft": "Concept bewerken"
},
"spam": {
"button_title": "Spam melden",
@@ -514,6 +519,7 @@
"identity_created": "Identiteit succesvol aangemaakt",
"identity_updated": "Identiteit succesvol bijgewerkt",
"identity_deleted": "Identiteit verwijderd",
"identity_set_primary": "Primaire identiteit bijgewerkt",
"identity_create_failed": "Kan identiteit niet aanmaken: {error}",
"identity_update_failed": "Kan identiteit niet bijwerken: {error}",
"identity_delete_failed": "Kan identiteit niet verwijderen: {error}",
@@ -527,7 +533,10 @@
"templates_exported": "Sjablonen succesvol geëxporteerd",
"templates_imported": "{count, plural, one {# sjabloon geïmporteerd} other {# sjablonen 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": {
"today": "Vandaag",
@@ -1293,7 +1302,8 @@
"not_spam": "Geen spam",
"color_tag": "Label",
"remove_color": "Label verwijderen",
"items_selected": "{count} e-mails geselecteerd"
"items_selected": "{count} e-mails geselecteerd",
"edit_draft": "Concept bewerken"
},
"shortcuts": {
"title": "Sneltoetsen",
@@ -1358,6 +1368,7 @@
"delete_confirm": "Deze identiteit verwijderen? Dit kan niet ongedaan worden gemaakt.",
"cannot_delete": "Deze identiteit kan niet worden verwijderd",
"primary_identity": "Primair",
"set_as_primary": "Instellen als primair",
"no_identities": "Geen identiteiten gevonden",
"display": {
"reply_to": "Antwoord naar:",
@@ -1458,6 +1469,16 @@
"all": "Alle",
"groups": "Groepen"
},
"shared": {
"title": "Gedeeld"
},
"address_books": {
"title": "Adresboeken",
"moved": "Contact verplaatst naar {name}",
"moved_plural": "{count} contacten verplaatst naar {name}",
"move_failed": "Verplaatsen van contact mislukt",
"address_book": "Adresboek"
},
"detail": {
"emails": "E-mailadressen",
"phones": "Telefoonnummers",
@@ -1491,11 +1512,11 @@
"personal_interest": "Interesse",
"personal_other": "Overig",
"gender": "Geslacht",
"gender_M": "Man",
"gender_F": "Vrouw",
"gender_O": "Anders",
"gender_N": "Niet van toepassing",
"gender_U": "Onbekend",
"gender_masculine": "Man",
"gender_feminine": "Vrouw",
"gender_other": "Anders",
"gender_none": "Niet van toepassing",
"gender_unknown": "Onbekend",
"calendar": "Kalender",
"calendar_uri": "Kalender-URL",
"scheduling_uri": "Planning-URL",
@@ -1513,6 +1534,8 @@
"form": {
"create_title": "Nieuw contact",
"edit_title": "Contact bewerken",
"section_address_book": "Adresboek",
"select_address_book": "Selecteer een adresboek...",
"section_identity": "Naam en identiteit",
"section_work": "Werk en organisatie",
"prefix": "Voorvoegsel",
+31 -8
View File
@@ -191,8 +191,12 @@
"mark_read": "Marcar como lido",
"print": "Imprimir",
"view_source": "Ver código-fonte",
"export_email": "Exportar como .eml",
"import_email": "Importar .eml",
"keyboard_shortcuts": "Atalhos de teclado (?)",
"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",
"source_copied": "Código-fonte copiado para a área de transferência",
"attachments": "Anexos",
@@ -294,7 +298,8 @@
"unstar": "Remover favorito (s)",
"compose": "Compor (c)",
"previous": "E-mail anterior",
"next": "Próximo e-mail"
"next": "Próximo e-mail",
"edit_draft": "Editar rascunho"
},
"spam": {
"button_title": "Reportar spam",
@@ -514,6 +519,7 @@
"identity_created": "Identidade criada com sucesso",
"identity_updated": "Identidade atualizada com sucesso",
"identity_deleted": "Identidade excluída",
"identity_set_primary": "Identidade principal atualizada",
"identity_create_failed": "Falha ao criar identidade: {error}",
"identity_update_failed": "Falha ao atualizar identidade: {error}",
"identity_delete_failed": "Falha ao excluir identidade: {error}",
@@ -527,7 +533,10 @@
"templates_exported": "Modelos exportados com sucesso",
"templates_imported": "{count, plural, one {# modelo importado} other {# modelos 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": {
"today": "Hoje",
@@ -1293,7 +1302,8 @@
"not_spam": "Não é spam",
"color_tag": "Etiqueta",
"remove_color": "Remover etiqueta",
"items_selected": "{count} e-mails selecionados"
"items_selected": "{count} e-mails selecionados",
"edit_draft": "Editar rascunho"
},
"shortcuts": {
"title": "Atalhos de Teclado",
@@ -1358,6 +1368,7 @@
"delete_confirm": "Excluir esta identidade? Isso não pode ser desfeito.",
"cannot_delete": "Esta identidade não pode ser excluída",
"primary_identity": "Principal",
"set_as_primary": "Definir como principal",
"no_identities": "Nenhuma identidade encontrada",
"display": {
"reply_to": "Responder para:",
@@ -1458,6 +1469,16 @@
"all": "Todos",
"groups": "Grupos"
},
"shared": {
"title": "Compartilhados"
},
"address_books": {
"title": "Diretórios",
"moved": "Contato movido para {name}",
"moved_plural": "{count} contatos movidos para {name}",
"move_failed": "Falha ao mover o contato",
"address_book": "Diretório"
},
"detail": {
"emails": "Endereços de e-mail",
"phones": "Números de telefone",
@@ -1491,11 +1512,11 @@
"personal_interest": "Interesse",
"personal_other": "Outro",
"gender": "Gênero",
"gender_M": "Masculino",
"gender_F": "Feminino",
"gender_O": "Outro",
"gender_N": "Não aplicável",
"gender_U": "Desconhecido",
"gender_masculine": "Masculino",
"gender_feminine": "Feminino",
"gender_other": "Outro",
"gender_none": "Não aplicável",
"gender_unknown": "Desconhecido",
"calendar": "Calendário",
"calendar_uri": "URL do calendário",
"scheduling_uri": "URL de agendamento",
@@ -1513,6 +1534,8 @@
"form": {
"create_title": "Novo contato",
"edit_title": "Editar contato",
"section_address_book": "Diretório",
"select_address_book": "Selecionar um diretório...",
"section_identity": "Nome e identidade",
"section_work": "Trabalho e organização",
"prefix": "Prefixo",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.4.1",
"version": "1.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.4.1",
"version": "1.4.2",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.18",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.4.1",
"version": "1.4.2",
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+34 -5
View File
@@ -52,12 +52,41 @@ function classifyLoginError(error: unknown): string {
return 'generic';
}
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
const identities = [...rawIdentities].sort((a, b) => {
const aMatch = a.email === username ? -1 : 0;
const bMatch = b.email === username ? -1 : 0;
return aMatch - bMatch;
function emailMatchesUsername(email: string, username: string): boolean {
if (email === username) return true;
// Handle local-part login: username "user" should match "user@domain.tld"
if (!username.includes('@') && email.split('@')[0] === username) return true;
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;
useIdentityStore.getState().setIdentities(identities);
return { identities, primaryIdentity };
+94 -15
View File
@@ -92,7 +92,7 @@ export const useCalendarStore = create<CalendarStore>()(
fetchCalendars: async (client) => {
set({ isLoading: true, error: null });
try {
const calendars = await client.getCalendars();
const calendars = await client.getAllCalendars();
const { selectedCalendarIds } = get();
const validIds = calendars.map(c => c.id);
const stillValid = selectedCalendarIds.filter(id => validIds.includes(id));
@@ -110,7 +110,7 @@ export const useCalendarStore = create<CalendarStore>()(
fetchEvents: async (client, start, end) => {
set({ isLoadingEvents: true, error: null });
try {
const events = await client.queryCalendarEvents({
const events = await client.queryAllCalendarEvents({
after: start,
before: end,
});
@@ -124,8 +124,31 @@ export const useCalendarStore = create<CalendarStore>()(
createEvent: async (client, event, sendSchedulingMessages) => {
set({ error: null });
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] }));
if (sendSchedulingMessages && created.participants) {
try {
await client.sendImipInvitation(created);
} catch (e) {
debug.error('Failed to send invitation emails:', e);
}
}
return created;
} catch (error) {
debug.error('Failed to create event:', error);
@@ -137,10 +160,34 @@ export const useCalendarStore = create<CalendarStore>()(
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
set({ error: null });
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) => ({
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) {
debug.error('Failed to update event:', error);
set({ error: 'Failed to update event' });
@@ -157,6 +204,10 @@ export const useCalendarStore = create<CalendarStore>()(
throw new Error('Invalid participant ID');
}
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
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patchKey = `participants/${escapedId}/participationStatus`;
@@ -167,9 +218,10 @@ export const useCalendarStore = create<CalendarStore>()(
patch.replyTo = replyTo;
}
await client.updateCalendarEvent(
eventId,
realId,
patch as unknown as Partial<CalendarEvent>,
true
true,
targetAccountId
);
set((state) => ({
events: state.events.map(e => {
@@ -192,6 +244,10 @@ export const useCalendarStore = create<CalendarStore>()(
importEvents: async (client, events, calendarId) => {
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) {
const src = event as Partial<CalendarEvent>;
try {
@@ -229,7 +285,7 @@ export const useCalendarStore = create<CalendarStore>()(
}
const data: Partial<CalendarEvent> = {
calendarIds: { [calendarId]: true },
calendarIds: { [realCalendarId]: true },
uid: src.uid,
title: src.title,
description: src.description,
@@ -259,7 +315,7 @@ export const useCalendarStore = create<CalendarStore>()(
const v = (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] }));
imported++;
} catch (error) {
@@ -272,7 +328,7 @@ export const useCalendarStore = create<CalendarStore>()(
continue;
}
try {
const all = await client.queryCalendarEvents({});
const all = await client.queryCalendarEvents({}, undefined, undefined, targetAccountId);
const matching = all.filter((e) => e.uid === src.uid);
if (matching.length > 0) {
const existingIds = new Set(storeEvents.map((e) => e.id));
@@ -296,7 +352,21 @@ export const useCalendarStore = create<CalendarStore>()(
deleteEvent: async (client, id, sendSchedulingMessages) => {
set({ error: null });
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) => ({
events: state.events.filter(e => e.id !== id),
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
@@ -314,7 +384,10 @@ export const useCalendarStore = create<CalendarStore>()(
updateCalendar: async (client, calendarId, updates) => {
set({ error: null });
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) => ({
calendars: state.calendars.map(c =>
c.id === calendarId ? { ...c, ...updates } : c
@@ -346,7 +419,10 @@ export const useCalendarStore = create<CalendarStore>()(
removeCalendar: async (client, calendarId) => {
set({ error: null });
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) => ({
calendars: state.calendars.filter(c => c.id !== calendarId),
selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== calendarId),
@@ -362,18 +438,21 @@ export const useCalendarStore = create<CalendarStore>()(
clearCalendarEvents: async (client, calendarId) => {
set({ error: null });
try {
const cal = get().calendars.find(c => c.id === calendarId);
const realCalId = cal?.originalId || calendarId;
const targetAccountId = cal?.accountId;
let totalDeleted = 0;
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
let hasMore = true;
while (hasMore) {
// Query all events and filter client-side by calendarId
// to avoid relying on server-side inCalendars filter support
const allEvents = await client.getCalendarEvents();
const calendarEvents = allEvents.filter(e => e.calendarIds?.[calendarId]);
const allEvents = await client.getCalendarEvents(undefined, targetAccountId);
const calendarEvents = allEvents.filter(e => e.calendarIds?.[realCalId]);
if (calendarEvents.length === 0) break;
const ids = calendarEvents.map(e => e.id);
const { destroyed } = await client.batchDeleteCalendarEvents(ids);
const { destroyed } = await client.batchDeleteCalendarEvents(ids, targetAccountId);
totalDeleted += destroyed.length;
// If we couldn't destroy any events, stop to avoid infinite loop
+92 -12
View File
@@ -80,6 +80,7 @@ interface ContactStore {
clearSelection: () => void;
bulkDeleteContacts: (client: JMAPClient | null, ids: 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>;
}
@@ -101,7 +102,7 @@ export const useContactStore = create<ContactStore>()(
fetchContacts: async (client) => {
set({ isLoading: true, error: null });
try {
const contacts = await client.getContacts();
const contacts = await client.getAllContacts();
set({ contacts, isLoading: false });
} catch (error) {
console.error('Failed to fetch contacts:', error);
@@ -111,7 +112,7 @@ export const useContactStore = create<ContactStore>()(
fetchAddressBooks: async (client) => {
try {
const addressBooks = await client.getAddressBooks();
const addressBooks = await client.getAllAddressBooks();
set({ addressBooks });
} catch (error) {
console.error('Failed to fetch address books:', error);
@@ -122,7 +123,16 @@ export const useContactStore = create<ContactStore>()(
createContact: async (client, contact) => {
set({ isLoading: true, error: null });
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) => ({
contacts: [...state.contacts, created],
isLoading: false,
@@ -137,7 +147,10 @@ export const useContactStore = create<ContactStore>()(
updateContact: async (client, id, updates) => {
set({ error: null });
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) => ({
contacts: state.contacts.map(c =>
c.id === id ? { ...c, ...updates } : c
@@ -153,7 +166,10 @@ export const useContactStore = create<ContactStore>()(
deleteContact: async (client, id) => {
set({ error: null });
try {
await client.deleteContact(id);
const contact = get().contacts.find(c => c.id === id);
const originalId = contact?.originalId || id;
const accountId = contact?.isShared ? contact.accountId : undefined;
await client.deleteContact(originalId, accountId);
set((state) => ({
contacts: state.contacts.filter(c => c.id !== id),
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
@@ -296,7 +312,10 @@ export const useContactStore = create<ContactStore>()(
name: { components: [{ kind: 'given', value: name }], isOrdered: true },
};
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) => ({
contacts: state.contacts.map(c =>
@@ -313,13 +332,15 @@ export const useContactStore = create<ContactStore>()(
const newMembers = { ...group.members };
memberIds.forEach(id => {
const contact = contacts.find(c => c.id === id);
const key = contact?.uid || id;
const key = contact?.uid || contact?.originalId || id;
newMembers[key] = true;
});
const updates: Partial<ContactCard> = { members: newMembers };
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) => ({
contacts: state.contacts.map(c =>
@@ -359,7 +380,9 @@ export const useContactStore = create<ContactStore>()(
const updates: Partial<ContactCard> = { members: newMembers };
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) => ({
contacts: state.contacts.map(c =>
@@ -370,7 +393,10 @@ export const useContactStore = create<ContactStore>()(
deleteGroup: async (client, groupId) => {
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) => ({
contacts: state.contacts.filter(c => c.id !== groupId),
@@ -410,13 +436,16 @@ export const useContactStore = create<ContactStore>()(
bulkDeleteContacts: async (client, ids) => {
set({ error: null });
const { supportsSync } = get();
const { supportsSync, contacts } = get();
const deletedIds = new Set(ids);
if (client && supportsSync) {
for (const id of ids) {
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) {
console.error(`Failed to delete contact ${id}:`, error);
deletedIds.delete(id);
@@ -439,6 +468,57 @@ export const useContactStore = create<ContactStore>()(
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);
set((state) => ({
contacts: state.contacts.map(c =>
c.id === id ? { ...c, addressBookIds: { [targetBookOriginalId]: 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;
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: { [targetBookOriginalId]: true },
};
}),
}));
}
}
},
importContacts: async (client, contacts) => {
const { supportsSync } = get();
let imported = 0;
+3 -3
View File
@@ -61,7 +61,7 @@ interface EmailStore {
loadMoreEmails: (client: JMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
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>;
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: 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 });
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
set({ isLoading: false });
} catch (error) {
+7 -1
View File
@@ -15,6 +15,7 @@ interface IdentityStore {
// Identity state (from server)
identities: Identity[];
selectedIdentityId: string | null;
preferredPrimaryId: string | null;
isLoading: boolean;
error: string | null;
@@ -27,6 +28,7 @@ interface IdentityStore {
updateIdentityLocal: (identityId: string, updates: Partial<Identity>) => void;
removeIdentity: (identityId: string) => void;
selectIdentity: (identityId: string | null) => void;
setPreferredPrimary: (identityId: string | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearIdentities: () => void;
@@ -43,6 +45,7 @@ export const useIdentityStore = create<IdentityStore>()(
(set, get) => ({
identities: [],
selectedIdentityId: null,
preferredPrimaryId: null,
isLoading: false,
error: null,
subAddress: {
@@ -71,6 +74,8 @@ export const useIdentityStore = create<IdentityStore>()(
selectIdentity: (identityId) => set({ selectedIdentityId: identityId }),
setPreferredPrimary: (identityId) => set({ preferredPrimaryId: identityId }),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
@@ -120,7 +125,8 @@ export const useIdentityStore = create<IdentityStore>()(
name: 'identity-storage',
// Only persist sub-addressing data, not identities (they're server-side)
partialize: (state) => ({
subAddress: state.subAddress
subAddress: state.subAddress,
preferredPrimaryId: state.preferredPrimaryId,
}),
}
)
+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 }),
}));