diff --git a/CHANGELOG.md b/CHANGELOG.md index f2932b92..facf803b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 805df4af..57a97cd0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar Built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-1.4.1-green.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.2-green.svg)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue)](https://ghcr.io/bulwarkmail/webmail) diff --git a/VERSION b/VERSION index 347f5833..9df886c4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.1 +1.4.2 diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index ed252007..df64b196 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -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 (
{viewContent} - {isLoadingEvents && calendars.length > 0 && ( + {isLoadingEvents && calendars.length > 0 && events.length === 0 && (
@@ -791,6 +794,7 @@ export default function CalendarPage() { {!isMobile && showEventModal && (
("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 ; + return ; case "edit": if (!selectedContact) return null; return ( @@ -508,10 +543,12 @@ export default function ContactsPage() {
; }) => { 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" /> @@ -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} diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 9dee2a98..56641227 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -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)} diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 5e067970..3595ba2a 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -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(null); const contextMenuRef = useRef(null); + const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]); + const sharedAccountGroups = useMemo(() => { + const shared = calendars.filter(c => c.isShared); + const groups = new Map(); + 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 ( +
+ + + {/* Subscription context menu on right-click */} + {contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => { + const sub = getSubscriptionForCalendar(cal.id); + if (!sub) return null; + return ( +
+ + + {sub.lastRefreshed && ( +
+ {tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })} +
+ )} +
+ ); + })()} + + {/* Color picker popover on right-click */} + {colorPickerId === cal.id && onColorChange && ( +
+

{t("management.change_color")}

+ { + onColorChange(cal.id, c); + setColorPickerId(null); + }} + allowCustom + /> +
+ )} +
+ ); + }; + return (

{t("my_calendars")}

- {calendars.map((cal) => { - const isVisible = selectedCalendarIds.includes(cal.id); - const color = cal.color || "#3b82f6"; - - return ( -
- - - {/* Subscription context menu on right-click */} - {contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => { - const sub = getSubscriptionForCalendar(cal.id); - if (!sub) return null; - return ( -
- - - {sub.lastRefreshed && ( -
- {tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })} -
- )} -
- ); - })()} - - {/* Color picker popover on right-click */} - {colorPickerId === cal.id && onColorChange && ( -
-

{t("management.change_color")}

- { - onColorChange(cal.id, c); - setColorPickerId(null); - }} - allowCustom - /> -
- )} -
- ); - })} + {personalCalendars.map(renderCalendarItem)}
+ + {sharedAccountGroups.map((group) => ( +
+

+ + {group.accountName} +

+
+ {group.calendars.map(renderCalendarItem)} +
+
+ ))}
); } diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 2395a1e0..5780eb41 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -153,7 +153,7 @@ export function CalendarToolbar({ {t("my_calendars")}
- {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({ ); })}
+ {(() => { + const shared = calendars.filter(c => c.isShared); + const groups = new Map(); + 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) => ( +
+

+ {group.accountName} +

+
+ {group.cals.map((cal) => { + const isVisible = selectedCalendarIds.includes(cal.id); + const color = cal.color || "#3b82f6"; + return ( + + ); + })} +
+
+ )); + })()}
)}
diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx new file mode 100644 index 00000000..4e459408 --- /dev/null +++ b/components/calendar/task-list-view.tsx @@ -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 ; + if (priority === 5) return ; + if (priority >= 6 && priority <= 9) return ; + return null; +} + +function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType, 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 ( +
+ +

{t("tasks.no_tasks")}

+
+ ); + } + + return ( +
+
+ {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 ( +
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 */} + + + {/* Content */} +
+
+ + {task.title || t("tasks.no_title")} + + {priorityIcon} +
+ +
+ {dueDateInfo && ( + + + {dueDateInfo.label} + + )} + {cal && ( + + + {cal.name} + + )} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/components/contacts/__tests__/contact-list-item.test.tsx b/components/contacts/__tests__/contact-list-item.test.tsx index 8e1c8574..252e9888 100644 --- a/components/contacts/__tests__/contact-list-item.test.tsx +++ b/components/contacts/__tests__/contact-list-item.test.tsx @@ -30,6 +30,7 @@ describe('ContactListItem', () => { density: 'regular' as const, onClick: vi.fn(), onCheckboxClick: vi.fn(), + selectedContactIds: new Set(), }; it('renders contact name and email', () => { diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 9d69d210..ae05be43 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -351,13 +351,16 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } )} - {contact.gender && (contact.gender.sex || contact.gender.identity) && ( + {contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
- {contact.gender.sex && {t(`detail.gender_${contact.gender.sex.toUpperCase()}`, { defaultValue: contact.gender.sex })}} - {contact.gender.identity && ( - {contact.gender.sex ? " — " : ""}{contact.gender.identity} - )} + {contact.speakToAs.grammaticalGender && {t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}} + {contact.speakToAs.pronouns && (() => { + const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; + return firstPronoun ? ( + {contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun} + ) : null; + })()}
)} diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 657be3a2..ffeb6603 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -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) => Promise; 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(null); const [emailErrors, setEmailErrors] = useState>({}); @@ -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) {
+ {/* Address Book Selector */} + {addressBooks && addressBooks.length > 1 && ( +
+ + + +
+ )} + {/* Name & Identity — full width */}
@@ -737,11 +781,11 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
diff --git a/components/contacts/contact-list-item.tsx b/components/contacts/contact-list-item.tsx index 920c274c..ccb13479 100644 --- a/components/contacts/contact-list-item.tsx +++ b/components/contacts/contact-list-item.tsx @@ -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; 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) => { + // 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 (
{ if (e.ctrlKey || e.metaKey) { e.preventDefault(); diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index a21a85d8..8ce903a8 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -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(); + 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 = {}; + 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 (
{/* Header */} @@ -65,10 +107,31 @@ export function ContactsSidebar({ {t("tabs.all")} - {individuals.length} + {individuals.filter(c => !c.isShared).length} + {/* Personal address books */} + {personalBooks.length > 0 && ( +
+
+ + {t("address_books.title")} + +
+ {personalBooks.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + /> + ))} +
+ )} + {/* Groups section */} {(sortedGroups.length > 0) && (
@@ -82,7 +145,7 @@ export function ContactsSidebar({
{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({
)} + + {/* Shared accounts with address books */} + {sharedBookGroups.map((group) => ( +
+
+ + + {group.accountName} + +
+ {group.books.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + /> + ))} +
+ ))}
); } + +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) => { + 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) => { + 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 ( + + ); +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 01e1bbbd..b3bd2393 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -55,6 +55,7 @@ interface EmailComposerProps { fromEmail?: string; fromName?: string; identityId?: string; + attachments?: Array<{ blobId: string; name: string; type: string; size: number }>; }) => void | Promise; 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, }); } diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index ecf449f2..34c99575 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -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({ )} + {/* Edit Draft - only for single draft emails */} + {!showBatchActions && isDraft && onEditDraft && ( + <> + handleAction(onEditDraft)} + /> + + + )} + {/* Single email actions - Reply, Reply All, Forward */} {!showBatchActions && ( <> diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 5c02d802..095913f2 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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)} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 632df8bb..c3770397 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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(/]*>[\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(null); const [tnefAttachments, setTnefAttachments] = useState([]); + // Embedded message/rfc822 unwrapping (Outlook forward-as-attachment) + const [embeddedEmailHtml, setEmbeddedEmailHtml] = useState(null); + const [embeddedEmailText, setEmbeddedEmailText] = useState(null); + const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState([]); + 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, '$1'); 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(/(https?:\/\/[^\s<]+)/g, '$1'); + 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({ )} + {isDraft && onEditDraft && ( + + )} + {!isDraft && (<> + )}
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */} @@ -3855,6 +4008,29 @@ export function EmailViewer({ )} + {/* Draft Banner */} + {isDraft && ( +
+
+
+ + {t('draft_banner')} +
+ {onEditDraft && ( + + )} +
+
+ )} + { @@ -3944,8 +4120,8 @@ export function EmailViewer({ )} - {/* Quick Reply Section */} - - + )} @@ -4107,6 +4283,17 @@ export function EmailViewer({ {t('previous')} + {isDraft && onEditDraft ? ( + + ) : ( + <> + )} + )}