Merge branch 'dev'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 + but no real text.
|
||||
*/
|
||||
function isHtmlBodyEffectivelyEmpty(html: string): boolean {
|
||||
const textContent = html
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/ /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 + ) — 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
// @vitest-environment node
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { create } from 'zustand';
|
||||
import type { CalendarTask } from '@/lib/jmap/types';
|
||||
|
||||
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
||||
|
||||
interface TaskStore {
|
||||
tasks: CalendarTask[];
|
||||
selectedTaskId: string | null;
|
||||
filter: TaskViewFilter;
|
||||
showCompleted: boolean;
|
||||
setTasks: (tasks: CalendarTask[]) => void;
|
||||
setSelectedTaskId: (id: string | null) => void;
|
||||
setFilter: (filter: TaskViewFilter) => void;
|
||||
setShowCompleted: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export const useTaskStore = create<TaskStore>((set) => ({
|
||||
tasks: [],
|
||||
selectedTaskId: null,
|
||||
filter: 'all',
|
||||
showCompleted: false,
|
||||
setTasks: (tasks) => set({ tasks }),
|
||||
setSelectedTaskId: (id) => set({ selectedTaskId: id }),
|
||||
setFilter: (filter) => set({ filter }),
|
||||
setShowCompleted: (show) => set({ showCompleted: show }),
|
||||
}));
|
||||
Reference in New Issue
Block a user