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)
-[](CHANGELOG.md)
+[](CHANGELOG.md)
[](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