Compare commits

..
55 Commits
Author SHA1 Message Date
Linus Rath 01c8afbfe8 Merge branch 'dev' 2026-03-31 18:34:49 +02:00
Linus Rath be2e0f2b68 chore: update version to 1.4.11 2026-03-31 18:24:25 +02:00
Linus Rath aa40c8be26 fix: harden proxy auth and SSRF defenses 2026-03-31 17:47:09 +02:00
Linus Rath b3d4c9241c i18n: add missing translation keys to all non-English locales 2026-03-31 16:40:36 +02:00
Linus Rath 34dd5122b3 feat: logging to include categories for better log management 2026-03-31 16:31:27 +02:00
Linus Rath dab3606b04 fix: strip server-immutable fields from updates before sending to JMAP #128 2026-03-31 16:15:56 +02:00
Linus Rath 0f7638055c fix: auto-focus input fields in email composer for improved user experience #126 2026-03-31 16:10:14 +02:00
Linus Rath 66fe7fd359 fix: enhance security by blocking plugins with dangerous JS patterns and enforcing strict session secret length 2026-03-31 15:56:32 +02:00
Linus Rath 1b2ee7da3a fix: prevent orphaning of nested mailboxes by restricting deduplication to root-level folders 2026-03-31 15:29:09 +02:00
Linus Rath f6bec519f4 fix: update file feature disabled messages and add stability warnings 2026-03-31 15:18:13 +02:00
Linus Rath 7102add194 fix: add self-signed certificate detection and update status messages for S/MIME signatures 2026-03-31 15:13:48 +02:00
Linus Rath a3d894730b fix: hardened security, CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, PDF iframe sandbox 2026-03-31 15:11:38 +02:00
Linus Rath 79b99ed4f8 Merge branch 'dev' 2026-03-31 00:53:10 +02:00
Linus Rath 68214c3e91 chore: update version to 1.4.10 2026-03-31 00:51:36 +02:00
Linus Rath 5da0e2bdf1 fix: patch critical auth bypass and credential leak vulnerabilities 2026-03-31 00:46:15 +02:00
Linus Rath 92ada0460a Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-31 00:27:45 +02:00
Linus Rath 1cce5c3c8a fix: JSCalendar 2.0 recurrenceRule single-object compatibility 116 2026-03-31 00:11:04 +02:00
Linus RathandGitHub 40fd7799e9 Update security vulnerability reporting contact 2026-03-30 23:47:01 +02:00
Linus Rath aaa283357e feat: implemented plugin configuration UI and calendar event action slot
- Add configSchema support to plugin manifest and ServerPlugin registry
- Add schema-driven admin config page (string, secret, boolean, number, select fields)
- Add per-plugin config storage backend (JSON files + REST API)
- Add calendar-event-actions and admin-plugin-page slot names to plugin store
- Add registerCalendarEventAction and registerAdminPage to plugin API
- Add calendarFormHooks (onCalendarEventFormOpen/Save) to hook bus
- Add PluginSlot in calendar event modal for plugin action buttons
- Style calendar event action buttons to match Bulwark outline button design
- Add Configure link per plugin in admin plugins dashboard
- Add Jitsi Meet plugin with tests (repos/plugins/jitsi-meet)
- Exclude data/admin/plugins from ESLint (deployed plugin bundles)
2026-03-30 21:12:05 +02:00
Linus Rath 066ab1bc32 debug: add diagnostic logging to 3DES S/MIME decrypt path
Log legacy key import success/failure, CMS algorithm OIDs (content
encryption + key transport), legacy key availability during decrypt
attempts, and detailed error messages from both RSA-OAEP and
RSAES-PKCS1-v1_5 decrypt paths.
2026-03-30 19:15:02 +02:00
Linus Rath 92fb9bf132 fix: enhance calendar event handling to distinguish between events and tasks 2026-03-30 18:04:49 +02:00
Linus Rath 4c804d5d2b fix: align tag counts with mailbox folder counts in sidebar 2026-03-30 17:31:26 +02:00
Linus Rath 63b464641b fix: link existing events to target calendar during iCal import instead of skipping #113 2026-03-30 16:47:08 +02:00
Linus Rath 67210c9924 fix: handle 2FA/TOTP session expiry with basic auth #117 2026-03-30 16:37:07 +02:00
Linus Rath 8937777bcd fix: support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys #35 2026-03-30 16:00:27 +02:00
Linus Rath 789d211a71 fix: enhance mailbox handling with logging 2026-03-30 15:46:54 +02:00
Linus Rath c512352f77 fix: add replyToAddresses to email composer 2026-03-30 15:27:50 +02:00
Linus Rath c859230862 feat: add virtual location input #121 2026-03-30 15:19:47 +02:00
Linus Rath 0fb8b81acd fix: deduplicate UIDs during iCal import to prevent mass failures #113 2026-03-30 15:06:28 +02:00
Linus Rath 1eebec292a fix: enhance email loading and deduplication logic in email store #119 2026-03-30 08:32:46 +02:00
Linus Rath 2d983c9853 fix: improve mailbox tree logic #118 2026-03-29 23:47:52 +02:00
Linus Rath a453f7fa67 fix: events disappearing after iCal import/subscription refresh 2026-03-29 18:33:25 +02:00
Linus Rath 4c853f176b feat: add iCal subscription editing and batch event import 2026-03-29 18:27:16 +02:00
Linus Rath 5583d95ecc feat: preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts 2026-03-29 17:25:15 +02:00
Linus Rath 9d1302ef0a feat: skip server-managed vacation script in Sieve script handling 2026-03-29 17:18:33 +02:00
Linus Rath 75b5d31414 feat: implement client-side recurrence expansion for calendar events 2026-03-29 17:12:30 +02:00
Linus Rath 1e90885f3f i18n: add missing translation keys to all non-English locales 2026-03-29 14:20:41 +02:00
Linus Rath 02577c7502 feat: add calendar hover preview settings and functionality 2026-03-29 14:10:28 +02:00
Linus Rath 375c0a7339 feat: add JMAP server endpoint labels and hints in multiple languages 2026-03-29 00:52:42 +01:00
Linus Rath 99d5276cfa feat: add support for custom JMAP server endpoints in login and settings 2026-03-29 00:47:35 +01:00
Linus Rath 60b2cf5911 fix: adjust account list spacing and remove push connection indicator 2026-03-28 23:33:44 +01:00
Linus Rath 8d7cd26fa9 feat: enhance compose functionality with button integration and translations 2026-03-28 22:53:10 +01:00
Linus Rath 1ba8e2fd47 feat: add folder expansion state management and settings navigation 2026-03-28 22:32:01 +01:00
Linus Rath c7c22bd210 fix: account isolation, auto-import signer certs, and no-key error handling #35 2026-03-28 17:32:06 +01:00
Linus Rath 6e0c79ca2c i18n: add 17 missing translation keys to all non-English locales 2026-03-28 15:35:24 +01:00
Linus Rath 9140110435 feat: add options to hide account switcher and show account avatars on navigation rail 2026-03-28 15:20:25 +01:00
Linus Rath e49f7c1a75 fix: improve dark mode handling for media elements and background images 2026-03-28 14:57:04 +01:00
Linus Rath 098127148e fix(tasks): work around Stalwart not returning Task objects via CalendarEvent/query
Stalwart's CalendarEvent/query ignores Task-type objects and does not
support the 'types' filter (returns unsupportedFilter error).  This
caused tasks  both locally created and from external clients like
Thunderbird  to disappear on reload.

Root causes:
- CalendarEvent/query only returns @type:'Event' objects on Stalwart,
  so tasks were invisible to the query endpoint.
- CALENDAR_EVENT_PROPERTIES lacked Task-specific fields (due, progress,
  progressUpdated, priority), causing garbled data when tasks were
  fetched with Event properties (e.g. utcStart:'32548-12-04T15:30:07Z').

Changes:
- Add CALENDAR_TASK_PROPERTIES with Task-specific fields (due, progress,
  progressUpdated, priority).
- Rewrite getCalendarTasks() to first try CalendarEvent/query with
  types:['Task'] filter, then fall back to CalendarEvent/get ids:null
  which returns all calendar objects regardless of @type per JMAP spec.
- Rewrite createCalendarTask() to fetch back created tasks using
  CALENDAR_TASK_PROPERTIES instead of piggybacking on createCalendarEvent.
- Add comprehensive debug logging throughout the task fetch/create flow
  (TaskStore, JMAP client) visible when Debug Mode is enabled.
- Add 'types' field to CalendarEventFilter interface.
2026-03-28 14:17:54 +01:00
Linus Rath 308adf0101 feat: add mail layout settings and update email list components 2026-03-28 01:40:41 +01:00
Linus Rath 574bd7ecef fix: ensure draft editing function is called correctly in EmailViewer component #60 2026-03-28 00:52:28 +01:00
Linus Rath d6b0714b4e fix: match hover action background to selected row state 2026-03-28 00:42:42 +01:00
Linus Rath af8ea8349e fix: use UTC timestamps for timed event rendering 2026-03-28 00:33:38 +01:00
Linus Rath 8eeabfc995 Fix: nested button in theme card 2026-03-28 00:17:15 +01:00
Linus Rath ddb636ed73 feat: add auto-select reply identity feature with settings and localization 2026-03-27 23:58:42 +01:00
Linus Rath 92ff5fe449 feat: enhance calendar event handling with full-day detection and layout adjustments 2026-03-27 22:04:57 +01:00
127 changed files with 7672 additions and 1262 deletions
+8 -1
View File
@@ -9,9 +9,16 @@
# App name displayed in the UI
APP_NAME=Bulwark Webmail
# URL of your JMAP-compatible mail server (required)
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
JMAP_SERVER_URL=https://your-jmap-server.com
# Allow users to specify a custom JMAP server URL on the login form.
# When enabled, a "JMAP Server" field appears on the login page.
# Users can connect to any JMAP-compatible server.
# NOTE: External JMAP servers must include this domain in their CORS
# Access-Control-Allow-Origin header, or browser requests will be blocked.
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
# =============================================================================
# Stalwart Mail Server Integration
# =============================================================================
-7
View File
@@ -1,7 +0,0 @@
# Check for AI attribution in commit message
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
echo " This violates project policy in CLAUDE.md"
echo " Remove 'Co-Authored-By: Claude' and similar references"
exit 1
fi
+62
View File
@@ -1,5 +1,67 @@
# Changelog
## 1.4.11 (2026-03-31)
### Features
- **Logging**: Add logging categories for better log management
### Fixes
- **Security**: Harden security with CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, and PDF iframe sandbox
- **Security**: Harden proxy authentication and SSRF defenses
- **Security**: Block plugins with dangerous JS patterns and enforce strict session secret length validation
- **S/MIME**: Add self-signed certificate detection and update status messages for S/MIME signatures
- **Email**: Auto-focus input fields in email composer for improved user experience (#126)
- **Mailbox**: Prevent orphaning of nested mailboxes by restricting deduplication to root-level folders
- **JMAP**: Strip server-immutable fields from updates before sending to JMAP (#128)
- **Files**: Update file feature disabled messages and add stability warnings
- **i18n**: Add missing translation keys to all non-English locales
## 1.4.10 (2026-03-31)
### Features
- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin
- **Calendar**: Implement client-side recurrence expansion for calendar events
- **Calendar**: Add iCal subscription editing and batch event import
- **Calendar**: Add hover preview settings and functionality
- **Calendar**: Add virtual location input for calendar events (#121)
- **Email**: Add reply-to addresses support in email composer
- **Email**: Add mail layout settings and update email list components
- **Email**: Add auto-select reply identity feature with settings and localization
- **Email**: Enhance compose functionality with button integration and translations
- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts
- **Filters**: Skip server-managed vacation script in Sieve script handling
- **Settings**: Add support for custom JMAP server endpoints in login and settings
- **Settings**: Add folder expansion state management and settings navigation
- **UI**: Add options to hide account switcher and show account avatars on navigation rail
- **i18n**: Add JMAP server endpoint labels and hints in multiple languages
- **i18n**: Add missing translation keys to all non-English locales
### Fixes
- **Security**: Patch critical auth bypass and credential leak vulnerabilities
- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35)
- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35)
- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116)
- **Calendar**: Enhance calendar event handling to distinguish between events and tasks
- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113)
- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113)
- **Calendar**: Fix events disappearing after iCal import/subscription refresh
- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments
- **Calendar**: Use UTC timestamps for timed event rendering
- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query
- **Email**: Enhance email loading and deduplication logic in email store (#119)
- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60)
- **Email**: Match hover action background to selected row state
- **Email**: Align tag counts with mailbox folder counts in sidebar
- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117)
- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118)
- **UI**: Improve dark mode handling for media elements and background images
- **UI**: Adjust account list spacing and remove push connection indicator
- **UI**: Fix nested button in theme card
## 1.4.9 (2026-03-27)
### Features
+1 -1
View File
@@ -195,7 +195,7 @@ webmail/
- **Never commit sensitive data** (API keys, passwords, etc.)
- **Sanitize user input** and email content
- **Block external content** by default for privacy
- Report security vulnerabilities privately
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
## Questions?
+1 -1
View File
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.4.9-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.4.10-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
+1 -1
View File
@@ -1 +1 @@
1.4.9
1.4.11
+39 -10
View File
@@ -39,6 +39,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils";
import { useTaskStore } from "@/stores/task-store";
import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
@@ -66,9 +67,9 @@ export default function CalendarPage() {
isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions,
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore();
@@ -82,6 +83,7 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -337,18 +339,28 @@ export default function CalendarPage() {
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (calendarHoverPreview === 'off') return;
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event
if (showEventModal && editEvent?.id === event.id) return;
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, [isMobile, showEventModal, editEvent]);
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
hoverTimerRef.current = setTimeout(() => {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, ms);
} else {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
const handleHoverLeave = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
hoverTimerRef.current = setTimeout(() => {
setDetailEvent(null);
setDetailAnchorRect(null);
}, 200);
}, 300);
}, []);
const handleEditFromDetail = useCallback(() => {
@@ -385,12 +397,16 @@ export default function CalendarPage() {
}
}, [client, fetchEvents]);
const focusCalendarOnEvent = useCallback((event: Pick<CalendarEvent, "start">) => {
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
if (!event.start) {
return;
}
const eventDate = parseISO(event.start);
const eventDate = getEventStartDate({
start: event.start,
utcStart: event.utcStart ?? null,
showWithoutTime: event.showWithoutTime ?? false,
});
if (Number.isNaN(eventDate.getTime())) {
return;
}
@@ -749,7 +765,7 @@ export default function CalendarPage() {
return;
}
debug.log('Calendar visibility summary', {
debug.log('calendar', 'Calendar visibility summary', {
totalEvents: events.length,
visibleEvents: visibleEvents.length,
hiddenEvents: hiddenEvents.length,
@@ -873,7 +889,7 @@ export default function CalendarPage() {
})();
return (
<div className="relative flex-1 flex flex-col overflow-hidden">
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{viewContent}
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
@@ -933,6 +949,7 @@ export default function CalendarPage() {
updateCalendar(client, calendarId, { color });
} : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client}
/>
</div>
@@ -1099,6 +1116,18 @@ export default function CalendarPage() {
/>
)}
{editingSubscription && client && (() => {
const sub = icalSubscriptions.find(s => s.id === editingSubscription);
if (!sub) return null;
return (
<ICalSubscriptionModal
client={client}
editSubscription={sub}
onClose={() => setEditingSubscription(null)}
/>
);
})()}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
+18 -1
View File
@@ -17,15 +17,18 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsMobile } from "@/hooks/use-media-query";
import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AlertTriangle } from "lucide-react";
export default function FilesPage() {
const router = useRouter();
const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -393,11 +396,24 @@ export default function FilesPage() {
)}
<div className="flex-1 min-h-0">
{supportsFiles === false ? (
{!filesEnabled ? (
<div className="flex items-center justify-center h-full">
<div className="max-w-lg text-center space-y-3 px-4">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">{t("disabled_title")}</p>
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
</div>
</div>
) : supportsFiles === false ? (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div>
) : (
<div className="flex flex-col flex-1 min-h-0">
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
</div>
<FileBrowser
currentPath={currentPath}
resources={resources}
@@ -442,6 +458,7 @@ export default function FilesPage() {
onToggleDetails={handleToggleDetails}
detailResource={detailResource}
/>
</div>
)}
</div>
</div>
+46 -6
View File
@@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.9";
const APP_VERSION = "1.4.10";
const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" },
@@ -32,13 +32,14 @@ export default function LoginPage() {
const isAddAccountMode = searchParams.get("mode") === "add-account";
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = useConfig();
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [jmapEndpoint, setJmapEndpoint] = useState("");
const [totpCode, setTotpCode] = useState("");
const [showTotpField, setShowTotpField] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
@@ -73,6 +74,12 @@ export default function LoginPage() {
}
}, [appName, serverUrl]);
useEffect(() => {
if (serverUrl && !jmapEndpoint) {
setJmapEndpoint(serverUrl);
}
}, [serverUrl, jmapEndpoint]);
useEffect(() => {
try {
if (sessionStorage.getItem('session_expired') === 'true') {
@@ -91,6 +98,14 @@ export default function LoginPage() {
prevError.current = error;
}, [error]);
// Auto-show and focus TOTP field when server requires it
useEffect(() => {
if (error === 'totp_required') {
setShowTotpField(true);
setTimeout(() => totpInputRef.current?.focus(), 100);
}
}, [error]);
useEffect(() => {
if (!serverUrl) return;
const saved = localStorage.getItem("webmail_usernames");
@@ -264,7 +279,7 @@ export default function LoginPage() {
);
}
if (!serverUrl && !demoMode) {
if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-md mx-auto px-4 text-center">
@@ -362,7 +377,7 @@ export default function LoginPage() {
sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", serverUrl!);
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
if (isAddAccountMode) {
sessionStorage.setItem("oauth_add_account_mode", "true");
}
@@ -382,8 +397,9 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
const success = await login(
serverUrl,
effectiveServerUrl,
formData.username,
formData.password,
totpCode || undefined,
@@ -749,6 +765,27 @@ export default function LoginPage() {
/* Login Form */
<form onSubmit={handleSubmit} className="space-y-5">
<fieldset disabled={isLoading} className="space-y-4">
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
{allowCustomJmapEndpoint && (
<div className="space-y-1.5">
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
{t("jmap_endpoint_label")}
</label>
<Input
id="jmap-endpoint"
type="url"
value={jmapEndpoint}
onChange={(e) => setJmapEndpoint(e.target.value)}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("jmap_endpoint_placeholder")}
required
/>
<p className="text-[11px] text-muted-foreground leading-snug">
{t("jmap_endpoint_cors_hint")}
</p>
</div>
)}
{/* Username field */}
<div className="space-y-1.5">
<label htmlFor="username" className="block text-sm font-medium text-foreground">
@@ -861,7 +898,10 @@ export default function LoginPage() {
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
className={cn(
"h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest",
error === 'totp_required' && "border-primary ring-2 ring-primary/30"
)}
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
aria-label={t("totp_label")}
+54 -35
View File
@@ -30,6 +30,7 @@ import {
ComposerErrorFallback,
} from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
@@ -53,6 +54,7 @@ export default function Home() {
const t = useTranslations();
const tCommon = useTranslations('common');
const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout);
const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState("");
@@ -350,13 +352,13 @@ export default function Home() {
if (pushEnabled) {
setPushConnected(true);
debug.log('[Push] Push notifications successfully enabled');
debug.log('push', '[Push] Push notifications successfully enabled');
} else {
debug.log('[Push] Push notifications not available on this server');
debug.log('push', '[Push] Push notifications not available on this server');
}
} catch (error) {
// Push notifications are optional - don't break the app if they fail
debug.log('[Push] Failed to setup push notifications:', error);
debug.log('push', '[Push] Failed to setup push notifications:', error);
}
} catch (error) {
console.error('Error loading email data:', error);
@@ -390,7 +392,7 @@ export default function Home() {
useEffect(() => {
// Clear any existing timeout when email changes
if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Clearing previous timeout');
debug.log('email', '[Mark as Read] Clearing previous timeout');
clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null;
}
@@ -402,20 +404,20 @@ export default function Home() {
// Get current setting value
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
debug.log('email', '[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
if (markAsReadDelay === -1) {
// Never mark as read automatically
debug.log('[Mark as Read] Never mode - email will stay unread');
debug.log('email', '[Mark as Read] Never mode - email will stay unread');
} else if (markAsReadDelay === 0) {
// Mark as read instantly
debug.log('[Mark as Read] Instant mode - marking as read now');
debug.log('email', '[Mark as Read] Instant mode - marking as read now');
markAsRead(client, selectedEmail.id, true);
} else {
// Mark as read after delay
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
debug.log('email', '[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
markAsReadTimeoutRef.current = setTimeout(() => {
debug.log('[Mark as Read] Timeout fired - marking as read now');
debug.log('email', '[Mark as Read] Timeout fired - marking as read now');
markAsRead(client, selectedEmail.id, true);
markAsReadTimeoutRef.current = null;
}, markAsReadDelay);
@@ -424,7 +426,7 @@ export default function Home() {
// Cleanup on unmount or when dependencies change
return () => {
if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Cleanup - clearing timeout');
debug.log('email', '[Mark as Read] Cleanup - clearing timeout');
clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null;
}
@@ -439,7 +441,7 @@ export default function Home() {
if (emailNotificationsEnabled && emailNotificationSound) {
playNotificationSound(notificationSoundChoice);
}
debug.log('New email received:', newEmailNotification.subject);
debug.log('email', 'New email received:', newEmailNotification.subject);
clearNewEmailNotification();
}
}, [newEmailNotification, clearNewEmailNotification]);
@@ -519,7 +521,10 @@ export default function Home() {
const handleEditDraft = async (email?: Email) => {
if (!client) return;
let draft = email || selectedEmail;
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
? email
: selectedEmail;
let draft = draftCandidate;
if (!draft) return;
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
@@ -967,6 +972,10 @@ export default function Home() {
// Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const isFocusedMailLayout = mailLayout === 'focus';
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
// Handle email selection with mobile view switching
const handleEmailSelect = async (email: { id: string }) => {
@@ -1017,6 +1026,9 @@ export default function Home() {
setConversationEmails([]);
}
selectEmail(null);
if (isTablet) {
setTabletListVisible(true);
}
setActiveView("list");
};
@@ -1199,10 +1211,9 @@ export default function Home() {
// Tablet/Desktop: fixed width with collapse animation
"md:flex-shrink-0 md:shadow-sm",
!isResizing && "transition-all duration-200 ease-out",
// Tablet: collapse when email selected
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
)}
style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined}
style={!isMobile && !shouldCollapseListPane ? { width: emailListWidth } : undefined}
>
{/* Mobile Header for List View */}
<MobileHeader
@@ -1487,24 +1498,27 @@ export default function Home() {
</ErrorBoundary>
</div>
{/* Floating Compose Button (mobile) */}
{isMobile && (
<Button
onClick={() => {
setComposerMode('compose');
setShowComposer(true);
setActiveView('viewer');
}}
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
aria-label={t('sidebar.compose')}
>
<PenSquare className="h-6 w-6" />
</Button>
)}
{/* Floating Compose Button */}
<Button
onClick={() => {
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
className={cn(
"absolute z-40 rounded-full shadow-lg",
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
)}
aria-label={t('sidebar.compose')}
title={t('sidebar.compose_hint')}
data-tour="compose-button"
>
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
</Button>
</div>
{/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && (
{!isMobile && !isTablet && !isFocusedMailLayout && (
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
@@ -1521,7 +1535,8 @@ export default function Home() {
"max-md:fixed max-md:inset-0 max-md:z-30",
isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: relative
"md:relative"
"md:relative",
shouldHideViewerPane && "md:hidden"
)}
>
{/* Inline Composer - shown in viewer pane */}
@@ -1537,8 +1552,10 @@ export default function Home() {
mode={pendingDraft?.mode ?? composerMode}
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to,
cc: selectedEmail.cc,
bcc: selectedEmail.bcc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
@@ -1648,14 +1665,15 @@ export default function Home() {
}}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
onBack={() => {
setTabletListVisible(true);
selectEmail(null);
}}
onBack={handleMobileBack}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
}}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
@@ -1709,6 +1727,7 @@ export default function Home() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
<TotpReauthDialog />
</div>
</DragDropProvider>
);
+2 -7
View File
@@ -24,6 +24,7 @@ import {
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store';
@@ -78,13 +79,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
}, [pathname]);
function getJmapHeaders(): Record<string, string> {
const client = useAuthStore.getState().client;
if (!client) return {};
return {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
return getActiveAccountSlotHeaders();
}
async function checkAuth() {
+285
View File
@@ -0,0 +1,285 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
import Link from 'next/link';
interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
label: string;
description?: string;
required?: boolean;
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
}
interface PluginConfig {
[key: string]: unknown;
}
interface PluginInfo {
id: string;
name: string;
description: string;
version: string;
author: string;
type: string;
permissions: string[];
enabled: boolean;
configSchema?: Record<string, ConfigField>;
}
export default function PluginConfigPage() {
const params = useParams();
const pluginId = params.id as string;
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
const [config, setConfig] = useState<PluginConfig>({});
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginId]);
// Initialize form values from config + schema defaults when data loads
useEffect(() => {
if (!plugin?.configSchema) return;
const initial: Record<string, string> = {};
for (const [key, field] of Object.entries(plugin.configSchema)) {
const stored = config[key];
if (stored !== undefined && stored !== null) {
initial[key] = String(stored);
} else if (field.default !== undefined) {
initial[key] = String(field.default);
} else {
initial[key] = '';
}
}
setFormValues(initial);
}, [plugin, config]);
async function fetchData() {
setLoading(true);
try {
const [pluginsRes, configRes] = await Promise.all([
fetch('/api/admin/plugins'),
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
]);
if (pluginsRes.ok) {
const plugins: PluginInfo[] = await pluginsRes.json();
setPlugin(plugins.find(p => p.id === pluginId) || null);
}
if (configRes.ok) {
setConfig(await configRes.json());
}
} finally {
setLoading(false);
}
}
async function handleSaveAll() {
if (!plugin?.configSchema) return;
setSaving(true);
setMessage(null);
// Validate required fields
for (const [key, field] of Object.entries(plugin.configSchema)) {
if (field.required && !formValues[key]?.trim()) {
setMessage({ type: 'error', text: `"${field.label}" is required` });
setSaving(false);
return;
}
}
try {
// Save each changed field
let hasError = false;
for (const [key, field] of Object.entries(plugin.configSchema)) {
const newVal = formValues[key] ?? '';
const oldVal = config[key] !== undefined ? String(config[key]) : '';
// Skip unchanged fields (and skip secret fields that show as empty when they have a stored value)
if (newVal === oldVal) continue;
if (field.type === 'secret' && !newVal && config[key]) continue;
// Convert types
let value: unknown = newVal;
if (field.type === 'boolean') value = newVal === 'true';
else if (field.type === 'number') value = Number(newVal);
// Delete if clearing a non-required field
if (!newVal && !field.required) {
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
} else {
hasError = true;
}
continue;
}
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
if (res.ok) {
setConfig(prev => ({ ...prev, [key]: value }));
} else {
hasError = true;
}
}
setMessage(hasError
? { type: 'error', text: 'Some settings failed to save' }
: { type: 'success', text: 'Configuration saved' }
);
} catch {
setMessage({ type: 'error', text: 'Failed to save configuration' });
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Loading...
</div>
);
}
if (!plugin) {
return (
<div className="space-y-4">
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4" /> Back to Plugins
</Link>
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
</div>
);
}
const schema = plugin.configSchema;
const hasSchema = schema && Object.keys(schema).length > 0;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<Puzzle className="w-5 h-5" />
{plugin.name} Configuration
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
v{plugin.version} by {plugin.author}
</p>
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{hasSchema ? (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Settings</h2>
</div>
<div className="p-4 space-y-5">
{Object.entries(schema).map(([key, field]) => (
<div key={key}>
<label className="text-sm font-medium text-foreground block mb-1">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
</label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
)}
{field.type === 'boolean' ? (
<select
value={formValues[key] ?? String(field.default ?? 'false')}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
) : field.type === 'select' && field.options ? (
<select
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value=""> Select </option>
{field.options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : field.type === 'secret' ? (
<div className="relative">
<input
type={revealSecrets[key] ? 'text' : 'password'}
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
/>
<button
type="button"
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
>
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
) : (
<input
type={field.type === 'number' ? 'number' : 'text'}
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={field.placeholder || ''}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
)}
</div>
))}
<button
onClick={handleSaveAll}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
Save Configuration
</button>
</div>
</div>
) : (
<div className="border border-border rounded-lg p-8 text-center">
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
</div>
)}
</div>
);
}
+9 -1
View File
@@ -1,7 +1,8 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen } from 'lucide-react';
import Link from 'next/link';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
@@ -396,6 +397,13 @@ export default function AdminPluginsPage() {
</div>
<div className="flex items-center gap-2">
<Link
href={`/admin/plugins/${plugin.id}`}
title="Configure"
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Settings className="w-4 h-4" />
</Link>
<button
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
+3
View File
@@ -19,6 +19,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
};
const RESTRICTABLE_SETTINGS = [
@@ -28,10 +29,12 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
+8
View File
@@ -112,6 +112,14 @@ export default function AdminSettingsPage() {
<SettingsSection title="General">
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
{!!currentValue('allowCustomJmapEndpoint') && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
</p>
</div>
)}
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
+13 -3
View File
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
httpOnly: true,
@@ -69,10 +70,19 @@ export async function POST(request: NextRequest) {
}
// If session cookie exists, update it with the new password
const cookieStore = await cookies();
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
}
if (creds.authHeader.startsWith('Basic ')) {
setStalwartAuthContextInStore(cookieStore, creds.slot, {
serverUrl: creds.serverUrl,
username: creds.username,
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
});
}
return NextResponse.json({ ok: true });
+20
View File
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
const code = await jsFile.async('string');
// Block plugins with dangerous JS patterns
const DANGEROUS_JS_PATTERNS = [
{ pattern: /\beval\s*\(/g, label: 'eval()' },
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
{ pattern: /document\.cookie/g, label: 'document.cookie' },
{ pattern: /document\.write/g, label: 'document.write' },
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
];
const dangerousFindings: string[] = [];
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
if (pattern.test(code)) dangerousFindings.push(label);
pattern.lastIndex = 0;
}
if (dangerousFindings.length > 0) {
return NextResponse.json(
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
{ status: 400 },
);
}
// Validate permissions
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlugin } from '@/lib/admin/plugin-registry';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session';
/**
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
*
* Returns the full config object for admin-configured plugin settings.
* This endpoint is accessible from the client-side plugin API.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
const plugin = await getPlugin(id);
if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
const config = await getPluginConfig(id);
return NextResponse.json(config, {
headers: { 'Cache-Control': 'no-store' },
});
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PUT /api/admin/plugins/[id]/config — Set a config key
*
* Body: { key: string, value: unknown }
* Requires admin authentication (checked via admin session).
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const { id } = await params;
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
const plugin = await getPlugin(id);
if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
let body: { key?: string; value?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
if (!body.key || typeof body.key !== 'string') {
return NextResponse.json({ error: 'key is required and must be a string' }, { status: 400 });
}
// Validate key format (alphanumeric, hyphens, underscores, dots)
if (!/^[a-zA-Z0-9._-]+$/.test(body.key)) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
await setPluginConfig(id, body.key, body.value);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/plugins/[id]/config — Delete a config key
*
* Body: { key: string }
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const { id } = await params;
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
let body: { key?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
if (!body.key || typeof body.key !== 'string') {
return NextResponse.json({ error: 'key is required' }, { status: 400 });
}
await deletePluginConfigKey(id, body.key);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+12 -3
View File
@@ -139,12 +139,18 @@ export async function POST(request: NextRequest) {
}
const code = await entryFile.async('string');
// Security warnings (logged but not blocking for admin)
// Security: block plugins containing dangerous JS patterns
const warnings: string[] = [];
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
if (pattern.test(code)) warnings.push(`Contains ${label}`);
pattern.lastIndex = 0;
}
if (warnings.length > 0) {
return NextResponse.json(
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
{ status: 400 },
);
}
const now = new Date().toISOString();
const plugin: ServerPlugin = {
@@ -157,14 +163,17 @@ export async function POST(request: NextRequest) {
permissions: (manifest.permissions as string[]) || [],
entrypoint: manifest.entrypoint as string,
enabled: true,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
installedAt: now,
updatedAt: now,
};
await savePlugin(plugin, code);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
return NextResponse.json({ plugin, warnings });
return NextResponse.json({ plugin });
} catch (error) {
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+76 -1
View File
@@ -4,6 +4,11 @@ import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import {
clearStalwartAuthContextInStore,
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
...getCookieOptions(),
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot);
const token = encryptSession(serverUrl, username, password);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -56,9 +72,66 @@ export async function GET(request: NextRequest) {
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
const { serverUrl, username } = credentials;
return NextResponse.json(
{ serverUrl, username },
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
);
} catch (error) {
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PUT — retrieve full credentials (including password) for session restoration.
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
* browser fetch() requests succeed. Non-browser clients cannot forge these.
*/
export async function PUT(request: NextRequest) {
try {
// Require all Sec-Fetch-* headers to match a same-origin fetch() call.
// Browsers set these automatically and they cannot be overridden by JS.
const secFetchSite = request.headers.get('sec-fetch-site');
const secFetchMode = request.headers.get('sec-fetch-mode');
const secFetchDest = request.headers.get('sec-fetch-dest');
if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const slot = getSlot(request);
const cookieName = sessionCookieName(slot);
const cookieStore = await cookies();
const token = cookieStore.get(cookieName)?.value;
if (!token) {
return NextResponse.json({ error: 'No session' }, { status: 401 });
}
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
return NextResponse.json(credentials, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
@@ -77,10 +150,12 @@ export async function DELETE(request: NextRequest) {
// Delete all session cookies (slots 0-4)
for (let i = 0; i <= 4; i++) {
cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i);
}
} else {
const slot = getSlot(request);
cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
}
return NextResponse.json({ ok: true });
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
return bodySlot;
}
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
}
export async function POST(request: NextRequest) {
try {
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !authHeader) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Failed to store Stalwart auth context', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+188
View File
@@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
/**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
*
* This allows 2FA users who log in with basic auth + TOTP to upgrade
* to token-based auth, avoiding session expiry when the TOTP rotates.
*
* Tries three strategies:
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
* 2. ROPC grant without client_id
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
*/
async function tryTokenRequest(
tokenEndpoint: string,
params: URLSearchParams,
extraHeaders?: Record<string, string>,
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
try {
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers,
body: params.toString(),
});
if (!response.ok) {
const errorText = await response.text();
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
}
const tokens = await response.json();
if (!tokens.access_token) {
return { ok: false, status: 502, error: 'Response missing access_token' };
}
return { ok: true, tokens };
} catch (err) {
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
}
}
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
// 1. Try OAuth discovery
const metadata = await discoverOAuth(serverUrl);
if (metadata?.token_endpoint) return metadata.token_endpoint;
// 2. Try common Stalwart token endpoint paths directly
const candidates = [
`${serverUrl}/auth/token`,
`${serverUrl}/api/oauth/token`,
];
for (const url of candidates) {
try {
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
if (probe.status !== 404 && probe.status !== 405) {
return url;
}
} catch {
// Network error — endpoint not reachable
}
}
return null;
}
export async function POST(request: NextRequest) {
try {
const { serverUrl, username, password, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !password) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
}
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// Use the server-side JMAP_SERVER_URL if set (may differ from the
// public URL the browser uses, e.g. inside Docker).
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
if (!tokenEndpoint) {
// Also try with the client-provided URL in case the internal one differs
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
if (!clientEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
}
return await attemptAllStrategies(clientEndpoint, username, password, slot);
}
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
} catch (error) {
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
async function attemptAllStrategies(
tokenEndpoint: string,
username: string,
password: string,
slot: number,
): Promise<NextResponse> {
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
const clientId = process.env.OAUTH_CLIENT_ID;
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = [];
// Strategy 1: ROPC with client_id (if configured)
if (clientId) {
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
if (clientSecret) params.set('client_secret', clientSecret);
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
}
// Strategy 2: ROPC without client_id
{
const params = new URLSearchParams({ grant_type: 'password', username, password });
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
}
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
{
const params = new URLSearchParams({ grant_type: 'password' });
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (Basic Auth header)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'Basic Auth header', error: result.error });
}
// Strategy 4: client_credentials with Basic Auth (last resort)
{
const params = new URLSearchParams({ grant_type: 'client_credentials' });
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
}
logger.warn('TOTP token exchange: all strategies failed', { attempts });
return NextResponse.json({
error: 'token_exchange_failed',
detail: 'All token exchange strategies failed',
attempts,
}, { status: 502 });
}
async function storeAndRespond(
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
slot: number,
): Promise<NextResponse> {
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies();
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in || 3600,
has_refresh_token: !!tokens.refresh_token,
});
}
+1
View File
@@ -47,6 +47,7 @@ export async function GET() {
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
+86 -21
View File
@@ -1,9 +1,39 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { NextRequest, NextResponse } from 'next/server';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000;
function isValidExternalUrl(urlString: string): boolean {
const blockedAddressRanges = new BlockList();
blockedAddressRanges.addAddress('0.0.0.0');
blockedAddressRanges.addAddress('127.0.0.1');
blockedAddressRanges.addSubnet('10.0.0.0', 8);
blockedAddressRanges.addSubnet('172.16.0.0', 12);
blockedAddressRanges.addSubnet('192.168.0.0', 16);
blockedAddressRanges.addSubnet('169.254.0.0', 16);
blockedAddressRanges.addAddress('::', 'ipv6');
blockedAddressRanges.addAddress('::1', 'ipv6');
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function isBlockedIpAddress(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized);
if (family === 4) {
return blockedAddressRanges.check(normalized, 'ipv4');
}
if (family === 6) {
return blockedAddressRanges.check(normalized, 'ipv6');
}
return false;
}
async function isValidExternalUrl(urlString: string): Promise<boolean> {
let url: URL;
try {
url = new URL(urlString);
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
return false;
}
const hostname = url.hostname.toLowerCase();
const hostname = normalizeHostname(url.hostname);
// Block private/internal hostnames
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.endsWith('.localhost') ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
hostname.endsWith('.localdomain')
) {
return false;
}
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
return false;
}
return true;
if (isBlockedIpAddress(hostname)) {
return false;
}
if (isIP(hostname)) {
return true;
}
try {
const records = await lookup(hostname, { all: true, verbatim: true });
if (records.length === 0) {
return false;
}
return records.every((record) => !isBlockedIpAddress(record.address));
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
if (!isValidExternalUrl(url)) {
if (!(await isValidExternalUrl(url))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
@@ -64,20 +106,43 @@ export async function POST(request: NextRequest) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'follow',
});
const MAX_REDIRECTS = 5;
let currentUrl = url;
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!(await isValidExternalUrl(currentUrl))) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
}
response = await fetch(currentUrl, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'manual',
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
}
// Resolve relative redirects
currentUrl = new URL(location, currentUrl).toString();
continue;
}
break;
}
clearTimeout(timeout);
if (!response.ok) {
if (!response || !response.ok) {
return NextResponse.json(
{ error: `Remote server returned ${response.status}` },
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
{ status: 502 }
);
}
+2 -7
View File
@@ -53,16 +53,14 @@ function isEnabled(): boolean {
/**
* Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4).
* Returns true if any slot matches OR if no session cookies exist at all.
* Returns true only if a matching session cookie is found.
*/
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies();
let hasAnyCookie = false;
for (let slot = 0; slot <= 4; slot++) {
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (!token) continue;
hasAnyCookie = true;
const session = decryptSession(token);
if (session && session.username === username && session.serverUrl === serverUrl) {
@@ -70,10 +68,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
}
}
// No cookies at all → can't verify, allow (same-origin protection applies)
if (!hasAnyCookie) return true;
// Cookies exist but none matched → identity mismatch
// No matching session found (or no cookies at all)reject
return false;
}
+32 -8
View File
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
function normalizeDavRelativePath(rawPath: string): string {
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
const segments = sanitized.split('/').filter(Boolean);
return segments.map((segment) => {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
throw new Error('Invalid WebDAV path encoding');
}
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
throw new Error('Invalid WebDAV path segment');
}
return encodeURIComponent(decoded);
}).join('/');
}
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
const relativePath = normalizeDavRelativePath(rawPath);
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
}
/**
* POST /api/webdav
* Proxies WebDAV requests to the Stalwart server.
@@ -29,11 +55,8 @@ export async function POST(request: NextRequest) {
}
const davPath = request.headers.get('X-WebDAV-Path') || '/';
const cleanPath = davPath.replace(/^\/+/, '');
const baseUrl = creds.apiUrl.replace(/\/$/, '');
const targetUrl = cleanPath
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
// Build headers for the upstream request
const upstreamHeaders: Record<string, string> = {
@@ -50,10 +73,7 @@ export async function POST(request: NextRequest) {
// For MOVE/COPY, construct the full Destination URL from the relative path
const destination = request.headers.get('X-WebDAV-Destination');
if (destination) {
const cleanDest = destination.replace(/^\/+/, '');
upstreamHeaders['Destination'] = cleanDest
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
}
const overwrite = request.headers.get('Overwrite');
@@ -104,6 +124,10 @@ export async function POST(request: NextRequest) {
status: response.status,
});
} catch (error) {
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
+5 -5
View File
@@ -2,11 +2,11 @@
import { useMemo, useRef, useEffect, useCallback } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { format, parseISO, isToday, isTomorrow, startOfDay } from "date-fns";
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
import { MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card";
import { getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -49,7 +49,7 @@ export function CalendarAgendaView({
const grouped = useMemo(() => {
const sorted = [...events].sort((a, b) =>
parseISO(a.start).getTime() - parseISO(b.start).getTime()
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
);
const groups: DayGroup[] = [];
@@ -144,9 +144,9 @@ export function CalendarAgendaView({
const calId = getPrimaryCalendarId(ev);
const calendar = calId ? calendarMap.get(calId) : undefined;
const color = getEventColor(ev, calendar);
const start = parseISO(ev.start);
const start = getEventStartDate(ev);
const durMin = parseDuration(ev.duration);
const end = new Date(start.getTime() + durMin * 60000);
const end = getEventEndDate(ev);
const locationName = ev.locations
? Object.values(ev.locations)[0]?.name
: null;
+9 -11
View File
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
import { format, isSameDay, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
@@ -66,7 +66,7 @@ export function CalendarDayView({
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
if (!spansThisDay) return;
if (ev.showWithoutTime) allDay.push(ev);
if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
else timed.push(ev);
} catch { /* skip invalid dates */ }
});
@@ -127,10 +127,10 @@ export function CalendarDayView({
return format(new Date(2000, 0, 1, h), "HH:mm");
};
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile
@@ -200,7 +200,7 @@ export function CalendarDayView({
</div>
)}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
{HOURS.map((h) => (
@@ -241,11 +241,9 @@ export function CalendarDayView({
/>
))}
{layouted.map(({ event: ev, column, totalColumns }) => {
const start = parseISO(ev.start);
const startMin = start.getHours() * 60 + start.getMinutes();
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
+13 -1
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Globe, ListTodo, RefreshCw, Share2, Trash2 } from "lucide-react";
import { Globe, ListTodo, Pencil, 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";
@@ -18,6 +18,7 @@ interface CalendarSidebarPanelProps {
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null;
}
@@ -27,6 +28,7 @@ export function CalendarSidebarPanel({
onToggleVisibility,
onColorChange,
onSubscribe,
onEditSubscription,
client,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
@@ -173,6 +175,16 @@ export function CalendarSidebarPanel({
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={() => {
setContextMenuCalId(null);
onEditSubscription?.(sub.id);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
{tSub('edit')}
</button>
<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"
+22 -16
View File
@@ -7,9 +7,9 @@ import {
} from "date-fns";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
@@ -77,7 +77,7 @@ export function CalendarWeekView({
const cursor = new Date(startDay);
while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd");
if (!ev.showWithoutTime) {
if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
const arr = timed.get(key) || [];
arr.push(ev);
timed.set(key, arr);
@@ -89,10 +89,18 @@ export function CalendarWeekView({
return timed;
}, [events]);
const allDaySegments = useMemo(() => buildWeekSegments(
events.filter((event) => event.showWithoutTime),
weekDays,
), [events, weekDays]);
const allDaySegments = useMemo(() => {
const explicitAllDay = buildWeekSegmentsRaw(
events.filter((event) => event.showWithoutTime),
weekDays,
);
const timedFullDay = buildTimedFullDayWeekSegments(
events.filter((event) => !event.showWithoutTime),
weekDays,
);
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
}, [events, weekDays]);
const allDayRowCount = useMemo(() => {
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
@@ -189,13 +197,13 @@ export function CalendarWeekView({
<div
ref={rootRef}
className={cn(
"flex flex-col flex-1",
"flex min-h-0 min-w-0 flex-col flex-1",
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
)}
role="grid"
aria-label={t("views.week")}
>
<div className={cn("flex flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className="flex border-b border-border">
<div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
@@ -321,7 +329,7 @@ export function CalendarWeekView({
</div>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
{HOURS.map((h) => (
@@ -344,7 +352,7 @@ export function CalendarWeekView({
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
const todayCol = isToday(day);
const layouted = layoutOverlappingEvents(dayEvents);
const layouted = layoutOverlappingEvents(dayEvents, day);
return (
<div
@@ -371,11 +379,9 @@ export function CalendarWeekView({
/>
))}
{layouted.map(({ event: ev, column, totalColumns }) => {
const start = parseISO(ev.start);
const startMin = start.getHours() * 60 + start.getMinutes();
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
+4 -3
View File
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format, parseISO } from "date-fns";
import { format } from "date-fns";
import { Users } from "lucide-react";
import { getParticipantCount } from "@/lib/calendar-participants";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store";
interface EventCardProps {
@@ -68,14 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
const startDate = parseISO(event.start);
const startDate = getEventStartDate(event);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const calendarName = calendar?.name || "";
const durationMinutes = parseDuration(event.duration);
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
const endTime = getEventEndDate(event);
const timeString = `${format(startDate, timeFmt)} ${format(endTime, timeFmt)}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
+3 -2
View File
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import {
isOrganizer,
getUserParticipantId,
@@ -138,9 +139,9 @@ export function EventDetailPopover({
const [isSavingNote, setIsSavingNote] = useState(false);
const color = getEventColor(event, calendar);
const startDate = parseISO(event.start);
const startDate = getEventStartDate(event);
const durationMinutes = parseDuration(event.duration);
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
const endDate = getEventEndDate(event);
const locationName = useMemo(() => {
if (!event.locations) return null;
+61 -11
View File
@@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
import { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput } from "./participant-input";
import {
isOrganizer,
@@ -18,6 +18,7 @@ import {
getStatusCounts,
buildParticipantMap,
} from "@/lib/calendar-participants";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
export interface PendingEventPreview {
@@ -159,7 +160,7 @@ export function EventModal({
}, [event, existingParticipants]);
const getInitialStart = (): Date => {
if (event?.start) return parseISO(event.start);
if (event?.start) return getEventStartDate(event);
if (defaultDate) {
const d = new Date(defaultDate);
if (defaultEndDate) return d;
@@ -177,9 +178,7 @@ export function EventModal({
if (event.showWithoutTime) {
return getEventDisplayEndDate(event);
}
const s = parseISO(event.start);
const dur = parseDuration(event.duration);
return new Date(s.getTime() + dur * 60000);
return getEventEndDate(event);
}
if (defaultEndDate) return new Date(defaultEndDate);
return addHours(getInitialStart(), 1);
@@ -190,6 +189,9 @@ export function EventModal({
const [location, setLocation] = useState(
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
);
const [virtualLocation, setVirtualLocation] = useState(
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
);
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
@@ -315,6 +317,20 @@ export function EventModal({
data.locations = null;
}
if (virtualLocation.trim()) {
data.virtualLocations = {
vl1: {
"@type": "VirtualLocation",
name: null,
description: null,
uri: virtualLocation.trim(),
features: null,
},
};
} else if (event && event.virtualLocations && Object.keys(event.virtualLocations).length > 0) {
data.virtualLocations = null;
}
if (recurrence !== "none") {
data.recurrenceRules = [{
"@type": "RecurrenceRule",
@@ -374,7 +390,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -384,7 +400,7 @@ export function EventModal({
const handleDuplicate = useCallback(() => {
if (!event || !onDuplicate) return;
const start = parseISO(event.start);
const start = getEventStartDate(event);
const newStart = addDays(start, 1);
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
@@ -403,6 +419,7 @@ export function EventModal({
privacy: event.privacy,
};
if (event.locations) data.locations = structuredClone(event.locations);
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants);
@@ -456,9 +473,9 @@ export function EventModal({
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
if (isAttendeeMode && event) {
const startD = parseISO(event.start);
const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration);
const endD = new Date(startD.getTime() + durMin * 60000);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event);
@@ -567,9 +584,9 @@ export function EventModal({
// View mode: read-only display of event details with Edit button
if (mode === "view" && event) {
const startD = parseISO(event.start);
const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration);
const endD = new Date(startD.getTime() + durMin * 60000);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event);
@@ -780,6 +797,39 @@ export function EventModal({
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5">
<Video className="w-4 h-4" />
{t("form.meeting_link")}
</span>
</label>
<Input
type="url"
value={virtualLocation}
onChange={(e) => setVirtualLocation(e.target.value)}
placeholder="https://meet.example.com/..."
maxLength={2000}
/>
<PluginSlot
name="calendar-event-actions"
className="mt-2 flex flex-wrap gap-2"
extraProps={{
eventData: {
title,
description,
start: startDate + 'T' + startTime,
end: endDate + 'T' + endTime,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
},
setVirtualLocation,
}}
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5">
+3 -2
View File
@@ -7,6 +7,7 @@ import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
@@ -17,7 +18,7 @@ interface ICalImportModalProps {
onClose: () => void;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing";
@@ -195,7 +196,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const formatEventDate = (event: Partial<CalendarEvent>): string => {
if (!event.start) return "";
try {
const date = parseISO(event.start);
const date = getEventStartDate(event as CalendarEvent);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
return event.showWithoutTime
? format(date, "MMM d, yyyy")
+29 -14
View File
@@ -5,24 +5,28 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useCalendarStore } from "@/stores/calendar-store";
import { useCalendarStore, type ICalSubscription } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps {
client: IJMAPClient;
onClose: () => void;
editSubscription?: ICalSubscription;
}
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(60);
const isEdit = !!editSubscription;
const [url, setUrl] = useState(editSubscription?.url || "");
const [name, setName] = useState(editSubscription?.name || "");
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
setIsSubmitting(true);
try {
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
if (isEdit && editSubscription) {
const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
if (trimmedUrl !== editSubscription.url) updates.url = trimmedUrl;
if (name.trim() !== editSubscription.name) updates.name = name.trim();
if (color !== editSubscription.color) updates.color = color;
if (refreshInterval !== editSubscription.refreshInterval) updates.refreshInterval = refreshInterval;
await updateICalSubscription(client, editSubscription.id, updates);
toast.success(t("updated", { name: name.trim() }));
onClose();
} else {
setError(t("error"));
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
onClose();
} else {
setError(t("error"));
}
}
} catch {
setError(t("error"));
setError(isEdit ? t("update_error") : t("error"));
} finally {
setIsSubmitting(false);
}
@@ -108,7 +123,7 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title")}</h2>
<h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</h2>
</div>
<button
onClick={onClose}
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("subscribing")}
{isEdit ? t("saving") : t("subscribing")}
</>
) : (
t("subscribe")
isEdit ? t("save") : t("subscribe")
)}
</Button>
</div>
+10 -2
View File
@@ -7,9 +7,10 @@ import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear,
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
isSameDay, isSameMonth, isToday, format, parseISO,
isSameDay, isSameMonth, isToday, format,
} from "date-fns";
import { cn } from "@/lib/utils";
import { getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types";
type PickerView = "days" | "months" | "years";
@@ -54,7 +55,14 @@ export function MiniCalendar({
const eventDates = useMemo(() => {
const set = new Set<string>();
events.forEach(e => {
try { set.add(format(parseISO(e.start), "yyyy-MM-dd")); } catch { /* skip */ }
try {
const { startDay, endDay } = getEventDayBounds(e);
const cursor = new Date(startDay);
while (cursor <= endDay) {
set.add(format(cursor, "yyyy-MM-dd"));
cursor.setDate(cursor.getDate() + 1);
}
} catch { /* skip */ }
});
return set;
}, [events]);
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
render(<EmailListItem email={email} />);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('renders inline preview text in focused mail layout', () => {
useSettingsStore.setState({
showPreview: true,
mailLayout: 'focus',
});
const email = makeEmail({ preview: 'Inline preview content' });
const { container } = render(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
expect(container.querySelector('p')).toBeNull();
});
});
+97 -15
View File
@@ -29,6 +29,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { findReplyIdentityId } from "@/lib/reply-identity";
import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */
@@ -76,8 +77,10 @@ interface EmailComposerProps {
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: {
from?: { email?: string; name?: string }[];
replyToAddresses?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[];
bcc?: { email?: string; name?: string }[];
subject?: string;
body?: string;
htmlBody?: string;
@@ -100,17 +103,20 @@ export function EmailComposer({
const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
// Initialize with reply/forward data if provided
const getInitialTo = () => {
if (!replyTo) return "";
// RFC 5322: use Reply-To header if present, otherwise fall back to From
const replyTarget = replyTo.replyToAddresses?.length
? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ")
: replyTo.from?.[0]?.email || "";
if (mode === 'reply') {
const email = replyTo.from?.[0]?.email || "";
return email ? email + ', ' : "";
return replyTarget ? replyTarget + ', ' : "";
} else if (mode === 'replyAll') {
const from = replyTo.from?.[0]?.email || "";
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
const combined = [from, originalTo].filter(Boolean).join(", ");
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
return combined ? combined + ', ' : "";
}
return "";
@@ -226,6 +232,31 @@ export function EmailComposer({
const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity;
useEffect(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
if (mode !== 'reply' && mode !== 'replyAll') return;
const matchedIdentityId = findReplyIdentityId(identities, {
to: replyTo?.to,
cc: replyTo?.cc,
bcc: replyTo?.bcc,
});
if (matchedIdentityId) {
setSelectedIdentityId(matchedIdentityId);
}
}, [
autoSelectReplyIdentity,
identities,
initialData?.selectedIdentityId,
mode,
replyTo?.bcc,
replyTo?.cc,
replyTo?.to,
selectedIdentityId,
]);
const composerSignatureHtml = currentIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
: currentIdentity?.textSignature
@@ -301,6 +332,17 @@ export function EmailComposer({
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
// Auto-focus the To field when composing a new email or forwarding
useEffect(() => {
if (mode === 'forward' || mode === 'compose') {
// Small delay to ensure the input is rendered
const timer = setTimeout(() => {
toInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [mode]);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
@@ -308,10 +350,26 @@ export function EmailComposer({
const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = useRef<HTMLInputElement>(null);
const bccInputRef = useRef<HTMLInputElement>(null);
const subjectInputRef = useRef<HTMLInputElement>(null);
const bodyRef = useRef<HTMLTextAreaElement>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null);
const ccDropdownRef = useRef<HTMLDivElement>(null);
const bccDropdownRef = useRef<HTMLDivElement>(null);
const focusSubject = useCallback(() => {
subjectInputRef.current?.focus();
}, []);
const focusBody = useCallback(() => {
if (plainTextMode) {
bodyRef.current?.focus();
} else {
const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null;
proseMirror?.focus();
}
}, [plainTextMode]);
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current);
@@ -1040,6 +1098,7 @@ export function EmailComposer({
onInsertAutocomplete={insertAutocomplete}
validationError={validationErrors.to}
validationMessage={t('validation.recipient_required')}
onTab={focusSubject}
/>
<div className="flex gap-0.5 shrink-0">
<Button
@@ -1109,6 +1168,7 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input
ref={subjectInputRef}
type="text"
placeholder={t('subject_placeholder')}
value={subject}
@@ -1116,6 +1176,12 @@ export function EmailComposer({
setSubject(e.target.value);
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
}}
onKeyDown={(e) => {
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
focusBody();
}
}}
className={cn(
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
@@ -1128,6 +1194,7 @@ export function EmailComposer({
{/* Body */}
{plainTextMode ? (
<textarea
ref={bodyRef}
value={body}
onChange={(e) => {
setBody(e.target.value);
@@ -1142,16 +1209,18 @@ export function EmailComposer({
aria-invalid={validationErrors.body || undefined}
/>
) : (
<RichTextEditor
content={body}
onChange={(html) => {
setBody(html);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
/>
<div ref={editorContainerRef}>
<RichTextEditor
content={body}
onChange={(html) => {
setBody(html);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
/>
</div>
)}
{plainTextMode ? (
@@ -1483,6 +1552,7 @@ function RecipientChipInput({
onInsertAutocomplete,
validationError,
validationMessage,
onTab,
}: {
value: string;
onChange: (value: string) => void;
@@ -1499,6 +1569,7 @@ function RecipientChipInput({
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
}) {
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
const hasTrailingComma = value.trimEnd().endsWith(',');
@@ -1532,7 +1603,18 @@ function RecipientChipInput({
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
if (e.key !== 'Tab') e.preventDefault();
commitCurrentInput();
setTimeout(() => inputRef.current?.focus(), 0);
if (e.key === 'Tab' && onTab) {
e.preventDefault();
setTimeout(() => onTab(), 0);
} else {
setTimeout(() => inputRef.current?.focus(), 0);
}
return;
}
if (e.key === 'Tab' && !e.shiftKey && onTab) {
e.preventDefault();
onTab();
return;
}
+15 -3
View File
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
interface EmailHoverActionsProps {
email: Email;
backgroundClassName?: string;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
@@ -63,6 +64,7 @@ const CORNER_CLASSES = {
export function EmailHoverActions({
email,
backgroundClassName = "bg-muted",
onToggleStar,
onMarkAsRead,
onDelete,
@@ -77,6 +79,7 @@ export function EmailHoverActions({
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const hoverBackgroundClassName = backgroundClassName;
if (hoverActions.length === 0) return null;
@@ -144,7 +147,10 @@ export function EmailHoverActions({
CORNER_CLASSES[hoverActionsCorner],
)}
>
<div className="flex items-center gap-0.5 bg-muted rounded-lg px-1.5 py-0.5 shadow-md border border-border">
<div className={cn(
"flex items-center gap-0.5 rounded-lg px-1.5 py-0.5 shadow-md border border-border",
hoverBackgroundClassName,
)}>
{actionButtons}
</div>
</div>
@@ -155,8 +161,14 @@ export function EmailHoverActions({
<div
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
>
<div className="w-8 h-full bg-gradient-to-r from-transparent to-muted" />
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
<div
className={cn("w-8 h-full", hoverBackgroundClassName)}
style={{
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
maskImage: "linear-gradient(to right, transparent, black)",
}}
/>
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
{actionButtons}
</div>
</div>
+125 -71
View File
@@ -35,6 +35,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
@@ -44,6 +45,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tag using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords);
@@ -114,15 +117,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
}
}}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
<div
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
@@ -145,7 +152,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
)}
{/* Avatar */}
{density !== 'extra-compact' && (
{!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
@@ -156,85 +163,131 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
Important
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{sender?.name || sender?.email || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'shrink-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{email.subject || t('no_subject')}
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{inlinePreview && (
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
) : (
<>
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
Important
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{email.subject || t('no_subject')}
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || t('no_subject')}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)}
</>
)}
</div>
</div>
@@ -242,6 +295,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
+20 -4
View File
@@ -103,11 +103,16 @@ export function EmailList({
const parentRef = useRef<HTMLDivElement>(null);
const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isFocusedMailLayout = mailLayout === 'focus';
const estimateSize = useCallback(() => {
if (isFocusedMailLayout) {
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
}
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
}, [density, showPreview]);
}, [density, isFocusedMailLayout, showPreview]);
const virtualizer = useVirtualizer({
count: threadGroups.length,
@@ -216,15 +221,26 @@ export function EmailList({
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
// Range-based load more: trigger when last visible item is near the end
// Range-based load more: trigger when last visible item is near the end.
// Debounce to prevent rapid cascade when thread grouping reduces item
// count below the viewport size (e.g. 2400 emails → fewer thread groups).
const virtualItems = virtualizer.getVirtualItems();
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (lastVirtualItemIndex === undefined) return;
if (lastVirtualItemIndex >= threadGroups.length - 5) {
handleLoadMore();
// Clear any pending timer so we don't stack calls
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
loadMoreTimerRef.current = setTimeout(() => {
handleLoadMore();
loadMoreTimerRef.current = null;
}, 150);
}
return () => {
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
};
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
// Scroll to the thread group containing the selected email
@@ -244,7 +260,7 @@ export function EmailList({
useEffect(() => {
virtualizer.measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [density, showPreview]);
}, [density, isFocusedMailLayout, showPreview]);
return (
<div className={cn("flex flex-col min-h-0", className)}>
+120 -43
View File
@@ -68,6 +68,7 @@ import {
HelpCircle,
EditIcon,
PlayCircle,
PenSquare,
} from "lucide-react";
import { useTranslations } from "next-intl";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -121,6 +122,7 @@ interface EmailViewerProps {
onNavigatePrev?: () => void;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
onCompose?: () => void;
currentUserEmail?: string;
currentUserName?: string;
currentMailboxRole?: string;
@@ -865,6 +867,7 @@ export function EmailViewer({
onNavigatePrev,
onShowShortcuts,
onEditDraft,
onCompose,
currentUserEmail,
currentUserName,
currentMailboxRole,
@@ -887,8 +890,10 @@ export function EmailViewer({
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
// Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk';
@@ -906,7 +911,7 @@ export function EmailViewer({
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode } = useAuthStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const [showFullHeaders, setShowFullHeaders] = useState(false);
@@ -960,9 +965,9 @@ export function EmailViewer({
// Ensure S/MIME key records are loaded from IndexedDB
useLayoutEffect(() => {
smimeStore.load();
smimeStore.load(activeAccountId ?? undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [activeAccountId]);
// Build mailbox tree for move-to dropdown
const moveTargetIds = useMemo(() => new Set(
@@ -1540,8 +1545,25 @@ export function EmailViewer({
if (detection.type === 'enveloped-data') {
// Encrypted message
const { keyRecords, unlockedDecryptionKeys } = smimeStore;
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
smimeDebug('[S/MIME] decrypt attempt:', {
keyRecordCount: keyRecords.length,
unlockedKeyCount: unlockedDecryptionKeys.size,
legacyKeyCount: unlockedLegacyDecryptionKeys.size,
keyRecordIds: keyRecords.map(k => k.id),
});
// Short-circuit: no keys imported at all
if (keyRecords.length === 0) {
smimeDebug('[S/MIME] no key records available, skipping decrypt');
setSmimeStatus({
isSigned: false,
isEncrypted: true,
decryptionError: 'no-key',
});
return;
}
try {
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
let lastError: unknown = null;
@@ -1552,6 +1574,7 @@ export function EmailViewer({
cmsBytes: candidate.normalized,
keyRecords,
unlockedKeys: unlockedDecryptionKeys,
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
});
smimeDebug('[S/MIME] decrypt success with candidate:', {
source: candidate.source,
@@ -1633,8 +1656,15 @@ export function EmailViewer({
if (!existing) {
try {
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
} catch { /* ignore import errors */ }
smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint });
} catch (importErr) {
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
}
} else {
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
}
} else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) {
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email });
}
} catch (error) {
smimeError('[S/MIME] nested signature verify failed:', {
@@ -1672,10 +1702,12 @@ export function EmailViewer({
decryptionError: 'locked',
});
} else {
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
setSmimeStatus({
isSigned: false,
isEncrypted: true,
decryptionError: err instanceof Error ? err.message : 'Decryption failed',
decryptionError: isNoKeyError ? 'no-key' : errMsg,
});
}
}
@@ -1737,8 +1769,15 @@ export function EmailViewer({
if (!existing) {
try {
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
} catch { /* ignore import errors */ }
smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint });
} catch (importErr) {
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
}
} else {
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
}
} else if (result.status.signatureValid && result.status.signerCert) {
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email });
}
} catch (err) {
if (cancelled) return;
@@ -1771,6 +1810,7 @@ export function EmailViewer({
smimeStore.autoImportSignerCerts,
smimeStore.keyRecords,
smimeStore.unlockedDecryptionKeys,
smimeStore.unlockedLegacyDecryptionKeys,
smimeStore,
]);
@@ -1780,12 +1820,12 @@ export function EmailViewer({
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
if (!tnefAtt?.blobId) {
debug.log('TNEF: No winmail.dat attachment found in email', email?.id);
debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id);
return;
}
debug.group('TNEF Processing');
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
debug.group('TNEF Processing', 'email');
debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
// 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
@@ -1795,46 +1835,46 @@ export function EmailViewer({
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');
debug.log('email', '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');
debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only');
} else {
debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
}
let cancelled = false;
async function processTnef() {
try {
debug.time('TNEF fetch blob');
debug.time('TNEF fetch blob', 'email');
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
debug.timeEnd('TNEF fetch blob');
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
debug.timeEnd('TNEF fetch blob', 'email');
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
if (cancelled) {
debug.log('TNEF: Processing cancelled after fetch');
debug.log('email', 'TNEF: Processing cancelled after fetch');
debug.groupEnd();
return;
}
if (blobBytes.byteLength === 0) {
debug.warn('TNEF: Fetched blob is empty (0 bytes)');
debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)');
debug.groupEnd();
return;
}
const tnefData = new Uint8Array(blobBytes);
debug.time('TNEF parse');
debug.time('TNEF parse', 'email');
const parsed = parseTnef(tnefData);
debug.timeEnd('TNEF parse');
debug.timeEnd('TNEF parse', 'email');
if (cancelled) {
debug.log('TNEF: Processing cancelled after parse');
debug.log('email', 'TNEF: Processing cancelled after parse');
debug.groupEnd();
return;
}
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);
debug.log('email', '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 && !hasRealHtmlBody) {
setTnefHtml(parsed.htmlBody);
@@ -1844,11 +1884,11 @@ export function EmailViewer({
}
if (parsed.attachments.length > 0) {
setTnefAttachments(parsed.attachments);
debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
}
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
}
debug.groupEnd();
@@ -1886,13 +1926,13 @@ export function EmailViewer({
const hasRealText = !!textValue;
if (hasRealHtml || hasRealText) {
debug.log('Embedded RFC822: Outer email has real body content, not unwrapping');
debug.log('email', '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');
debug.group('Embedded RFC822 Unwrapping', 'email');
debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
debug.log('email', 'Outer email body is empty, will unwrap embedded email');
let cancelled = false;
@@ -1901,7 +1941,7 @@ export function EmailViewer({
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.warn('email', 'Embedded RFC822: Fetched blob is empty');
debug.groupEnd();
return;
}
@@ -1911,7 +1951,7 @@ export function EmailViewer({
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)',
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
', attachments:', parsed.attachments?.length ?? 0);
@@ -1923,7 +1963,7 @@ export function EmailViewer({
}
if (parsed.attachments && parsed.attachments.length > 0) {
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
debug.log('Embedded RFC822 attachments:', parsed.attachments.map(
debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map(
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
).join(', '));
}
@@ -2526,14 +2566,21 @@ export function EmailViewer({
// If email has native dark mode, let it handle its own theming
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
// Re-invert leaf media elements so they appear normal.
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
// double re-inverting images nested inside those containers.
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #1a1a1a; }
body { filter: invert(1) hue-rotate(180deg); }
img, video, picture, svg, canvas, object, embed,
[style*="background-image"], [style*="background:"],
[background], [bgcolor],
td[background], table[background],
img[src], input[type="image"] {
img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
[background]:not(:has(img, video, svg, canvas, object, embed)),
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
td[background]:not(:has(img, video, svg, canvas, object, embed)),
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
filter: invert(1) hue-rotate(180deg);
}
` : '';
@@ -2572,11 +2619,35 @@ export function EmailViewer({
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
// Dark mode: re-invert elements with stylesheet-defined background images
// (CSS attribute selectors only catch inline styles, not <style> block rules)
if (isDark && !emailHasNativeDarkMode) {
const win = doc.defaultView;
if (win) {
doc.body.querySelectorAll('*').forEach(el => {
const htmlEl = el as HTMLElement;
// Skip elements already handled by CSS attribute selectors
if (htmlEl.style.backgroundImage || htmlEl.style.background ||
htmlEl.hasAttribute('background') || htmlEl.hasAttribute('bgcolor')) return;
// Skip leaf media elements (already re-inverted by CSS)
const tag = htmlEl.tagName;
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
const computed = win.getComputedStyle(htmlEl);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Only re-invert if this container doesn't have media children
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) {
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
}
}
});
}
}
}
} catch {
// Cross-origin restrictions - iframe will still display content
}
}, []);
}, [isDark, emailHasNativeDarkMode]);
// Export email as .eml file
const handleExportEmail = async () => {
@@ -2775,6 +2846,12 @@ export function EmailViewer({
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
<PenSquare className="w-4 h-4 mr-2" />
{t('compose')}
</Button>
)}
</div>
</div>
);
@@ -2790,7 +2867,7 @@ export function EmailViewer({
<>
{/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
{showBackButton && isTablet && !tabletListVisible && onBack && (
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -2805,7 +2882,7 @@ export function EmailViewer({
<Button
variant="default"
size="sm"
onClick={onEditDraft}
onClick={() => onEditDraft()}
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.edit_draft')}
>
@@ -3524,7 +3601,7 @@ export function EmailViewer({
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && (
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -4403,7 +4480,7 @@ export function EmailViewer({
<Button
size="sm"
variant="outline"
onClick={onEditDraft}
onClick={() => onEditDraft()}
className="gap-1.5"
>
<EditIcon className="w-3.5 h-3.5" />
@@ -4672,7 +4749,7 @@ export function EmailViewer({
</button>
{isDraft && onEditDraft ? (
<button
onClick={onEditDraft}
onClick={() => onEditDraft()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
aria-label={t('tooltips.edit_draft')}
>
+13 -1
View File
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
text: t('unlock_key_desc'),
variant: 'warning',
});
} else if (status.decryptionError === 'no-key') {
items.push({
icon: <Lock className="w-4 h-4" />,
text: t('status_encrypted_no_key'),
variant: 'warning',
});
} else {
items.push({
icon: <ShieldX className="w-4 h-4" />,
@@ -49,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
// Signature status
if (status.isSigned) {
if (status.signatureValid === true) {
if (status.signerEmailMatch === false) {
if (status.selfSigned) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_self_signed'),
variant: 'warning',
});
} else if (status.signerEmailMatch === false) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_mismatch'),
+256 -140
View File
@@ -59,7 +59,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isChecked = selectedEmailIds.has(email.id);
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color and keyword definition from keyword definitions if not passed directly
const tagId = getEmailColorTag(email.keywords);
@@ -131,15 +134,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
onClick={handleClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
<div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
@@ -160,7 +167,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
)}
{density !== 'extra-compact' && (
{!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
@@ -170,76 +177,121 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{sender?.name || sender?.email || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'shrink-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{email.subject || '(no subject)'}
</span>
{inlinePreview && (
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
) : (
<>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{email.subject || "(no subject)"}
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
</div>
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)}
</>
)}
</div>
</div>
@@ -247,6 +299,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
@@ -280,8 +333,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
@@ -405,15 +461,19 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
onClick={handleHeaderClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
<div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox for thread selection - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleThreadCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
@@ -428,7 +488,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</button>
)}
{!isMobile && (
{!isMobile && !isFocusedMailLayout && (
<button
data-expand-toggle
onClick={(e) => {
@@ -460,7 +520,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
)}
{density !== 'extra-compact' && (
{!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar
name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email}
@@ -470,88 +530,143 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
hasUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{participantNames.join(", ")}
</span>
<span
className={cn(
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{hasStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{hasAnswered && !hasForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasForwarded && !hasAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-44',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{participantNames.join(', ')}
</span>
<span
className={cn(
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'shrink-0 truncate',
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{latestEmail.subject || '(no subject)'}
</span>
{inlinePreview && (
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{hasAnswered && hasForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
<span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
) : (
<>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
hasUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{participantNames.join(", ")}
</span>
<span
className={cn(
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{hasStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{hasAnswered && !hasForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasForwarded && !hasAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasAnswered && hasForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
</div>
{latestEmail.subject || "(no subject)"}
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
</div>
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
hasUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{latestEmail.preview || "No preview available"}
</p>
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
hasUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{latestEmail.preview || "No preview available"}
</p>
)}
</>
)}
</div>
</div>
@@ -559,6 +674,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{/* Hover Quick Actions for thread header */}
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
@@ -568,7 +684,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
/>
</div>
{isExpanded && !isMobile && (
{isExpanded && !isMobile && !isFocusedMailLayout && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
+1
View File
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
{!loading && !error && fileType === "pdf" && objectUrl && (
<iframe
src={objectUrl}
sandbox="allow-same-origin"
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
+146 -22
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield } from "lucide-react";
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check } from "lucide-react";
import { AccountSwitcher } from "./account-switcher";
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
@@ -16,8 +16,12 @@ import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
interface NavItem {
id: string;
@@ -164,20 +168,60 @@ export function NavigationRail({
const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore();
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
// Account list for rail
const accounts = useAccountStore((s) => s.accounts);
const activeAccountId = useAccountStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
const logoutAll = useAuthStore((s) => s.logoutAll);
const [logoutMenuOpen, setLogoutMenuOpen] = useState(false);
const logoutBtnRef = useRef<HTMLButtonElement>(null);
const logoutPopoverRef = useRef<HTMLDivElement>(null);
const [logoutPopoverStyle, setLogoutPopoverStyle] = useState<React.CSSProperties>({});
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const updateLogoutPosition = useCallback(() => {
if (!logoutBtnRef.current) return;
const rect = logoutBtnRef.current.getBoundingClientRect();
setLogoutPopoverStyle({
position: "fixed",
left: rect.right + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
});
}, []);
useEffect(() => {
if (!logoutMenuOpen) return;
updateLogoutPosition();
const handleClickOutside = (e: MouseEvent) => {
if (
logoutBtnRef.current?.contains(e.target as Node) ||
logoutPopoverRef.current?.contains(e.target as Node)
) return;
setLogoutMenuOpen(false);
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setLogoutMenuOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [logoutMenuOpen, updateLogoutPosition]);
useEffect(() => {
let cancelled = false;
const { client } = useAuthStore.getState();
if (!client) return;
const headers: Record<string, string> = {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
const headers = getActiveAccountSlotHeaders();
if (!headers['X-JMAP-Cookie-Slot']) return;
fetch('/api/admin/stalwart-check', { headers })
.then(res => res.json())
.then(data => {
@@ -199,7 +243,7 @@ export function NavigationRail({
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
];
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
@@ -504,6 +548,22 @@ export function NavigationRail({
<Keyboard className="w-[18px] h-[18px]" />
</button>
)}
{!onShowShortcuts && (
<>
<button
onClick={() => setShowShortcutsModal(true)}
data-tour="nav-shortcuts"
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("keyboard_shortcuts")}
>
<Keyboard className="w-[18px] h-[18px]" />
</button>
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
</>
)}
{quota && quota.total > 0 && (
<div data-tour="storage-quota">
@@ -511,21 +571,85 @@ export function NavigationRail({
</div>
)}
{isPushConnected != null && (
<span
className="relative group"
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
>
<span
className={cn(
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
)}
/>
</span>
{onLogout && showRailAccountList && accounts.length > 0 && (
<>
<div className="w-8 border-t my-1" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
{/* Account circles */}
<div className="flex flex-col items-center gap-3">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
const initials = getInitials(account.displayName || account.label, account.email || account.username);
return (
<button
key={account.id}
onClick={() => {
if (!isActive) switchAccount(account.id);
}}
className={cn(
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
isActive
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "opacity-70 hover:opacity-100"
)}
style={{ backgroundColor: account.avatarColor }}
title={`${account.displayName || account.label} (${account.email || account.username})`}
>
{initials}
{isActive && (
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2 h-2 text-primary-foreground" />
</span>
)}
</button>
);
})}
</div>
{/* Logout button with popover */}
<button
ref={logoutBtnRef}
onClick={() => setLogoutMenuOpen(!logoutMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("sign_out")}
aria-expanded={logoutMenuOpen}
aria-haspopup="true"
>
<LogOut className="w-4 h-4" />
</button>
{logoutMenuOpen && createPortal(
<div
ref={logoutPopoverRef}
style={logoutPopoverStyle}
className="w-56 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
role="menu"
>
<button
onClick={() => { setLogoutMenuOpen(false); logout(); }}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
<LogOut className="w-4 h-4" />
{t("sign_out")}
</button>
{accounts.length > 1 && (
<button
onClick={() => { setLogoutMenuOpen(false); logoutAll(); }}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-destructive hover:bg-muted transition-colors"
role="menuitem"
>
<LogOut className="w-4 h-4" />
{t("sign_out_all")}
</button>
)}
</div>,
document.body
)}
</>
)}
{onLogout && (
{onLogout && !showRailAccountList && (
<AccountSwitcher variant="rail" />
)}
</div>
+132 -37
View File
@@ -12,7 +12,6 @@ import {
Star,
Trash2,
Archive,
PenSquare,
ChevronsLeft,
ChevronsRight,
ChevronRight,
@@ -309,7 +308,7 @@ function TagItem({
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
{!isCollapsed && (
<>
<span className="truncate">{kw.label}</span>
<span className="flex-1 truncate">{kw.label}</span>
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
{unreadCount > 0 && (
<span className={cn(
@@ -428,9 +427,16 @@ export function Sidebar({
onUnreadFilterClick,
className,
}: SidebarProps) {
const router = useRouter();
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity: _primaryIdentity } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [foldersExpanded, setFoldersExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarFoldersExpanded');
return stored !== null ? JSON.parse(stored) : true;
} catch { return true; }
});
const [tagsExpanded, setTagsExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarTagsExpanded');
@@ -438,6 +444,7 @@ export function Sidebar({
} catch { return true; }
});
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher);
const tagCounts = useEmailStore(s => s.tagCounts);
const t = useTranslations('sidebar');
@@ -452,10 +459,17 @@ export function Sidebar({
}
} else {
const tree = buildMailboxTree(mailboxes);
const defaultExpanded = tree
.filter(node => node.children.length > 0)
.map(node => node.id);
setExpandedFolders(new Set(defaultExpanded));
const collectExpandable = (nodes: MailboxNode[]): string[] => {
const ids: string[] = [];
for (const node of nodes) {
if (node.children.length > 0) {
ids.push(node.id);
ids.push(...collectExpandable(node.children));
}
}
return ids;
};
setExpandedFolders(new Set(collectExpandable(tree)));
}
}, [mailboxes]);
@@ -539,7 +553,7 @@ export function Sidebar({
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
</Button>
{!isCollapsed && (
{!isCollapsed && !hideAccountSwitcher && (
<AccountSwitcher variant="expanded" className="flex-1" />
)}
</div>
@@ -553,24 +567,103 @@ export function Sidebar({
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
<div className="py-1">
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
{/* Folders Section Header */}
<div
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 font-medium",
isCollapsed ? "justify-center px-1" : "px-2",
"text-foreground hover:bg-muted"
)}
>
{!isCollapsed && (
<button
onClick={() => {
setFoldersExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
}}
className={cn(
"p-0.5 rounded mr-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
title={foldersExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
>
{foldersExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</button>
)}
<button
onClick={() => {
if (isCollapsed) return;
setFoldersExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
}}
className={cn(
"flex items-center px-1 rounded",
"transition-colors duration-150",
isCollapsed ? "justify-center" : "flex-1 text-left"
)}
style={{ paddingBlock: 'var(--density-sidebar-py)', ...(isCollapsed ? {} : { paddingLeft: '4px' }) }}
title={isCollapsed ? t("folders") : undefined}
>
<Folder className={cn(
"w-4 h-4 flex-shrink-0 transition-colors",
!isCollapsed && "mr-2",
foldersExpanded && "text-primary"
)} />
{!isCollapsed && (
<span className="flex-1 truncate">{t("folders")}</span>
)}
</button>
{!isCollapsed && (
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
title={t('settings')}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
{/* Folder Items */}
{((foldersExpanded && !isCollapsed) || isCollapsed) && (
<>
{mailboxTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
/>
))}
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
<>
{mailboxTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
/>
))}
</>
)}
</>
)}
</div>
@@ -635,6 +728,20 @@ export function Sidebar({
<span className="flex-1 truncate">{t("tags")}</span>
)}
</button>
{!isCollapsed && (
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
title={t('settings')}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
@@ -661,19 +768,7 @@ export function Sidebar({
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div>
{/* Compose Button */}
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
{isCollapsed ? (
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")} data-tour="compose-button">
<PenSquare className="w-5 h-5" />
</Button>
) : (
<Button onClick={onCompose} className="w-full" title={t("compose_hint")} data-tour="compose-button">
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
)}
</div>
</div>
);
}
+26 -1
View File
@@ -7,11 +7,12 @@ import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
export function AdvancedSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const { debugMode, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false);
@@ -72,6 +73,30 @@ export function AdvancedSettings() {
</SettingItem>
)}
{/* Debug Categories */}
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
{ALL_DEBUG_CATEGORIES.map((cat) => (
<SettingItem
key={cat.id}
label={t(`debug_categories.${cat.labelKey}`)}
description={t(`debug_categories.${cat.labelKey}_description`)}
>
<ToggleSwitch
checked={debugCategories?.[cat.id] !== false}
onChange={(checked) => {
updateSetting('debugCategories', {
...debugCategories,
[cat.id]: checked,
});
}}
/>
</SettingItem>
))}
</div>
)}
{/* Settings Sync */}
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
+17 -1
View File
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
@@ -145,6 +145,22 @@ export function AppearanceSettings() {
/>
</SettingItem>
{/* Hide Account Switcher */}
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
<ToggleSwitch
checked={hideAccountSwitcher}
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
/>
</SettingItem>
{/* Show Rail Account List */}
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
<ToggleSwitch
checked={showRailAccountList}
onChange={(checked) => updateSetting('showRailAccountList', checked)}
/>
</SettingItem>
{/* Animations */}
{!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
@@ -164,6 +165,7 @@ export function CalendarManagementSettings() {
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const tImport = useTranslations('calendar.import');
@@ -202,7 +204,10 @@ export function CalendarManagementSettings() {
fetch('/api/caldav/discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...getActiveAccountSlotHeaders(),
},
body: JSON.stringify({
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
}),
@@ -629,6 +634,14 @@ export function CalendarManagementSettings() {
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingSubscription(sub)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={tSub('edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleRefreshSubscription(sub.id)}
@@ -667,6 +680,14 @@ export function CalendarManagementSettings() {
onClose={() => setShowSubscriptionModal(false)}
/>
)}
{editingSubscription && client && (
<ICalSubscriptionModal
client={client}
editSubscription={editingSubscription}
onClose={() => setEditingSubscription(null)}
/>
)}
</SettingsSection>
);
}
+18
View File
@@ -19,6 +19,7 @@ export function CalendarSettings() {
showWeekNumbers,
enableCalendarTasks,
showTasksOnCalendar,
calendarHoverPreview,
updateSetting,
} = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore();
@@ -80,6 +81,23 @@ export function CalendarSettings() {
/>
</SettingItem>
<SettingItem
label={t('hover_preview')}
description={t('hover_preview_desc')}
>
<Select
value={calendarHoverPreview}
onChange={(value) => updateSetting('calendarHoverPreview', value as 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s')}
options={[
{ value: 'instant', label: t('hover_preview_instant') },
{ value: 'delay-500ms', label: t('hover_preview_delay_500ms') },
{ value: 'delay-1s', label: t('hover_preview_delay_1s') },
{ value: 'delay-2s', label: t('hover_preview_delay_2s') },
{ value: 'off', label: t('hover_preview_off') },
]}
/>
</SettingItem>
{isFeatureEnabled('calendarTasksEnabled') && (
<>
<SettingItem
+110 -3
View File
@@ -4,16 +4,92 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction, MailLayout } from '@/stores/settings-store';
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
];
function MailLayoutPreview({
value,
t,
}: {
value: MailLayout;
t: (key: string) => string;
}) {
const isSplit = value === 'split';
return (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate font-medium text-foreground">{row.sender}</div>
<div className="truncate text-muted-foreground">{row.subject}</div>
</div>
))}
</div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2.5 w-20 rounded bg-foreground/10" />
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'rounded-md px-2 py-1 text-[10px]',
row.selected ? 'bg-primary/10' : 'bg-muted/20'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
<span className="text-muted-foreground/80">{row.preview}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
export function EmailSettings() {
const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
@@ -39,7 +115,9 @@ export function EmailSettings() {
deleteAction,
permanentlyDeleteJunk,
showPreview,
mailLayout,
disableThreading,
autoSelectReplyIdentity,
plainTextMode,
emailsPerPage,
externalContentPolicy,
@@ -62,6 +140,8 @@ export function EmailSettings() {
return t('trusted_senders.count_other', { count });
};
const isFocusedLayout = mailLayout === 'focus';
const handleReorganizeArchive = async () => {
const { client } = useAuthStore.getState();
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
@@ -207,9 +287,29 @@ export function EmailSettings() {
/>
</SettingItem>
{!isSettingHidden('mailLayout') && (
<SettingItem label={t('mail_layout.label')} description={t('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
<div className="w-[22rem] max-w-full">
<RadioGroup
value={mailLayout}
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
options={[
{ value: 'split', label: t('mail_layout.split') },
{ value: 'focus', label: t('mail_layout.focus') },
]}
/>
<MailLayoutPreview value={mailLayout} t={t} />
</div>
</SettingItem>
)}
{/* Show Preview */}
{!isSettingHidden('showPreview') && (
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')} locked={isSettingLocked('showPreview')}>
<SettingItem
label={t('show_preview.label')}
description={isFocusedLayout ? t('show_preview.focus_description') : t('show_preview.description')}
locked={isSettingLocked('showPreview')}
>
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem>
)}
@@ -230,6 +330,13 @@ export function EmailSettings() {
/>
</SettingItem>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
/>
</SettingItem>
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
+4 -2
View File
@@ -19,6 +19,7 @@ import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-di
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
import { useSmimeStore } from "@/stores/smime-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAuthStore } from "@/stores/auth-store";
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
@@ -50,6 +51,7 @@ export function SmimeSettings() {
} = useSmimeStore();
const { identities } = useIdentityStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
// Local UI state
const [importDialogOpen, setImportDialogOpen] = useState(false);
@@ -75,8 +77,8 @@ export function SmimeSettings() {
const [exportError, setExportError] = useState<string | null>(null);
useEffect(() => {
load();
}, [load]);
load(activeAccountId ?? undefined);
}, [load, activeAccountId]);
// ── PKCS#12 import flow ────────────────────────────────────────
+50 -46
View File
@@ -174,64 +174,68 @@ interface ThemeCardProps {
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
return (
<button
onClick={onActivate}
disabled={disabled}
className={cn(
'relative flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
isActive
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-primary/40 bg-card',
disabled && !isActive && 'hover:border-border'
)}
>
{/* Preview / Placeholder */}
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
{preview ? (
<img src={preview} alt={name} className="w-full h-full object-cover" />
) : (
<Palette className="w-8 h-8 text-muted-foreground/40" />
<div className="relative">
<button
type="button"
onClick={onActivate}
disabled={disabled}
className={cn(
'flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
isActive
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-primary/40 bg-card',
disabled && !isActive && 'hover:border-border'
)}
</div>
{/* Info */}
<div className="w-full">
<div className="flex items-center justify-between gap-1">
<span className="text-sm font-medium text-foreground truncate">{name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{isForceEnabled && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium flex items-center gap-0.5" title="Admin enforced">
<Lock className="w-2.5 h-2.5" />
</span>
)}
{isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
)}
{isActive && <Check className="w-4 h-4 text-primary" />}
</div>
>
{/* Preview / Placeholder */}
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
{preview ? (
<img src={preview} alt={name} className="w-full h-full object-cover" />
) : (
<Palette className="w-8 h-8 text-muted-foreground/40" />
)}
</div>
<span className="text-xs text-muted-foreground truncate block">{author}</span>
{variants && (
<div className="flex gap-1 mt-1">
{variants.map(v => (
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{v}
</span>
))}
{/* Info */}
<div className="w-full">
<div className="flex items-center justify-between gap-1">
<span className="text-sm font-medium text-foreground truncate">{name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{isForceEnabled && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium flex items-center gap-0.5" title="Admin enforced">
<Lock className="w-2.5 h-2.5" />
</span>
)}
{isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
)}
{isActive && <Check className="w-4 h-4 text-primary" />}
</div>
</div>
)}
</div>
<span className="text-xs text-muted-foreground truncate block">{author}</span>
{variants && (
<div className="flex gap-1 mt-1">
{variants.map(v => (
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{v}
</span>
))}
</div>
)}
</div>
</button>
{/* Remove button */}
{onRemove && !isActive && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
type="button"
onClick={onRemove}
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Remove theme"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</button>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTotpReauthStore } from "@/stores/totp-reauth-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Shield } from "lucide-react";
/**
* Modal dialog that prompts the user for a fresh TOTP code when their
* 2FA session expires (TOTP rotates every ~30 seconds).
*
* Rendered once at the app root level. The JMAP client triggers it via
* the useTotpReauthStore when a 401 is received on a TOTP-authenticated session.
*/
export function TotpReauthDialog() {
const { isOpen, submit, cancel } = useTotpReauthStore();
const [code, setCode] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: cancel,
restoreFocus: true,
});
useEffect(() => {
if (isOpen) {
setCode("");
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (code.length >= 6) {
submit(code);
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={cancel} />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Two-factor authentication required"
className="relative z-10 w-full max-w-sm mx-4 bg-background rounded-2xl shadow-xl border border-border p-6"
>
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
<Shield className="w-5 h-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Session Expired</h2>
<p className="text-sm text-muted-foreground">Your 2FA code has rotated</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-4">
Enter a fresh authentication code from your authenticator app to continue.
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mb-4 leading-relaxed">
To avoid being prompted repeatedly, ask your administrator to enable OAuth authentication
(either Stalwart&apos;s built-in OAuth or an external identity provider).
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
ref={inputRef}
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
className="h-12 text-center font-mono tracking-widest text-lg bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder="000000"
autoComplete="one-time-code"
aria-label="Authentication code"
/>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={cancel}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={code.length < 6}
>
Verify
</Button>
</div>
</form>
</div>
</div>
);
}
+1
View File
@@ -71,6 +71,7 @@ export default [
".next/**",
"node_modules/**",
"repos/**",
"data/admin/plugins/**",
"*.config.js",
"*.config.mjs",
"e2e/**",
+4
View File
@@ -25,6 +25,7 @@ interface ConfigData {
loginWebsiteUrl: string;
demoMode: boolean;
autoSsoEnabled: boolean;
allowCustomJmapEndpoint: boolean;
embeddedMode: boolean;
parentOrigin: string;
}
@@ -100,6 +101,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
demoMode: configCache?.demoMode || false,
autoSsoEnabled: configCache?.autoSsoEnabled || false,
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
embeddedMode: configCache?.embeddedMode || false,
parentOrigin: configCache?.parentOrigin || '',
isLoading: !configCache,
@@ -131,6 +133,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: configCache.loginWebsiteUrl,
demoMode: configCache.demoMode,
autoSsoEnabled: configCache.autoSsoEnabled,
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
embeddedMode: configCache.embeddedMode,
parentOrigin: configCache.parentOrigin,
isLoading: false,
@@ -163,6 +166,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: data.loginWebsiteUrl,
demoMode: data.demoMode,
autoSsoEnabled: data.autoSsoEnabled,
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
embeddedMode: data.embeddedMode,
parentOrigin: data.parentOrigin,
isLoading: false,
@@ -45,4 +45,43 @@ describe('calendar event normalization', () => {
showWithoutTime: true,
});
});
describe('recurrenceRule normalization (JSCalendar 2.0 singular→plural)', () => {
it('wraps a single recurrenceRule object in an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' },
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]);
expect((result as Record<string, unknown>).recurrenceRule).toBeUndefined();
});
it('passes through recurrenceRule when already an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: [{ '@type': 'RecurrenceRule', frequency: 'daily' }],
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
});
it('passes through null recurrenceRule as-is', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: null,
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toBeNull();
});
it('wraps a single excludedRecurrenceRule object in an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
excludedRecurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.excludedRecurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
});
});
});
+109 -1
View File
@@ -1,11 +1,16 @@
import { describe, expect, it } from 'vitest';
import type { CalendarEvent } from '@/lib/jmap/types';
import {
buildTimedFullDayWeekSegments,
buildWeekSegments,
buildAllDayDuration,
getEventDayBounds,
getEventDisplayEndDate,
getEventEndDate,
getEventStartDate,
getTimedEventBoundsForDay,
isTimedEventFullDayOnDate,
layoutOverlappingEvents,
normalizeAllDayDuration,
} from '../calendar-utils';
@@ -92,7 +97,110 @@ describe('calendar-utils all-day handling', () => {
utcEnd: '2026-03-14T11:00:00Z',
});
expectLocalDateParts(getEventDisplayEndDate(event), 2026, 3, 14, 11);
expect(getEventDisplayEndDate(event).toISOString()).toBe('2026-03-14T11:00:00.000Z');
});
it('prefers utc timestamps for timed events with an event timezone', () => {
const event = makeEvent({
start: '2026-03-15T09:00:00',
duration: 'PT1H',
timeZone: 'America/New_York',
showWithoutTime: false,
utcStart: '2026-03-15T13:00:00Z',
utcEnd: '2026-03-15T14:00:00Z',
});
expect(getEventStartDate(event).toISOString()).toBe('2026-03-15T13:00:00.000Z');
expect(getEventEndDate(event).toISOString()).toBe('2026-03-15T14:00:00.000Z');
});
it('clips timed multi-day events to the visible day bounds', () => {
const event = makeEvent({
start: '2026-03-14T22:00:00',
duration: 'PT4H',
showWithoutTime: false,
utcStart: '2026-03-14T22:00:00Z',
utcEnd: '2026-03-15T02:00:00Z',
});
expect(getTimedEventBoundsForDay(event, new Date('2026-03-14T00:00:00Z'))).toMatchObject({
startMinutes: 1380,
endMinutes: 1440,
continuesBefore: false,
continuesAfter: true,
});
expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({
startMinutes: 0,
endMinutes: 180,
continuesBefore: true,
continuesAfter: false,
});
});
it('lays out continued timed events using clipped bounds for the active day', () => {
const event = makeEvent({
start: '2026-03-14T22:00:00',
duration: 'PT4H',
showWithoutTime: false,
utcStart: '2026-03-14T22:00:00Z',
utcEnd: '2026-03-15T02:00:00Z',
});
const layout = layoutOverlappingEvents([event], new Date('2026-03-15T00:00:00Z'));
expect(layout).toHaveLength(1);
expect(layout[0]).toMatchObject({
startMinutes: 0,
endMinutes: 180,
column: 0,
totalColumns: 1,
continuesBefore: true,
continuesAfter: false,
});
});
it('detects when a timed multi-day event fully occupies an intermediate day', () => {
const event = makeEvent({
start: '2026-03-14T12:00:00',
duration: 'PT48H',
showWithoutTime: false,
utcStart: '2026-03-14T12:00:00Z',
utcEnd: '2026-03-16T12:00:00Z',
});
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-15T00:00:00Z'))).toBe(true);
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-14T00:00:00Z'))).toBe(false);
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-16T00:00:00Z'))).toBe(false);
});
it('creates week-bar segments for timed events that fully cover visible days', () => {
const week = [
new Date('2026-03-14T00:00:00Z'),
new Date('2026-03-15T00:00:00Z'),
new Date('2026-03-16T00:00:00Z'),
new Date('2026-03-17T00:00:00Z'),
new Date('2026-03-18T00:00:00Z'),
new Date('2026-03-19T00:00:00Z'),
new Date('2026-03-20T00:00:00Z'),
];
const event = makeEvent({
start: '2026-03-14T12:00:00',
duration: 'PT72H',
showWithoutTime: false,
utcStart: '2026-03-14T12:00:00Z',
utcEnd: '2026-03-17T12:00:00Z',
});
const segments = buildTimedFullDayWeekSegments([event], week);
expect(segments).toHaveLength(1);
expect(segments[0]).toMatchObject({
startIndex: 1,
span: 2,
continuesBefore: false,
continuesAfter: false,
});
});
it('normalizes imported all-day durations to day units', () => {
+238
View File
@@ -0,0 +1,238 @@
import { describe, it, expect } from 'vitest';
// ─── Inline copies of the plugin helpers (pure functions, no deps) ──────────
// These mirror the implementations in repos/plugins/jitsi-meet/src/index.js
// so we can unit-test them without esbuild bundling.
function generateRoomName(eventTitle: string): string {
const slug = eventTitle
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60);
const suffix = crypto.randomUUID().slice(0, 8);
return slug ? `${slug}-${suffix}` : suffix;
}
function buildMeetingUrl(jitsiUrl: string, roomName: string): string {
const base = jitsiUrl.replace(/\/+$/, '');
return `${base}/${encodeURIComponent(roomName)}`;
}
function base64url(input: string | ArrayBuffer): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function createJitsiJwt(options: {
secret: string;
roomName: string;
userEmail?: string;
userName?: string;
jitsiUrl: string;
}): Promise<string> {
const { secret, roomName, userEmail, userName, jitsiUrl } = options;
let domain: string;
try {
domain = new URL(jitsiUrl).hostname;
} catch {
domain = jitsiUrl;
}
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'HS256', typ: 'JWT' };
const payload: Record<string, unknown> = {
iss: 'bulwark-webmail',
sub: domain,
aud: 'jitsi',
room: roomName,
iat: now,
exp: now + 86400,
context: {
user: {
...(userName ? { name: userName } : {}),
...(userEmail ? { email: userEmail } : {}),
},
},
};
const enc = new TextEncoder();
const headerB64 = base64url(JSON.stringify(header));
const payloadB64 = base64url(JSON.stringify(payload));
const signingInput = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const signature = await crypto.subtle.sign('HMAC', key, enc.encode(signingInput));
const signatureB64 = base64url(signature);
return `${signingInput}.${signatureB64}`;
}
// ─── Tests ──────────────────────────────────────────────────────
describe('generateRoomName', () => {
it('should slugify the event title and append a random suffix', () => {
const room = generateRoomName('Team Standup');
expect(room).toMatch(/^team-standup-[a-f0-9]{8}$/);
});
it('should handle special characters', () => {
const room = generateRoomName('Q&A Session: "Ask Me Anything!"');
expect(room).toMatch(/^q-a-session-ask-me-anything-[a-f0-9]{8}$/);
});
it('should handle empty title', () => {
const room = generateRoomName('');
expect(room).toMatch(/^[a-f0-9]{8}$/);
});
it('should handle whitespace-only title', () => {
const room = generateRoomName(' ');
expect(room).toMatch(/^[a-f0-9]{8}$/);
});
it('should truncate long titles to 60 chars plus suffix', () => {
const longTitle = 'A'.repeat(100);
const room = generateRoomName(longTitle);
// 60 chars of slug + '-' + 8 char suffix = 69 max
expect(room.length).toBeLessThanOrEqual(69);
});
it('should generate unique room names for the same title', () => {
const room1 = generateRoomName('Standup');
const room2 = generateRoomName('Standup');
expect(room1).not.toBe(room2);
});
});
describe('buildMeetingUrl', () => {
it('should combine base URL and room name', () => {
const url = buildMeetingUrl('https://meet.example.com', 'my-room-abc12345');
expect(url).toBe('https://meet.example.com/my-room-abc12345');
});
it('should strip trailing slashes from base URL', () => {
const url = buildMeetingUrl('https://meet.example.com/', 'room');
expect(url).toBe('https://meet.example.com/room');
});
it('should strip multiple trailing slashes', () => {
const url = buildMeetingUrl('https://meet.example.com///', 'room');
expect(url).toBe('https://meet.example.com/room');
});
it('should URL-encode the room name', () => {
const url = buildMeetingUrl('https://meet.example.com', 'room with spaces');
expect(url).toBe('https://meet.example.com/room%20with%20spaces');
});
});
describe('createJitsiJwt', () => {
it('should create a valid HS256 JWT', async () => {
const token = await createJitsiJwt({
secret: 'test-secret-key',
roomName: 'test-room',
userEmail: 'user@example.com',
userName: 'Test User',
jitsiUrl: 'https://meet.example.com',
});
const parts = token.split('.');
expect(parts).toHaveLength(3);
const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/')));
expect(header.alg).toBe('HS256');
expect(header.typ).toBe('JWT');
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
expect(payload.iss).toBe('bulwark-webmail');
expect(payload.sub).toBe('meet.example.com');
expect(payload.aud).toBe('jitsi');
expect(payload.room).toBe('test-room');
expect(payload.context.user.name).toBe('Test User');
expect(payload.context.user.email).toBe('user@example.com');
expect(payload.exp).toBe(payload.iat + 86400);
});
it('should set the sub claim to the Jitsi hostname', async () => {
const token = await createJitsiJwt({
secret: 'secret',
roomName: 'room',
jitsiUrl: 'https://jitsi.corp.example.com/subfolder',
});
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
expect(payload.sub).toBe('jitsi.corp.example.com');
});
it('should omit undefined user fields', async () => {
const token = await createJitsiJwt({
secret: 'secret',
roomName: 'room',
jitsiUrl: 'https://meet.example.com',
});
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
expect(payload.context.user.name).toBeUndefined();
expect(payload.context.user.email).toBeUndefined();
});
it('should produce a different signature with different secrets', async () => {
const token1 = await createJitsiJwt({
secret: 'secret-one',
roomName: 'room',
jitsiUrl: 'https://meet.example.com',
});
const token2 = await createJitsiJwt({
secret: 'secret-two',
roomName: 'room',
jitsiUrl: 'https://meet.example.com',
});
expect(token1.split('.')[2]).not.toBe(token2.split('.')[2]);
});
it('should produce a verifiable HMAC-SHA256 signature', async () => {
const secret = 'my-test-secret';
const token = await createJitsiJwt({
secret,
roomName: 'verify-room',
jitsiUrl: 'https://meet.example.com',
});
const [headerB64, payloadB64, signatureB64] = token.split('.');
const signingInput = `${headerB64}.${payloadB64}`;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const sigPadded = signatureB64.replace(/-/g, '+').replace(/_/g, '/');
const sigBinary = atob(sigPadded);
const sigBytes = new Uint8Array(sigBinary.length);
for (let i = 0; i < sigBinary.length; i++) {
sigBytes[i] = sigBinary.charCodeAt(i);
}
const valid = await crypto.subtle.verify('HMAC', key, sigBytes, enc.encode(signingInput));
expect(valid).toBe(true);
});
});
+434
View File
@@ -0,0 +1,434 @@
import { describe, it, expect } from 'vitest';
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils';
import type { Mailbox } from '@/lib/jmap/types';
const makeMailbox = (overrides: Partial<Mailbox> = {}): Mailbox => ({
id: 'mb-1',
name: 'Test',
sortOrder: 0,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
},
isSubscribed: true,
...overrides,
});
/**
* Helper: walk the tree and collect { id, depth, parentId } for every node.
*/
function collectNodes(tree: MailboxNode[]): { id: string; depth: number; parentName?: string }[] {
const result: { id: string; depth: number; parentName?: string }[] = [];
const walk = (nodes: MailboxNode[], parentName?: string) => {
for (const node of nodes) {
result.push({ id: node.id, depth: node.depth, parentName });
if (node.children.length > 0) walk(node.children, node.name);
}
};
walk(tree);
return result;
}
describe('mailbox deep nesting (depth 4+)', () => {
// Reproduce the exact scenario from the bug report:
// INBOX > PRIVAT > BOOKINGS > BOOKING1/BOOKING2/FLIGHTS/HOTEL1/HOTEL2/HOTEL3
// HOTEL2 > RESTAURANT (depth 4)
it('should correctly nest the reported folder structure (depth 4)', () => {
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
makeMailbox({ id: 'booking1', name: 'BOOKING1', parentId: 'bookings' }),
makeMailbox({ id: 'booking2', name: 'BOOKING2', parentId: 'bookings' }),
makeMailbox({ id: 'flights', name: 'FLIGHTS', parentId: 'bookings' }),
makeMailbox({ id: 'hotel1', name: 'HOTEL1', parentId: 'bookings' }),
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
makeMailbox({ id: 'hotel3', name: 'HOTEL3', parentId: 'bookings' }),
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
// RESTAURANT should be at depth 4, NOT depth 0
expect(byId['restaurant'].depth).toBe(4);
// Verify the full chain of depths
expect(byId['inbox'].depth).toBe(0);
expect(byId['privat'].depth).toBe(1);
expect(byId['bookings'].depth).toBe(2);
expect(byId['hotel2'].depth).toBe(3);
expect(byId['restaurant'].depth).toBe(4);
});
it('should not orphan depth-4 folders to root level', () => {
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
];
const tree = buildMailboxTree(mailboxes);
// RESTAURANT must NOT appear at root level
const rootNames = tree.map(n => n.name);
expect(rootNames).not.toContain('RESTAURANT');
// It must be nested under HOTEL2
const nodes = collectNodes(tree);
const restaurant = nodes.find(n => n.id === 'restaurant');
expect(restaurant).toBeDefined();
expect(restaurant!.depth).toBe(4);
expect(restaurant!.parentName).toBe('HOTEL2');
});
it('should handle nesting up to depth 6', () => {
const mailboxes = [
makeMailbox({ id: 'l0', name: 'Level0', sortOrder: 0 }),
makeMailbox({ id: 'l1', name: 'Level1', parentId: 'l0', sortOrder: 0 }),
makeMailbox({ id: 'l2', name: 'Level2', parentId: 'l1', sortOrder: 0 }),
makeMailbox({ id: 'l3', name: 'Level3', parentId: 'l2', sortOrder: 0 }),
makeMailbox({ id: 'l4', name: 'Level4', parentId: 'l3', sortOrder: 0 }),
makeMailbox({ id: 'l5', name: 'Level5', parentId: 'l4', sortOrder: 0 }),
makeMailbox({ id: 'l6', name: 'Level6', parentId: 'l5', sortOrder: 0 }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
// Only 1 root node
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('Level0');
// Verify all depths are correct
for (let i = 0; i <= 6; i++) {
expect(byId[`l${i}`].depth).toBe(i);
}
});
it('should handle nesting up to depth 10 (Stalwart default max)', () => {
const mailboxes: Mailbox[] = [];
for (let i = 0; i <= 10; i++) {
mailboxes.push(
makeMailbox({
id: `level-${i}`,
name: `Folder${i}`,
parentId: i === 0 ? undefined : `level-${i - 1}`,
sortOrder: 0,
})
);
}
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
// Should have exactly 11 nodes total
expect(flat).toHaveLength(11);
// Only 1 root
expect(tree).toHaveLength(1);
// Each node at correct depth
for (let i = 0; i <= 10; i++) {
const node = flat.find(n => n.id === `level-${i}`);
expect(node).toBeDefined();
expect(node!.depth).toBe(i);
}
});
it('should correctly count children at each level in deep trees', () => {
const mailboxes = [
makeMailbox({ id: 'root', name: 'Root', sortOrder: 0 }),
makeMailbox({ id: 'a', name: 'A', parentId: 'root' }),
makeMailbox({ id: 'b', name: 'B', parentId: 'root' }),
makeMailbox({ id: 'a1', name: 'A1', parentId: 'a' }),
makeMailbox({ id: 'a2', name: 'A2', parentId: 'a' }),
makeMailbox({ id: 'a1x', name: 'A1X', parentId: 'a1' }),
makeMailbox({ id: 'a1y', name: 'A1Y', parentId: 'a1' }),
makeMailbox({ id: 'a1x_deep', name: 'A1X_DEEP', parentId: 'a1x' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
// Verify structure
expect(tree).toHaveLength(1); // just Root
expect(tree[0].children).toHaveLength(2); // A, B
// Check depths
expect(byId['root'].depth).toBe(0);
expect(byId['a'].depth).toBe(1);
expect(byId['b'].depth).toBe(1);
expect(byId['a1'].depth).toBe(2);
expect(byId['a2'].depth).toBe(2);
expect(byId['a1x'].depth).toBe(3);
expect(byId['a1y'].depth).toBe(3);
expect(byId['a1x_deep'].depth).toBe(4);
});
it('should flatten deep trees in correct parent-before-child order', () => {
const mailboxes = [
makeMailbox({ id: 'l0', name: 'L0', sortOrder: 0 }),
makeMailbox({ id: 'l1', name: 'L1', parentId: 'l0', sortOrder: 0 }),
makeMailbox({ id: 'l2', name: 'L2', parentId: 'l1', sortOrder: 0 }),
makeMailbox({ id: 'l3', name: 'L3', parentId: 'l2', sortOrder: 0 }),
makeMailbox({ id: 'l4', name: 'L4', parentId: 'l3', sortOrder: 0 }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const ids = flat.map(n => n.id);
// Parent must always come before child in the flat list
for (let i = 0; i < 4; i++) {
const parentIdx = ids.indexOf(`l${i}`);
const childIdx = ids.indexOf(`l${i + 1}`);
expect(parentIdx).toBeLessThan(childIdx);
}
});
it('should handle mailboxes provided in reverse order (children before parents)', () => {
// JMAP doesn't guarantee order - children might arrive before parents
const mailboxes = [
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
// All nodes present
expect(flat).toHaveLength(5);
// Only 1 root
expect(tree).toHaveLength(1);
// Correct depths regardless of input order
expect(byId['inbox'].depth).toBe(0);
expect(byId['privat'].depth).toBe(1);
expect(byId['bookings'].depth).toBe(2);
expect(byId['hotel2'].depth).toBe(3);
expect(byId['restaurant'].depth).toBe(4);
});
it('should handle wide + deep trees without orphaning', () => {
// Mix of wide (many siblings) and deep nesting
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
// 5 children of inbox
...Array.from({ length: 5 }, (_, i) =>
makeMailbox({ id: `child-${i}`, name: `Child${i}`, parentId: 'inbox' })
),
// Each child has 2 sub-children
...Array.from({ length: 5 }, (_, i) => [
makeMailbox({ id: `gc-${i}-0`, name: `GC${i}-0`, parentId: `child-${i}` }),
makeMailbox({ id: `gc-${i}-1`, name: `GC${i}-1`, parentId: `child-${i}` }),
]).flat(),
// Some grandchildren have great-grandchildren (depth 3)
makeMailbox({ id: 'ggc-0', name: 'GGC0', parentId: 'gc-0-0' }),
makeMailbox({ id: 'ggc-1', name: 'GGC1', parentId: 'gc-2-1' }),
// depth 4
makeMailbox({ id: 'gggc-0', name: 'GGGC0', parentId: 'ggc-0' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const rootNames = tree.map(n => n.name);
// Only INBOX should be at root
expect(rootNames).toEqual(['INBOX']);
// Total nodes
expect(flat).toHaveLength(mailboxes.length);
// Verify depth-4 node
const gggc = flat.find(n => n.id === 'gggc-0');
expect(gggc).toBeDefined();
expect(gggc!.depth).toBe(4);
});
});
describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () => {
it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
// Reporter's exact scenario: two subfolders with the same name.
// The dedup uses substring matching and removes non-role folders whose name
// matches a role folder — even if they're deep in the tree with children.
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
// User-created subfolder also named "Sent" nested under Inbox
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
// Child of the custom "Sent" folder — becomes orphaned if parent is deduped
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const rootIds = tree.map(n => n.id);
// sent-custom MUST be kept because it has children — removing it orphans sent-child
const sentCustom = flat.find(n => n.id === 'sent-custom');
expect(sentCustom).toBeDefined();
expect(sentCustom!.depth).toBe(1); // nested under Inbox
const sentChild = flat.find(n => n.id === 'sent-child');
expect(sentChild).toBeDefined();
expect(sentChild!.depth).toBe(2); // nested under sent-custom
expect(rootIds).not.toContain('sent-child'); // must NOT be orphaned at root
});
it('should keep nested folders when name is substring of a role name', () => {
// "Draft" is a substring of "Drafts" — dedup removes it, orphaning children
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
makeMailbox({ id: 'draft-folder', name: 'Draft', parentId: 'inbox' }),
makeMailbox({ id: 'draft-child', name: 'Notes', parentId: 'draft-folder' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const rootIds = tree.map(n => n.id);
const draftChild = flat.find(n => n.id === 'draft-child');
expect(draftChild).toBeDefined();
expect(draftChild!.depth).toBe(2);
expect(rootIds).not.toContain('draft-child');
});
it('should handle the exact reported structure with duplicate names at different depths', () => {
// Stalwart allows creating subfolders with the same name at different levels.
// If any of those names match a role mailbox name, dedup could remove them.
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'trash-role', name: 'Trash', role: 'trash' }),
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
// User created a subfolder named "Trash" under BOOKINGS (e.g. for old bookings)
makeMailbox({ id: 'trash-custom', name: 'Trash', parentId: 'bookings' }),
// Depth 4: child of the custom Trash folder
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'trash-custom' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const rootIds = tree.map(n => n.id);
// RESTAURANT must be at depth 4, not orphaned at root
const restaurant = flat.find(n => n.id === 'restaurant');
expect(restaurant).toBeDefined();
expect(restaurant!.depth).toBe(4);
expect(rootIds).not.toContain('restaurant');
// Custom "Trash" must be kept as it has children
const trashCustom = flat.find(n => n.id === 'trash-custom');
expect(trashCustom).toBeDefined();
expect(trashCustom!.depth).toBe(3);
});
it('should only dedup root-level non-role mailboxes that duplicate role mailboxes', () => {
// Dedup should only remove mailboxes that are BOTH:
// 1. At root level (no parentId) — same structural position as role mailbox
// 2. Name-matching a role mailbox
// Nested mailboxes with matching names should always be kept.
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level duplicate — OK to remove
makeMailbox({ id: 'proj', name: 'Projects', parentId: 'inbox' }),
makeMailbox({ id: 'sent-nested', name: 'Sent', parentId: 'proj' }), // nested — must keep
makeMailbox({ id: 'report', name: 'Report', parentId: 'sent-nested' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
// "Sent Mail" at root (no parentId) can be deduped — that's fine
// But "Sent" nested under Projects must be kept
const sentNested = flat.find(n => n.id === 'sent-nested');
expect(sentNested).toBeDefined();
expect(sentNested!.depth).toBe(2);
const report = flat.find(n => n.id === 'report');
expect(report).toBeDefined();
expect(report!.depth).toBe(3);
});
});
describe('mailbox orphan behavior (missing parent)', () => {
it('should expose orphan-to-root behavior when parent is missing', () => {
// Simulates what happens if JMAP response is incomplete (e.g., truncated at 500 objects)
// Middle parent "bookings" is missing from the response
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
// 'bookings' is MISSING — simulating truncated JMAP response
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const rootIds = tree.map(n => n.id);
// hotel2's parent ("bookings") is missing from the response.
// Current behavior: hotel2 becomes a root node (orphaned).
// This means RESTAURANT also appears under hotel2 at root, but at depth 1 instead of depth 4.
const hotel2 = flat.find(n => n.id === 'hotel2');
expect(hotel2).toBeDefined();
// Diagnostic: document that orphaning DOES happen
if (rootIds.includes('hotel2')) {
expect(hotel2!.depth).toBe(0); // orphaned at root
const restaurant = flat.find(n => n.id === 'restaurant');
expect(restaurant!.depth).toBe(1); // child of orphaned root
console.warn(
'CONFIRMED: Missing intermediate parent causes orphan-to-root. ' +
'hotel2 (parentId: "bookings") is at root with depth 0 instead of depth 3.'
);
}
});
it('should expose orphan behavior with multiple missing parents in chain', () => {
// Deep chain where two intermediate parents are missing
const mailboxes = [
makeMailbox({ id: 'root', name: 'Root', sortOrder: 0 }),
// 'level1' is MISSING
// 'level2' is MISSING
makeMailbox({ id: 'level3', name: 'Level3', parentId: 'level2' }),
makeMailbox({ id: 'level4', name: 'Level4', parentId: 'level3' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
const rootIds = tree.map(n => n.id);
// level3's parent (level2) is missing → level3 becomes root
expect(rootIds).toContain('level3');
// level4 is correctly nested under level3 (since level3 IS in the map)
const level4 = flat.find(n => n.id === 'level4');
expect(level4).toBeDefined();
expect(level4!.depth).toBe(1); // child of orphaned level3 (depth 0)
console.warn(
'CONFIRMED: Missing parents in chain cause subtree to float to root. ' +
'Level3 (expected depth 2) is at depth 0, Level4 (expected depth 3) is at depth 1.'
);
});
});
+2
View File
@@ -52,6 +52,8 @@ function resetStore() {
'settings-section': [],
'context-menu-email': [],
'navigation-rail-bottom': [],
'calendar-event-actions': [],
'admin-plugin-page': [],
},
initialized: false,
});
+379
View File
@@ -0,0 +1,379 @@
import { describe, it, expect } from 'vitest';
import { expandRecurringEvents } from '../recurrence-expansion';
import type { CalendarEvent } from '@/lib/jmap/types';
/** Helper: create a minimal CalendarEvent for testing recurrence */
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'evt1',
uid: 'uid1',
calendarIds: { cal1: true },
start: '2025-01-06T09:00:00', // Monday
duration: 'PT1H',
title: 'Test Event',
showWithoutTime: false,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
...overrides,
} as CalendarEvent;
}
function expand(event: CalendarEvent, rangeStart: string, rangeEnd: string) {
return expandRecurringEvents([event], rangeStart, rangeEnd);
}
function starts(events: CalendarEvent[]) {
return events.map(e => e.start);
}
describe('expandRecurringEvents', () => {
it('passes through non-recurring events unchanged', () => {
const event = makeEvent();
const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('evt1');
});
// -----------------------------------------------------------------------
// Daily
// -----------------------------------------------------------------------
describe('daily frequency', () => {
it('expands daily events within range', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-07T09:00:00',
'2025-01-08T09:00:00',
]);
});
it('respects interval', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', interval: 2 } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-12T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-08T09:00:00',
'2025-01-10T09:00:00',
]);
});
it('respects count', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 3 } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
expect(result).toHaveLength(3);
});
it('respects until', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', until: '2025-01-08T09:00:00' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
expect(result).toHaveLength(3);
});
});
// -----------------------------------------------------------------------
// Weekly
// -----------------------------------------------------------------------
describe('weekly frequency', () => {
it('expands weekly with implicit byDay (same weekday as start)', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
// Jan 6 is Monday, so every Monday
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-13T09:00:00',
'2025-01-20T09:00:00',
'2025-01-27T09:00:00',
]);
});
it('expands weekly with explicit byDay (MWF)', () => {
const event = makeEvent({
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'weekly',
byDay: [{ day: 'mo' }, { day: 'we' }, { day: 'fr' }],
} as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-08T09:00:00',
'2025-01-10T09:00:00',
]);
});
it('expands weekly with interval=2', () => {
const event = makeEvent({
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'weekly',
interval: 2,
byDay: [{ day: 'mo' }],
} as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-02-03T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-20T09:00:00',
]);
});
});
// -----------------------------------------------------------------------
// Monthly
// -----------------------------------------------------------------------
describe('monthly frequency', () => {
it('expands monthly with implicit byMonthDay', () => {
const event = makeEvent({
start: '2025-01-15T10:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'monthly' } as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
expect(starts(result)).toEqual([
'2025-01-15T10:00:00',
'2025-02-15T10:00:00',
'2025-03-15T10:00:00',
]);
});
it('expands monthly with byMonthDay', () => {
const event = makeEvent({
start: '2025-01-01T08:00:00',
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byMonthDay: [1, 15],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-02-28T00:00:00');
expect(starts(result)).toEqual([
'2025-01-01T08:00:00',
'2025-01-15T08:00:00',
'2025-02-01T08:00:00',
'2025-02-15T08:00:00',
]);
});
it('expands monthly with negative byMonthDay (-1 = last day)', () => {
const event = makeEvent({
start: '2025-01-31T08:00:00',
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byMonthDay: [-1],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-01-31', '2025-02-28', '2025-03-31']);
});
it('expands monthly with byDay + nthOfPeriod (2nd Tuesday)', () => {
const event = makeEvent({
start: '2025-01-14T09:00:00', // 2nd Tuesday
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'tu', nthOfPeriod: 2 }],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-01-14', '2025-02-11', '2025-03-11']);
});
it('expands monthly with byDay nthOfPeriod=-1 (last Friday)', () => {
const event = makeEvent({
start: '2025-01-31T09:00:00', // last Friday of Jan
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-01-31', '2025-02-28', '2025-03-28']);
});
});
// -----------------------------------------------------------------------
// Yearly
// -----------------------------------------------------------------------
describe('yearly frequency', () => {
it('expands yearly on the same date', () => {
const event = makeEvent({
start: '2023-03-15T12:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'yearly' } as any],
});
const result = expand(event, '2023-01-01T00:00:00', '2026-01-01T00:00:00');
expect(starts(result)).toEqual([
'2023-03-15T12:00:00',
'2024-03-15T12:00:00',
'2025-03-15T12:00:00',
]);
});
it('expands yearly with byMonth and byDay (last Friday of November = Thanksgiving-ish)', () => {
const event = makeEvent({
start: '2025-11-28T09:00:00', // last Friday of Nov 2025
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'yearly',
byMonth: ['11'],
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
// Last Friday of November: 2025-11-28, 2026-11-27, 2027-11-26
expect(days).toEqual(['2025-11-28', '2026-11-27', '2027-11-26']);
});
it('expands yearly with byMonth + byMonthDay', () => {
const event = makeEvent({
start: '2025-07-04T00:00:00',
showWithoutTime: true,
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'yearly',
byMonth: ['7'],
byMonthDay: [4],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
expect(result).toHaveLength(3);
});
});
// -----------------------------------------------------------------------
// bySetPosition
// -----------------------------------------------------------------------
describe('bySetPosition', () => {
it('selects first and last from monthly byDay expansion', () => {
const event = makeEvent({
start: '2025-01-06T10:00:00',
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'mo' }, { day: 'tu' }, { day: 'we' }, { day: 'th' }, { day: 'fr' }],
bySetPosition: [1, -1], // first and last weekday of month
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-03-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
// Jan: first weekday = Jan 1 (Wed), last weekday = Jan 31 (Fri)
// Feb: first weekday = Feb 3 (Mon), last weekday = Feb 28 (Fri)
// But event starts Jan 6, so Jan 1 is before start → filtered out
expect(days).toContain('2025-01-31');
expect(days).toContain('2025-02-03');
expect(days).toContain('2025-02-28');
});
});
// -----------------------------------------------------------------------
// Recurrence overrides
// -----------------------------------------------------------------------
describe('recurrenceOverrides', () => {
it('applies overrides to matching occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceOverrides: {
'2025-01-07T09:00:00': { title: 'Modified' },
},
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
const modified = result.find(e => e.recurrenceId === '2025-01-07T09:00:00');
expect(modified?.title).toBe('Modified');
});
it('excludes occurrences marked as excluded', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceOverrides: {
'2025-01-07T09:00:00': { excluded: true } as any,
},
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
expect(result).toHaveLength(2);
expect(starts(result)).toEqual(['2025-01-06T09:00:00', '2025-01-08T09:00:00']);
});
it('adds RDATE-style overrides not generated by rules', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
recurrenceOverrides: {
'2025-01-08T09:00:00': { title: 'Extra Wednesday' }, // Not a Monday
},
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-14T00:00:00');
expect(result.some(e => e.recurrenceId === '2025-01-08T09:00:00')).toBe(true);
});
});
// -----------------------------------------------------------------------
// All-day events
// -----------------------------------------------------------------------
describe('all-day events', () => {
it('expands all-day weekly events', () => {
const event = makeEvent({
start: '2025-01-06T00:00:00',
showWithoutTime: true,
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
expect(result).toHaveLength(4); // 4 Mondays: 6, 13, 20, 27
});
});
// -----------------------------------------------------------------------
// Edge cases
// -----------------------------------------------------------------------
describe('edge cases', () => {
it('does not exceed 500 occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2030-01-01T00:00:00');
expect(result.length).toBeLessThanOrEqual(500);
});
it('handles invalid start date gracefully', () => {
const event = makeEvent({
start: 'invalid',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00');
expect(result).toHaveLength(0);
});
it('generates synthetic IDs for occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].id).toBe('evt1:2025-01-06T09:00:00');
expect(result[1].id).toBe('evt1:2025-01-07T09:00:00');
});
it('preserves originalId pointing to master', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].originalId).toBe('evt1');
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { findReplyIdentityId } from '../reply-identity';
import type { Identity } from '../jmap/types';
const identities: Identity[] = [
{
id: 'primary',
name: 'Harry Primary',
email: 'harry@primary.com',
mayDelete: false,
},
{
id: 'secondary',
name: 'Harry Secondary',
email: 'harry@secondary.com',
mayDelete: false,
},
];
describe('findReplyIdentityId', () => {
it('matches the identity that received the original message', () => {
const selected = findReplyIdentityId(identities, {
to: [{ email: 'harry@secondary.com' }],
});
expect(selected).toBe('secondary');
});
it('matches case-insensitively across recipients', () => {
const selected = findReplyIdentityId(identities, {
cc: [{ email: 'HARRY@PRIMARY.COM' }],
});
expect(selected).toBe('primary');
});
it('falls back to sub-address matching when needed', () => {
const selected = findReplyIdentityId(identities, {
to: [{ email: 'harry+news@secondary.com' }],
});
expect(selected).toBe('secondary');
});
it('returns null when no reply recipient matches an identity', () => {
const selected = findReplyIdentityId(identities, {
to: [{ email: 'other@example.com' }],
});
expect(selected).toBeNull();
});
});
+2
View File
@@ -11,6 +11,7 @@ import { useFilterStore } from '@/stores/filter-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store';
import { useSmimeStore } from '@/stores/smime-store';
// Minimal snapshot shapes — we only capture what we need
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -117,6 +118,7 @@ export function clearAllStores(): void {
useVacationStore.getState().clearState();
useCalendarStore.getState().clearState();
useFilterStore.getState().clearState();
useSmimeStore.getState().clearState();
}
/** Evict cached state for one account */
+81
View File
@@ -0,0 +1,81 @@
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
function getPluginConfigDir(): string {
return path.join(getAdminDir(), 'plugin-config');
}
function configPath(pluginId: string): string {
return path.join(getPluginConfigDir(), `${pluginId}.json`);
}
async function ensureDir(dir: string): Promise<void> {
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
}
/**
* Get all config for a plugin.
*/
export async function getPluginConfig(pluginId: string): Promise<Record<string, unknown>> {
try {
const raw = await readFile(configPath(pluginId), 'utf-8');
return JSON.parse(raw);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
logger.warn(`Failed to read plugin config for ${pluginId}`, {
error: error instanceof Error ? error.message : 'Unknown error',
});
return {};
}
}
/**
* Set a single config key for a plugin.
*/
export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise<void> {
const dir = getPluginConfigDir();
await ensureDir(dir);
const config = await getPluginConfig(pluginId);
config[key] = value;
const filePath = configPath(pluginId);
const tmpPath = filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
await rename(tmpPath, filePath);
}
/**
* Delete a single config key for a plugin.
*/
export async function deletePluginConfigKey(pluginId: string, key: string): Promise<void> {
const config = await getPluginConfig(pluginId);
delete config[key];
if (Object.keys(config).length === 0) {
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
return;
}
const dir = getPluginConfigDir();
await ensureDir(dir);
const filePath = configPath(pluginId);
const tmpPath = filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
await rename(tmpPath, filePath);
}
/**
* Delete all config for a plugin (used when uninstalling).
*/
export async function deleteAllPluginConfig(pluginId: string): Promise<void> {
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
}
+11
View File
@@ -17,6 +17,16 @@ function getThemesDir(): string {
// ─── Types ───────────────────────────────────────────────────
export interface PluginConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
label: string;
description?: string;
required?: boolean;
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
}
export interface ServerPlugin {
id: string;
name: string;
@@ -28,6 +38,7 @@ export interface ServerPlugin {
entrypoint: string;
enabled: boolean;
forceEnabled?: boolean;
configSchema?: Record<string, PluginConfigField>;
installedAt: string;
updatedAt: string;
}
+22 -1
View File
@@ -8,9 +8,17 @@ const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
);
}
return createHash('sha256').update(secret).digest();
}
@@ -116,11 +124,24 @@ export async function clearAdminSessionCookie(): Promise<void> {
/**
* Get the client IP from the request headers.
*
* Proxies typically *append* to X-Forwarded-For, so the last entry
* before our trusted proxy is the most reliable client IP. When a
* single reverse proxy sits in front of the app the rightmost entry
* is the one added by that proxy. We take the rightmost entry to
* avoid trusting attacker-controlled values prepended to the header.
*
* If you run behind multiple trusted proxies, set TRUSTED_PROXY_DEPTH
* to the number of trusted proxies (default 1).
*/
export function getClientIP(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0].trim();
const parts = forwarded.split(',').map(s => s.trim()).filter(Boolean);
const depth = Math.max(1, parseInt(process.env.TRUSTED_PROXY_DEPTH || '1', 10));
// Take the entry at position (length - depth), clamped to 0
const index = Math.max(0, parts.length - depth);
return parts[index] || '0.0.0.0';
}
return request.headers.get('x-real-ip') || '0.0.0.0';
}
+3
View File
@@ -37,6 +37,7 @@ export interface FeatureGates {
debugModeEnabled: boolean;
folderIconsEnabled: boolean;
hoverActionsConfigEnabled: boolean;
filesEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -54,6 +55,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
debugModeEnabled: true,
folderIconsEnabled: true,
hoverActionsConfigEnabled: true,
filesEnabled: true,
};
export interface ThemePolicy {
@@ -120,6 +122,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
+18
View File
@@ -0,0 +1,18 @@
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
export function getActiveAccountSlot(): number | null {
const authState = useAuthStore.getState();
const accountState = useAccountStore.getState();
const activeAccountId = authState.activeAccountId ?? accountState.activeAccountId;
const activeAccount = activeAccountId
? accountState.getAccountById(activeAccountId)
: accountState.getActiveAccount();
return typeof activeAccount?.cookieSlot === 'number' ? activeAccount.cookieSlot : null;
}
export function getActiveAccountSlotHeaders(): Record<string, string> {
const slot = getActiveAccountSlot();
return slot === null ? {} : { 'X-JMAP-Cookie-Slot': String(slot) };
}
+8
View File
@@ -5,9 +5,17 @@ const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
);
}
return createHash('sha256').update(secret).digest();
}
+80
View File
@@ -0,0 +1,80 @@
const VERIFY_TIMEOUT_MS = 10000;
export class JmapAuthVerificationError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'JmapAuthVerificationError';
this.status = status;
}
}
function isSupportedProtocol(protocol: string): boolean {
return protocol === 'http:' || protocol === 'https:';
}
export function normalizeJmapServerUrl(serverUrl: string): string {
let url: URL;
try {
url = new URL(serverUrl);
} catch {
throw new JmapAuthVerificationError('Invalid server URL', 400);
}
if (!isSupportedProtocol(url.protocol)) {
throw new JmapAuthVerificationError('Unsupported server URL protocol', 400);
}
url.hash = '';
url.search = '';
return url.toString().replace(/\/+$/, '');
}
export function validateProxyAuthHeader(authHeader: string): void {
if (!/^(?:Basic|Bearer)\s+\S+$/i.test(authHeader)) {
throw new JmapAuthVerificationError('Invalid Authorization header', 400);
}
}
export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise<string> {
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
validateProxyAuthHeader(authHeader);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
try {
const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, {
method: 'GET',
headers: { Authorization: authHeader },
signal: controller.signal,
});
if (!response.ok) {
throw new JmapAuthVerificationError(
response.status === 401 || response.status === 403
? 'Authentication failed'
: 'Failed to verify JMAP session',
response.status === 401 || response.status === 403 ? 401 : 502,
);
}
const session = await response.json().catch(() => null) as { apiUrl?: unknown; accounts?: unknown } | null;
if (!session || typeof session.apiUrl !== 'string' || typeof session.accounts !== 'object' || session.accounts === null) {
throw new JmapAuthVerificationError('Invalid JMAP session response', 502);
}
return normalizedServerUrl;
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
throw error;
}
if (error instanceof Error && error.name === 'AbortError') {
throw new JmapAuthVerificationError('JMAP session verification timed out', 504);
}
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
} finally {
clearTimeout(timeout);
}
}
+46 -4
View File
@@ -74,15 +74,57 @@ export function isAllDayEventLike(event: Pick<Partial<CalendarEvent>, 'start' |
&& end.getMilliseconds() === 0;
}
/**
* Stalwart returns "recurrenceRule" (singular) instead of RFC 8984 "recurrenceRules" (plural).
* Normalize server responses to match the client's internal type.
*/
function normalizeStalwartPropertyNames<T extends Partial<CalendarEvent>>(event: T): T {
const raw = event as Record<string, unknown>;
let patched = false;
const updates: Partial<CalendarEvent> = {};
if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) {
// JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object,
// but Stalwart may also return it as an array (for JMAP-created events).
// Normalize both forms to our internal array type.
const val = raw.recurrenceRule;
if (val != null && !Array.isArray(val) && typeof val === 'object') {
updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules'];
} else {
updates.recurrenceRules = val as CalendarEvent['recurrenceRules'];
}
patched = true;
}
if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) {
const val = raw.excludedRecurrenceRule;
if (val != null && !Array.isArray(val) && typeof val === 'object') {
updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules'];
} else {
updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules'];
}
patched = true;
}
if (!patched) return event;
const result = { ...event, ...updates } as T;
delete (result as Record<string, unknown>).recurrenceRule;
delete (result as Record<string, unknown>).excludedRecurrenceRule;
return result;
}
export function normalizeCalendarEventLike<T extends Partial<CalendarEvent>>(event: T): T {
if (!isAllDayEventLike(event)) {
return event;
// First normalize Stalwart's singular property names to RFC 8984 plural forms
const normalized = normalizeStalwartPropertyNames(event);
if (!isAllDayEventLike(normalized)) {
return normalized;
}
return {
...event,
...normalized,
showWithoutTime: true,
duration: normalizeAllDayDurationValue(event.duration),
duration: normalizeAllDayDurationValue(normalized.duration),
} as T;
}
+149 -38
View File
@@ -1,4 +1,4 @@
import { differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
import { parseDuration } from "@/components/calendar/event-card";
import type { CalendarEvent } from "@/lib/jmap/types";
@@ -11,15 +11,63 @@ export interface CalendarWeekSegment {
continuesAfter: boolean;
}
export interface TimedEventLayout {
event: CalendarEvent;
column: number;
totalColumns: number;
startMinutes: number;
endMinutes: number;
continuesBefore: boolean;
continuesAfter: boolean;
}
export function getEventStartDate(
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
): Date {
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
return parseISO(source);
}
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
rawSegments.sort((left, right) => {
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
if (left.span !== right.span) return right.span - left.span;
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
return left.event.showWithoutTime ? -1 : 1;
}
const timeDiff = getEventStartDate(left.event).getTime() - getEventStartDate(right.event).getTime();
if (timeDiff !== 0) return timeDiff;
return (left.event.title || "").localeCompare(right.event.title || "");
});
const rowEndIndices: number[] = [];
return rawSegments.map((segment) => {
const segmentEndIndex = segment.startIndex + segment.span - 1;
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
if (row === -1) {
row = rowEndIndices.length;
rowEndIndices.push(segmentEndIndex);
} else {
rowEndIndices[row] = segmentEndIndex;
}
return { ...segment, row };
});
}
export function getEventEndDate(event: CalendarEvent): Date {
const start = parseISO(event.start);
if (!event.showWithoutTime && event.utcEnd) {
return parseISO(event.utcEnd);
}
const start = getEventStartDate(event);
if (!event.duration) return start;
return new Date(start.getTime() + parseDuration(event.duration) * 60000);
}
export function getEventDisplayEndDate(event: CalendarEvent): Date {
const end = getEventEndDate(event);
if (!event.showWithoutTime || end.getTime() <= parseISO(event.start).getTime()) {
const start = getEventStartDate(event);
if (!event.showWithoutTime || end.getTime() <= start.getTime()) {
return end;
}
return subMilliseconds(end, 1);
@@ -27,11 +75,44 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } {
return {
startDay: startOfDay(parseISO(event.start)),
startDay: startOfDay(getEventStartDate(event)),
endDay: startOfDay(getEventDisplayEndDate(event)),
};
}
export function getTimedEventBoundsForDay(
event: CalendarEvent,
day: Date,
): { startMinutes: number; endMinutes: number; continuesBefore: boolean; continuesAfter: boolean } | null {
if (event.showWithoutTime) return null;
const eventStart = getEventStartDate(event);
const eventEnd = getEventEndDate(event);
const dayStart = startOfDay(day);
const nextDayStart = addDays(dayStart, 1);
if (eventEnd <= dayStart || eventStart >= nextDayStart) {
return null;
}
const clippedStart = eventStart > dayStart ? eventStart : dayStart;
const clippedEnd = eventEnd < nextDayStart ? eventEnd : nextDayStart;
const startMinutes = Math.max(0, Math.floor((clippedStart.getTime() - dayStart.getTime()) / 60000));
const endMinutes = Math.min(1440, Math.ceil((clippedEnd.getTime() - dayStart.getTime()) / 60000));
return {
startMinutes,
endMinutes,
continuesBefore: eventStart < dayStart,
continuesAfter: eventEnd > nextDayStart,
};
}
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
const bounds = getTimedEventBoundsForDay(event, day);
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
}
export function normalizeAllDayDuration(duration: string | undefined): string | undefined {
if (!duration) return undefined;
const totalMinutes = parseDuration(duration);
@@ -44,7 +125,7 @@ export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
return `P${dayCount}D`;
}
export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
export function buildWeekSegmentsRaw(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
if (weekDays.length === 0) return [];
const weekStart = startOfDay(weekDays[0]);
@@ -71,59 +152,89 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca
} satisfies CalendarWeekSegment];
});
rawSegments.sort((left, right) => {
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
if (left.span !== right.span) return right.span - left.span;
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
return left.event.showWithoutTime ? -1 : 1;
return rawSegments;
}
export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
return packWeekSegments(buildWeekSegmentsRaw(events, weekDays));
}
export function buildTimedFullDayWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
if (weekDays.length === 0) return [];
const rawSegments = events.flatMap((event) => {
const fullDayIndices = weekDays
.map((day, index) => (isTimedEventFullDayOnDate(event, day) ? index : -1))
.filter((index) => index >= 0);
if (fullDayIndices.length === 0) {
return [];
}
const timeDiff = parseISO(left.event.start).getTime() - parseISO(right.event.start).getTime();
if (timeDiff !== 0) return timeDiff;
return (left.event.title || "").localeCompare(right.event.title || "");
const segments: CalendarWeekSegment[] = [];
let rangeStart = fullDayIndices[0];
let previousIndex = fullDayIndices[0];
const pushSegment = (startIndex: number, endIndex: number) => {
const startDay = weekDays[startIndex];
const endDay = weekDays[endIndex];
segments.push({
event,
startIndex,
span: endIndex - startIndex + 1,
row: -1,
continuesBefore: isTimedEventFullDayOnDate(event, addDays(startDay, -1)),
continuesAfter: isTimedEventFullDayOnDate(event, addDays(endDay, 1)),
});
};
for (let index = 1; index < fullDayIndices.length; index++) {
const currentIndex = fullDayIndices[index];
if (currentIndex !== previousIndex + 1) {
pushSegment(rangeStart, previousIndex);
rangeStart = currentIndex;
}
previousIndex = currentIndex;
}
pushSegment(rangeStart, previousIndex);
return segments;
});
const rowEndIndices: number[] = [];
return rawSegments.map((segment) => {
const segmentEndIndex = segment.startIndex + segment.span - 1;
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
if (row === -1) {
row = rowEndIndices.length;
rowEndIndices.push(segmentEndIndex);
} else {
rowEndIndices[row] = segmentEndIndex;
}
return { ...segment, row };
});
return packWeekSegments(rawSegments);
}
export function layoutOverlappingEvents(
events: CalendarEvent[],
): { event: CalendarEvent; column: number; totalColumns: number }[] {
const sorted = [...events].sort((a, b) => {
const diff = parseISO(a.start).getTime() - parseISO(b.start).getTime();
day: Date,
): TimedEventLayout[] {
const layoutInputs = events.flatMap((event) => {
const bounds = getTimedEventBoundsForDay(event, day);
return bounds ? [{ event, ...bounds }] : [];
});
const sorted = layoutInputs.sort((a, b) => {
const diff = a.startMinutes - b.startMinutes;
if (diff !== 0) return diff;
return parseDuration(b.duration) - parseDuration(a.duration);
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
});
const columns: { event: CalendarEvent; end: number }[][] = [];
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
const result: TimedEventLayout[] = [];
for (const event of sorted) {
const start = parseISO(event.start);
const startMin = start.getHours() * 60 + start.getMinutes();
const endMin = Math.min(1440, startMin + Math.max(15, parseDuration(event.duration)));
let placed = false;
for (let col = 0; col < columns.length; col++) {
if (columns[col].every(e => e.end <= startMin)) {
columns[col].push({ event, end: endMin });
result.push({ event, column: col, totalColumns: 0 });
if (columns[col].every(e => e.end <= event.startMinutes)) {
columns[col].push({ event: event.event, end: event.endMinutes });
result.push({ ...event, column: col, totalColumns: 0 });
placed = true;
break;
}
}
if (!placed) {
columns.push([{ event, end: endMin }]);
result.push({ event, column: columns.length - 1, totalColumns: 0 });
columns.push([{ event: event.event, end: event.endMinutes }]);
result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
}
}
+57 -24
View File
@@ -1,25 +1,53 @@
import { useSettingsStore } from '@/stores/settings-store';
import type { DebugCategory } from '@/stores/settings-store';
/**
* Debug logger that respects the debugMode setting.
* Check if debug logging is enabled, optionally for a specific category.
* When a category is provided, both debugMode AND that category must be enabled.
*/
function isEnabled(category?: DebugCategory): boolean {
const state = useSettingsStore.getState();
if (!state.debugMode) return false;
if (!category) return true;
return state.debugCategories?.[category] !== false;
}
/**
* Debug logger that respects the debugMode setting and category filters.
* Use this instead of console.log for conditional debug output.
*
* Each method accepts an optional category as the first argument.
* When a category is provided, the message only logs if that category is enabled
* in Settings > Advanced > Debug Categories.
*
* Usage:
* debug.log('calendar', 'Event created', event); // Only logs when 'calendar' category is on
* debug.log('Uncategorized message'); // Logs whenever debugMode is on
*/
export const debug = {
/**
* Log a debug message (only when debugMode is enabled)
* Log a debug message (only when debugMode is enabled and category is active)
*/
log: (...args: unknown[]) => {
if (useSettingsStore.getState().debugMode) {
console.log('[DEBUG]', ...args);
log: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
if (isEnabled(categoryOrMsg)) {
console.log(`[DEBUG:${categoryOrMsg}]`, ...args);
}
} else if (isEnabled()) {
console.log('[DEBUG]', categoryOrMsg, ...args);
}
},
/**
* Log a warning message (only when debugMode is enabled)
* Log a warning message (only when debugMode is enabled and category is active)
*/
warn: (...args: unknown[]) => {
if (useSettingsStore.getState().debugMode) {
console.warn('[DEBUG]', ...args);
warn: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
if (isEnabled(categoryOrMsg)) {
console.warn(`[DEBUG:${categoryOrMsg}]`, ...args);
}
} else if (isEnabled()) {
console.warn('[DEBUG]', categoryOrMsg, ...args);
}
},
@@ -31,11 +59,11 @@ export const debug = {
},
/**
* Start a collapsed console group (only when debugMode is enabled)
* Start a collapsed console group (only when debugMode is enabled and category is active)
*/
group: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.group(`[DEBUG] ${label}`);
group: (label: string, category?: DebugCategory) => {
if (isEnabled(category)) {
console.group(`[DEBUG${category ? ':' + category : ''}] ${label}`);
}
},
@@ -43,35 +71,40 @@ export const debug = {
* End a console group (only when debugMode is enabled)
*/
groupEnd: () => {
if (useSettingsStore.getState().debugMode) {
if (isEnabled()) {
console.groupEnd();
}
},
/**
* Start a performance timer (only when debugMode is enabled)
* Start a performance timer (only when debugMode is enabled and category is active)
*/
time: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.time(`[DEBUG] ${label}`);
time: (label: string, category?: DebugCategory) => {
if (isEnabled(category)) {
console.time(`[DEBUG${category ? ':' + category : ''}] ${label}`);
}
},
/**
* End a performance timer (only when debugMode is enabled)
*/
timeEnd: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.timeEnd(`[DEBUG] ${label}`);
timeEnd: (label: string, category?: DebugCategory) => {
if (isEnabled(category)) {
console.timeEnd(`[DEBUG${category ? ':' + category : ''}] ${label}`);
}
},
/**
* Log a table (only when debugMode is enabled)
* Log a table (only when debugMode is enabled and category is active)
*/
table: (data: unknown) => {
if (useSettingsStore.getState().debugMode) {
table: (data: unknown, category?: DebugCategory) => {
if (isEnabled(category)) {
console.table(data);
}
}
};
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']);
function isCategoryKey(value: string): value is DebugCategory {
return CATEGORY_KEYS.has(value);
}
+12
View File
@@ -41,6 +41,9 @@ export class DemoJMAPClient implements IJMAPClient {
getServerUrl(): string { return 'https://demo.example.com'; }
getAuthHeader(): string { return 'Bearer demo-token'; }
updateAccessToken(): void { /* no-op */ }
upgradeToBearer(): void { /* no-op */ }
enableTotpReauth(): void { /* no-op */ }
updateBasicAuth(): void { /* no-op */ }
getAccountId(): string { return 'demo-account'; }
getUsername(): string { return 'demo@example.com'; }
@@ -597,6 +600,15 @@ export class DemoJMAPClient implements IJMAPClient {
return full;
}
async batchCreateCalendarEvents(events: Partial<CalendarEvent>[]): Promise<{ created: CalendarEvent[]; failed: string[] }> {
const created: CalendarEvent[] = [];
for (const event of events) {
const full = await this.createCalendarEvent(event);
created.push(full);
}
return { created, failed: [] };
}
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
const event = this.data.calendarEvents.find(e => e.id === eventId);
if (!event) throw new Error('Event not found');
+3 -2
View File
@@ -11,9 +11,10 @@ export const EMAIL_SANITIZE_CONFIG = {
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: true,
// Allow blob: URIs so authenticated inline images (CID) are not stripped
// Allow blob: URIs so authenticated inline images (CID) are not stripped.
// data: is restricted to image/* MIME types to prevent SVG script injection.
// eslint-disable-next-line no-useless-escape
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
FORBID_TAGS: [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'meta', 'link', 'base',
+4
View File
@@ -19,6 +19,9 @@ export interface IJMAPClient {
getServerUrl(): string;
getAuthHeader(): string;
updateAccessToken(token: string): void;
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void;
enableTotpReauth(basePassword: string, callback: () => Promise<string | null>): void;
updateBasicAuth(newPassword: string): void;
getAccountId(): string;
getUsername(): string;
@@ -193,6 +196,7 @@ export interface IJMAPClient {
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
batchCreateCalendarEvents(events: Partial<CalendarEvent>[], targetAccountId?: string): Promise<{ created: CalendarEvent[]; failed: string[] }>;
updateCalendarEvent(
eventId: string,
updates: Partial<CalendarEvent>,
+635 -62
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -660,6 +660,7 @@ export interface CalendarEventFilter {
attendee?: string;
participationStatus?: string;
uid?: string;
types?: string[];
}
// JMAP Push Notification Types (RFC 8620 Section 7)
+2 -2
View File
@@ -31,7 +31,7 @@ function playFile(file: string) {
const audio = new Audio(file);
audio.volume = 0.3;
audio.play().catch((e) => {
debug.log('Could not play audio file, falling back to beep:', e);
debug.log('push', 'Could not play audio file, falling back to beep:', e);
playBeep();
});
}
@@ -47,6 +47,6 @@ export function playNotificationSound(sound?: NotificationSoundChoice) {
playBeep();
}
} catch (e) {
debug.log('Could not play notification sound:', e);
debug.log('push', 'Could not play notification sound:', e);
}
}
+81 -1
View File
@@ -11,11 +11,13 @@ import type {
SidebarWidget,
ContextMenuItem,
KeyboardShortcut,
AdminPageSection,
CalendarEventAction,
SlotName,
} from './plugin-types';
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
import {
emailHooks, calendarHooks, contactHooks, fileHooks,
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
@@ -116,6 +118,8 @@ export interface PluginAPI {
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
registerCalendarEventAction: (action: CalendarEventAction) => Disposable;
registerAdminPage: (page: AdminPageSection) => Disposable;
};
hooks: PluginHooksAPI;
toast: {
@@ -126,6 +130,12 @@ export interface PluginAPI {
};
storage: ReturnType<typeof createPluginStorage>;
log: ReturnType<typeof createPluginLogger>;
admin: {
getConfig: (key: string) => Promise<unknown>;
getAllConfig: () => Promise<Record<string, unknown>>;
setConfig: (key: string, value: unknown) => Promise<void>;
deleteConfig: (key: string) => Promise<void>;
};
}
// Simplified hooks API type (all hooks return Disposable)
@@ -176,6 +186,9 @@ export interface PluginHooksAPI {
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
// Calendar Form
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
// Contacts
onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -321,6 +334,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write',
@@ -407,6 +421,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(emailHooks)),
// Calendar
...Object.fromEntries(Object.entries(calendarHooks)),
// Calendar Form
...Object.fromEntries(Object.entries(calendarFormHooks)),
// Contacts
...Object.fromEntries(Object.entries(contactHooks)),
// Files
@@ -581,6 +597,38 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
requirePermission(plugin, 'ui:navigation-rail');
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100);
},
registerCalendarEventAction: (action: CalendarEventAction) => {
requirePermission(plugin, 'ui:calendar-action');
const Component = (props: Record<string, unknown>) => {
const externals = getPluginExternals();
const React = externals?.React;
if (!React) return null;
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
const iconSpan = createElement('span', {
'aria-hidden': 'true',
style: { display: 'contents' },
dangerouslySetInnerHTML: {
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="8" width="14" height="12" rx="2"/></svg>',
},
});
return createElement('button', {
onClick: () => action.onClick(
props.eventData as import('./plugin-types').CalendarEventFormView,
{ setVirtualLocation: props.setVirtualLocation as (url: string) => void },
),
className: 'inline-flex items-center gap-1.5 h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background cursor-pointer',
title: action.label,
type: 'button',
}, iconSpan, action.label);
};
return registerSlot(plugin.id, 'calendar-event-actions', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
},
registerAdminPage: (page: AdminPageSection) => {
requirePermission(plugin, 'ui:admin-page');
return registerSlot(plugin.id, 'admin-plugin-page', page.render as React.ComponentType<Record<string, unknown>>, 100);
},
},
hooks,
@@ -594,5 +642,37 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
storage: createPluginStorage(plugin.id),
log: createPluginLogger(plugin.id),
admin: {
getConfig: async (key: string) => {
requirePermission(plugin, 'admin:config');
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
if (!res.ok) return null;
const data = await res.json();
return data[key] ?? null;
},
getAllConfig: async () => {
requirePermission(plugin, 'admin:config');
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
if (!res.ok) return {};
return res.json();
},
setConfig: async (key: string, value: unknown) => {
requirePermission(plugin, 'admin:config');
await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
},
deleteConfig: async (key: string) => {
requirePermission(plugin, 'admin:config');
await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
},
},
};
}
+7 -1
View File
@@ -218,6 +218,12 @@ export const calendarHooks = {
onCalendarAlertAcknowledge: new HookBus(),
};
// §7.2b Calendar Form Hooks (UI integration)
export const calendarFormHooks = {
onCalendarEventFormOpen: new HookBus(),
onCalendarEventFormSave: new HookBus(),
};
// §7.3 Contact Hooks
export const contactHooks = {
onContactOpen: new HookBus(),
@@ -396,7 +402,7 @@ export const sidebarAppHooks = {
// ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [
emailHooks, calendarHooks, contactHooks, fileHooks,
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
+32 -1
View File
@@ -94,7 +94,9 @@ export type SlotName =
| 'email-detail-sidebar'
| 'settings-section'
| 'context-menu-email'
| 'navigation-rail-bottom';
| 'navigation-rail-bottom'
| 'calendar-event-actions'
| 'admin-plugin-page';
export interface SlotRegistration {
pluginId: string;
@@ -147,6 +149,32 @@ export interface ContextMenuItem {
order?: number;
}
export interface AdminPageSection {
id: string;
label: string;
icon?: string;
render: React.ComponentType;
}
export interface CalendarEventAction {
id: string;
label: string;
icon?: string;
onClick: (eventData: CalendarEventFormView, helpers: { setVirtualLocation: (url: string) => void }) => void;
order?: number;
}
export interface CalendarEventFormView {
title: string;
description: string;
start: string;
end: string;
isAllDay: boolean;
location: string;
virtualLocation: string;
calendarId: string;
}
export interface KeyboardShortcut {
id: string;
keys: string;
@@ -377,6 +405,8 @@ export const ALL_PERMISSIONS = [
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
'ui:calendar-action', 'ui:admin-page',
'admin:config',
'app:lifecycle',
] as const;
@@ -397,6 +427,7 @@ export const ALLOWED_PLUGIN_FILES = new Set([
export const DISALLOWED_CSS_PATTERNS = [
/@import\b/i,
/url\s*\(\s*['"]?https?:/i,
/url\s*\(\s*['"]?data:/i,
/expression\s*\(/i,
/javascript\s*:/i,
/-moz-binding/i,
+659
View File
@@ -0,0 +1,659 @@
/**
* Client-side recurrence expansion for JSCalendar events.
*
* Implements JSCalendar 2.0 (draft-ietf-calext-jscalendarbis-15) §3.3.3.1
* recurrence rule interpretation algorithm with full byX filtering,
* implicit byX property addition, and bySetPosition support.
*
* Stalwart does not yet support mutations on the synthetic IDs produced by
* CalendarEvent/query?expandRecurrences=true, so we fetch raw events (with
* real, mutable IDs) and expand recurring series into individual occurrences
* in the browser.
*/
import { parseISO, format, addDays, addWeeks, addMonths, addYears } from 'date-fns';
import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types';
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
const INDEX_TO_DAY: string[] = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
/**
* Given a master event and a date range, return an array of "virtual" occurrence
* events. Each occurrence carries a synthetic `id` (for dedup in React) and a
* `masterEventId` field that points back to the real server-side ID so the
* store can use it for mutations.
*
* Non-recurring events are returned as-is. For recurring events the master is
* **not** returned only expanded instances within the range.
*/
export function expandRecurringEvents(
events: CalendarEvent[],
rangeStart: string,
rangeEnd: string,
): CalendarEvent[] {
const start = parseISO(rangeStart);
const end = parseISO(rangeEnd);
const result: CalendarEvent[] = [];
for (const event of events) {
if (!event.recurrenceRules?.length) {
result.push(event);
continue;
}
const occurrences = expandEvent(event, start, end);
result.push(...occurrences);
}
return result;
}
function expandEvent(
master: CalendarEvent,
rangeStart: Date,
rangeEnd: Date,
): CalendarEvent[] {
const eventStart = parseISO(master.start);
if (isNaN(eventStart.getTime())) return [];
const rules = master.recurrenceRules || [];
const overrides = master.recurrenceOverrides || {};
const occurrences: CalendarEvent[] = [];
const seenDates = new Set<string>();
for (const rule of rules) {
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
for (const date of dates) {
const dateKey = master.showWithoutTime
? format(date, 'yyyy-MM-dd')
: date.toISOString();
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
const recurrenceId = master.showWithoutTime
? format(date, "yyyy-MM-dd'T'00:00:00")
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
const override = overrides[recurrenceId] as (Partial<CalendarEvent> & { excluded?: boolean }) | undefined;
if (override?.excluded) continue;
occurrences.push(createOccurrence(master, date, recurrenceId, override));
}
}
// Add overrides that define new dates not generated by rules (RDATE equivalent)
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
const override = rawOverride as Partial<CalendarEvent> & { excluded?: boolean };
if (override.excluded) continue;
const overrideDate = parseISO(recurrenceId);
if (isNaN(overrideDate.getTime())) continue;
if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue;
const dateKey = master.showWithoutTime
? format(overrideDate, 'yyyy-MM-dd')
: overrideDate.toISOString();
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
occurrences.push(createOccurrence(master, overrideDate, recurrenceId, override));
}
return occurrences;
}
function createOccurrence(
master: CalendarEvent,
date: Date,
recurrenceId: string,
override?: Partial<CalendarEvent>,
): CalendarEvent {
const startStr = master.showWithoutTime
? format(date, "yyyy-MM-dd'T'00:00:00")
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
return {
...master,
...(override || {}),
id: `${master.id}:${recurrenceId}`,
originalId: master.originalId || master.id,
uid: master.uid,
calendarIds: master.calendarIds,
start: (override?.start) || startStr,
recurrenceId,
recurrenceRules: master.recurrenceRules,
recurrenceOverrides: master.recurrenceOverrides,
excludedRecurrenceRules: master.excludedRecurrenceRules,
};
}
// ---------------------------------------------------------------------------
// §3.3.3.1 — Implicit byX property addition
// ---------------------------------------------------------------------------
function addImplicitByX(
rule: CalendarRecurrenceRule,
eventStart: Date,
): CalendarRecurrenceRule {
const r = { ...rule };
const freq = r.frequency;
// bySecond
if (freq !== 'secondly' && !r.bySecond?.length) {
r.bySecond = [eventStart.getSeconds()];
}
// byMinute
if (freq !== 'secondly' && freq !== 'minutely' && !r.byMinute?.length) {
r.byMinute = [eventStart.getMinutes()];
}
// byHour
if (freq !== 'secondly' && freq !== 'minutely' && freq !== 'hourly' && !r.byHour?.length) {
r.byHour = [eventStart.getHours()];
}
// weekly: implicit byDay
if (freq === 'weekly' && !r.byDay?.length) {
r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
}
// monthly: implicit byMonthDay
if (freq === 'monthly' && !r.byDay?.length && !r.byMonthDay?.length) {
r.byMonthDay = [eventStart.getDate()];
}
// yearly: implicit byMonth / byMonthDay / byDay
if (freq === 'yearly' && !r.byYearDay?.length) {
if (!r.byMonth?.length && !r.byWeekNo?.length && (r.byMonthDay?.length || !r.byDay?.length)) {
r.byMonth = [String(eventStart.getMonth() + 1)];
}
if (!r.byMonthDay?.length && !r.byWeekNo?.length && !r.byDay?.length) {
r.byMonthDay = [eventStart.getDate()];
}
if (r.byWeekNo?.length && !r.byMonthDay?.length && !r.byDay?.length) {
r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
}
}
return r;
}
// ---------------------------------------------------------------------------
// §3.3.3.1 — Generate occurrence dates
// ---------------------------------------------------------------------------
function generateDates(
eventStart: Date,
rawRule: CalendarRecurrenceRule,
rangeStart: Date,
rangeEnd: Date,
): Date[] {
const rule = addImplicitByX(rawRule, eventStart);
const dates: Date[] = [];
const interval = rule.interval || 1;
const countLimit = rule.count || Infinity;
const until = rule.until ? parseISO(rule.until) : null;
let totalCount = 0;
let current = new Date(eventStart);
const maxIterations = 2000;
const maxOccurrences = 500;
let iterations = 0;
while (iterations++ < maxIterations) {
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (until && current > until) break;
// For frequencies that produce one candidate per iteration at a time,
// we can stop when we pass rangeEnd. But for frequencies that expand
// into multiple candidates per period, we need the candidate generation.
if (current >= rangeEnd && rule.frequency !== 'yearly' && rule.frequency !== 'monthly'
&& rule.frequency !== 'weekly') break;
// Generate candidates for the current period, then filter
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
// Apply bySetPosition if present
const filtered = rule.bySetPosition?.length
? applyBySetPosition(candidates, rule.bySetPosition)
: candidates;
for (const d of filtered) {
if (until && d > until) break;
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
// Spec rule 4: eliminate dates before event start
if (d < eventStart) continue;
totalCount++;
if (d >= rangeStart && d < rangeEnd) {
dates.push(d);
}
if (d >= rangeEnd) break;
}
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
current = advancePeriod(current, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
if (current <= eventStart && iterations === 1) {
// Safety: ensure we don't go backward
current = advancePeriod(eventStart, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
}
}
// Spec rule 1: the initial start date-time is ALWAYS the first occurrence
if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
if (eventStart >= rangeStart && eventStart < rangeEnd) {
// Check it's not already in the list
if (!dates.some(d => d.getTime() === eventStart.getTime())) {
dates.unshift(eventStart);
}
}
}
return dates;
}
// ---------------------------------------------------------------------------
// Generate all candidates within one period, filtered by byX properties
// ---------------------------------------------------------------------------
function generateCandidatesForPeriod(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const freq = rule.frequency;
let candidates: Date[];
// Step 1: Generate base candidates based on frequency + expansion byX
switch (freq) {
case 'yearly':
candidates = expandYearly(periodStart, rule, eventStart);
break;
case 'monthly':
candidates = expandMonthly(periodStart, rule, eventStart);
break;
case 'weekly':
candidates = expandWeekly(periodStart, rule, eventStart);
break;
case 'daily':
case 'hourly':
case 'minutely':
case 'secondly':
candidates = [new Date(periodStart)];
break;
default:
candidates = [new Date(periodStart)];
}
// Step 2: Filter candidates by all applicable byX constraints
candidates = candidates.filter(d => matchesByX(d, rule));
candidates.sort((a, b) => a.getTime() - b.getTime());
return candidates;
}
// ---------------------------------------------------------------------------
// Yearly expansion
// ---------------------------------------------------------------------------
function expandYearly(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const year = periodStart.getFullYear();
const h = eventStart.getHours();
const m = eventStart.getMinutes();
const s = eventStart.getSeconds();
let dates: Date[] = [];
// Determine which months to iterate
const months = rule.byMonth?.length
? rule.byMonth.map(ms => parseInt(ms.replace('L', ''), 10) - 1)
: [eventStart.getMonth()];
if (rule.byWeekNo?.length) {
// Expand by ISO week numbers
for (const wn of rule.byWeekNo) {
const weekDates = datesInISOWeek(year, wn, rule.firstDayOfWeek || 'mo');
dates.push(...weekDates.map(d => { d.setHours(h, m, s, 0); return d; }));
}
} else if (rule.byYearDay?.length) {
// Expand by day-of-year
for (const yd of rule.byYearDay) {
const d = dayOfYear(year, yd);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
} else if (rule.byDay?.length && rule.byMonthDay?.length) {
// Both byDay and byMonthDay: expand byMonthDay in each month, then byDay filters later
for (const mo of months) {
for (const md of rule.byMonthDay) {
const d = resolveMonthDay(year, mo, md);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
}
} else if (rule.byDay?.length) {
// byDay with nthOfPeriod in yearly context = nth weekday of year or month
for (const mo of months) {
const expanded = expandByDayInMonth(year, mo, rule.byDay, h, m, s);
dates.push(...expanded);
}
} else if (rule.byMonthDay?.length) {
for (const mo of months) {
for (const md of rule.byMonthDay) {
const d = resolveMonthDay(year, mo, md);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
}
} else {
// Simple yearly: same date each year
for (const mo of months) {
const d = new Date(year, mo, eventStart.getDate(), h, m, s, 0);
if (d.getMonth() === mo) dates.push(d);
}
}
return dates;
}
// ---------------------------------------------------------------------------
// Monthly expansion
// ---------------------------------------------------------------------------
function expandMonthly(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const year = periodStart.getFullYear();
const month = periodStart.getMonth();
const h = eventStart.getHours();
const m = eventStart.getMinutes();
const s = eventStart.getSeconds();
const dates: Date[] = [];
if (rule.byDay?.length) {
const expanded = expandByDayInMonth(year, month, rule.byDay, h, m, s);
dates.push(...expanded);
} else if (rule.byMonthDay?.length) {
for (const md of rule.byMonthDay) {
const d = resolveMonthDay(year, month, md);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
} else {
// Implicit byMonthDay already added, but as fallback:
const d = new Date(year, month, eventStart.getDate(), h, m, s, 0);
if (d.getMonth() === month) dates.push(d);
}
return dates;
}
// ---------------------------------------------------------------------------
// Weekly expansion
// ---------------------------------------------------------------------------
function expandWeekly(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const dates: Date[] = [];
const baseDay = periodStart.getDay();
const byDay = rule.byDay?.length ? rule.byDay : [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
for (const { day } of byDay) {
const targetDay = DAY_INDEX[day];
if (targetDay === undefined) continue;
let diff = targetDay - baseDay;
if (diff < 0) diff += 7;
const d = addDays(periodStart, diff);
d.setHours(eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), 0);
dates.push(d);
}
return dates.sort((a, b) => a.getTime() - b.getTime());
}
// ---------------------------------------------------------------------------
// byX matching (Step 2 of §3.3.3.1)
// ---------------------------------------------------------------------------
function matchesByX(date: Date, rule: CalendarRecurrenceRule): boolean {
if (rule.byMonth?.length) {
const month = String(date.getMonth() + 1);
if (!rule.byMonth.some(m => m.replace('L', '') === month)) return false;
}
if (rule.byWeekNo?.length) {
const wn = getISOWeekNumber(date);
const weeksInYear = getISOWeeksInYear(date.getFullYear());
if (!rule.byWeekNo.some(w => (w > 0 ? w : weeksInYear + 1 + w) === wn)) return false;
}
if (rule.byYearDay?.length) {
const yd = getDayOfYear(date);
const daysInYear = isLeapYear(date.getFullYear()) ? 366 : 365;
if (!rule.byYearDay.some(d => (d > 0 ? d : daysInYear + 1 + d) === yd)) return false;
}
if (rule.byMonthDay?.length) {
const md = date.getDate();
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
if (!rule.byMonthDay.some(d => (d > 0 ? d : daysInMonth + 1 + d) === md)) return false;
}
if (rule.byDay?.length) {
const dayName = INDEX_TO_DAY[date.getDay()];
const freq = rule.frequency;
if (!rule.byDay.some(nd => {
if (nd.day !== dayName) return false;
if (nd.nthOfPeriod == null) return true;
if (freq === 'monthly') {
return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod);
}
if (freq === 'yearly') {
// When byMonth is present, nthOfPeriod scopes to the month (iCalendar semantics)
if (rule.byMonth?.length) {
return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod);
}
return nd.nthOfPeriod === nthWeekdayInYear(date, nd.nthOfPeriod);
}
return true;
})) return false;
}
if (rule.byHour?.length) {
if (!rule.byHour.includes(date.getHours())) return false;
}
if (rule.byMinute?.length) {
if (!rule.byMinute.includes(date.getMinutes())) return false;
}
if (rule.bySecond?.length) {
if (!rule.bySecond.includes(date.getSeconds())) return false;
}
return true;
}
// ---------------------------------------------------------------------------
// bySetPosition (Step 3 of §3.3.3.1)
// ---------------------------------------------------------------------------
function applyBySetPosition(dates: Date[], positions: number[]): Date[] {
if (!dates.length) return dates;
const result: Date[] = [];
const len = dates.length;
for (const pos of positions) {
const idx = pos > 0 ? pos - 1 : len + pos;
if (idx >= 0 && idx < len) {
result.push(dates[idx]);
}
}
return result.sort((a, b) => a.getTime() - b.getTime());
}
// ---------------------------------------------------------------------------
// Advance to the start of the next period
// ---------------------------------------------------------------------------
function advancePeriod(
date: Date,
frequency: CalendarRecurrenceRule['frequency'],
interval: number,
_firstDayOfWeek: string,
): Date {
switch (frequency) {
case 'daily': return addDays(date, interval);
case 'weekly': return addWeeks(date, interval);
case 'monthly': return addMonths(date, interval);
case 'yearly': return addYears(date, interval);
case 'hourly': return new Date(date.getTime() + interval * 3600000);
case 'minutely': return new Date(date.getTime() + interval * 60000);
case 'secondly': return new Date(date.getTime() + interval * 1000);
default: return addDays(date, interval);
}
}
// ---------------------------------------------------------------------------
// Helper: expand byDay within a specific month (monthly or yearly context)
// ---------------------------------------------------------------------------
function expandByDayInMonth(
year: number,
month: number,
byDay: CalendarNDay[],
h: number, m: number, s: number,
): Date[] {
const dates: Date[] = [];
for (const { day, nthOfPeriod } of byDay) {
const targetDay = DAY_INDEX[day];
if (targetDay === undefined) continue;
if (nthOfPeriod != null && nthOfPeriod !== 0) {
const d = nthWeekdayOfMonth(year, month, targetDay, nthOfPeriod);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
} else {
// Every occurrence of this weekday in the month
let d = new Date(year, month, 1);
while (d.getDay() !== targetDay) d = addDays(d, 1);
while (d.getMonth() === month) {
const occ = new Date(d);
occ.setHours(h, m, s, 0);
dates.push(occ);
d = addDays(d, 7);
}
}
}
return dates;
}
// ---------------------------------------------------------------------------
// Helper: find nth weekday of month
// ---------------------------------------------------------------------------
function nthWeekdayOfMonth(
year: number,
month: number,
weekday: number,
nth: number,
): Date | null {
if (nth > 0) {
let d = new Date(year, month, 1);
while (d.getDay() !== weekday) d = addDays(d, 1);
d = addDays(d, (nth - 1) * 7);
return d.getMonth() === month ? d : null;
} else {
let d = new Date(year, month + 1, 0); // last day of month
while (d.getDay() !== weekday) d = addDays(d, -1);
if (nth < -1) d = addDays(d, (nth + 1) * 7);
return d.getMonth() === month ? d : null;
}
}
// ---------------------------------------------------------------------------
// Helper: check if date is the nth (or nth-last) weekday in its month
// ---------------------------------------------------------------------------
function nthWeekdayInMonth(date: Date, nth: number): number {
if (nth > 0) {
// Count from start: which occurrence of this weekday is it?
return Math.floor((date.getDate() - 1) / 7) + 1;
} else {
// Count from end
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
return -(Math.floor((daysInMonth - date.getDate()) / 7) + 1);
}
}
// ---------------------------------------------------------------------------
// Helper: check if date is the nth weekday in its year
// ---------------------------------------------------------------------------
function nthWeekdayInYear(date: Date, nth: number): number {
const yd = getDayOfYear(date);
const weekday = date.getDay();
if (nth > 0) {
// Find first occurrence of this weekday in the year
const jan1 = new Date(date.getFullYear(), 0, 1);
let first = jan1;
while (first.getDay() !== weekday) first = addDays(first, 1);
const firstYd = getDayOfYear(first);
return Math.floor((yd - firstYd) / 7) + 1;
} else {
const daysInYear = isLeapYear(date.getFullYear()) ? 366 : 365;
const dec31 = new Date(date.getFullYear(), 11, 31);
let last = dec31;
while (last.getDay() !== weekday) last = addDays(last, -1);
const lastYd = getDayOfYear(last);
return -(Math.floor((lastYd - yd) / 7) + 1);
}
}
// ---------------------------------------------------------------------------
// Helper: resolve negative/positive byMonthDay to a real date
// ---------------------------------------------------------------------------
function resolveMonthDay(year: number, month: number, day: number): Date | null {
const daysInMonth = new Date(year, month + 1, 0).getDate();
const actualDay = day > 0 ? day : daysInMonth + 1 + day;
if (actualDay < 1 || actualDay > daysInMonth) return null;
return new Date(year, month, actualDay);
}
// ---------------------------------------------------------------------------
// Helper: day of year (1-indexed)
// ---------------------------------------------------------------------------
function getDayOfYear(date: Date): number {
const start = new Date(date.getFullYear(), 0, 0);
const diff = date.getTime() - start.getTime();
return Math.floor(diff / 86400000);
}
// ---------------------------------------------------------------------------
// Helper: day-of-year to Date
// ---------------------------------------------------------------------------
function dayOfYear(year: number, yd: number): Date | null {
const daysInYear = isLeapYear(year) ? 366 : 365;
const actual = yd > 0 ? yd : daysInYear + 1 + yd;
if (actual < 1 || actual > daysInYear) return null;
const d = new Date(year, 0, actual);
return d;
}
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
// ---------------------------------------------------------------------------
// ISO week helpers
// ---------------------------------------------------------------------------
function getISOWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
function getISOWeeksInYear(year: number): number {
const dec28 = new Date(Date.UTC(year, 11, 28));
return getISOWeekNumber(dec28);
}
function datesInISOWeek(year: number, weekNo: number, _firstDayOfWeek: string): Date[] {
const weeksInYear = getISOWeeksInYear(year);
const actual = weekNo > 0 ? weekNo : weeksInYear + 1 + weekNo;
if (actual < 1 || actual > weeksInYear) return [];
// Find Monday of ISO week 1
const jan4 = new Date(year, 0, 4);
const dayOfWeek = jan4.getDay() || 7; // Monday=1 ... Sunday=7
const week1Monday = addDays(jan4, 1 - dayOfWeek);
const targetMonday = addDays(week1Monday, (actual - 1) * 7);
const dates: Date[] = [];
for (let i = 0; i < 7; i++) {
dates.push(addDays(targetMonday, i));
}
return dates;
}
+62
View File
@@ -0,0 +1,62 @@
import type { Identity } from '@/lib/jmap/types';
interface ReplyRecipient {
email?: string | null;
}
interface ReplyRecipients {
to?: ReplyRecipient[];
cc?: ReplyRecipient[];
bcc?: ReplyRecipient[];
}
function normalizeEmailAddress(email: string): string {
return email.trim().toLowerCase();
}
function normalizeBaseEmailAddress(email: string): string {
const normalized = normalizeEmailAddress(email);
const atIndex = normalized.indexOf('@');
if (atIndex <= 0) {
return normalized;
}
const localPart = normalized.slice(0, atIndex);
const domain = normalized.slice(atIndex + 1);
const plusIndex = localPart.indexOf('+');
return `${plusIndex >= 0 ? localPart.slice(0, plusIndex) : localPart}@${domain}`;
}
export function findReplyIdentityId(
identities: Identity[],
recipients?: ReplyRecipients,
): string | null {
if (identities.length === 0 || !recipients) {
return null;
}
const receivedAddresses = [
...(recipients.to || []),
...(recipients.cc || []),
...(recipients.bcc || []),
]
.map((recipient) => recipient.email?.trim())
.filter((email): email is string => Boolean(email));
if (receivedAddresses.length === 0) {
return null;
}
const exactMatches = new Set(receivedAddresses.map(normalizeEmailAddress));
const exactIdentity = identities.find((identity) => exactMatches.has(normalizeEmailAddress(identity.email)));
if (exactIdentity) {
return exactIdentity.id;
}
const baseMatches = new Set(receivedAddresses.map(normalizeBaseEmailAddress));
const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email)));
return baseIdentity?.id ?? null;
}
+1 -1
View File
@@ -147,7 +147,7 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
for (const rule of enabledRules) {
if (rule.conditions.length === 0 || rule.actions.length === 0) {
debug.warn(`Skipping rule "${rule.name}": empty conditions or actions`);
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
continue;
}
+1 -1
View File
@@ -109,7 +109,7 @@ export function parseScript(content: string): ParseResult {
try {
metadata = JSON.parse(jsonStr);
} catch (e) {
debug.warn('Failed to parse Sieve metadata JSON:', e);
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
return OPAQUE;
}
+46
View File
@@ -148,4 +148,50 @@ describe('key-storage', () => {
expect(certs.find(c => c.id === id)).toBeUndefined();
});
});
describe('accountId filtering', () => {
it('listKeyRecords filters by accountId', async () => {
const id1 = uid();
const id2 = uid();
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
const acct1Records = await listKeyRecords('acct-1');
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
expect(acct1Records.find(r => r.id === id2)).toBeUndefined();
});
it('listKeyRecords includes records without accountId when filtering', async () => {
const id1 = uid();
const id2 = uid();
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com` }));
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
const acct1Records = await listKeyRecords('acct-1');
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
expect(acct1Records.find(r => r.id === id2)).toBeDefined();
});
it('listPublicCerts filters by accountId', async () => {
const id1 = uid();
const id2 = uid();
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
const acct1Certs = await listPublicCerts('acct-1');
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
expect(acct1Certs.find(c => c.id === id2)).toBeUndefined();
});
it('listPublicCerts includes certs without accountId when filtering', async () => {
const id1 = uid();
const id2 = uid();
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com` }));
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
const acct1Certs = await listPublicCerts('acct-1');
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
expect(acct1Certs.find(c => c.id === id2)).toBeDefined();
});
});
});
+3 -1
View File
@@ -57,7 +57,9 @@ beforeEach(() => {
defaultSignIdentity: {},
defaultEncrypt: false,
rememberUnlockedKeys: false,
autoImportSignerCerts: false,
autoImportSignerCerts: true,
accountPreferences: {},
currentAccountId: null,
isLoading: false,
error: null,
});
+6
View File
@@ -274,6 +274,12 @@ export function getLinerCryptoEngine(): pkijs.CryptoEngine {
return linerEngine!;
}
/** Get the webcrypto-liner Crypto instance (for importKey with legacy algorithms). */
export function getLinerCrypto(): Crypto {
ensureLiner();
return linerCryptoInstance!;
}
/**
* Run an async operation with the global PKI.js engine set to webcrypto-liner,
* then restore the previous engine afterwards.
+26 -9
View File
@@ -1,22 +1,35 @@
import type { SmimeKeyRecord, SmimePublicCert } from './types';
const DB_NAME = 'smime-store';
const DB_VERSION = 1;
const DB_VERSION = 2;
const KEY_RECORDS_STORE = 'key-records';
const PUBLIC_CERTS_STORE = 'public-certs';
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains(KEY_RECORDS_STORE)) {
const oldVersion = event.oldVersion;
if (oldVersion < 1) {
const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
keyStore.createIndex('email', 'email', { unique: false });
}
if (!db.objectStoreNames.contains(PUBLIC_CERTS_STORE)) {
keyStore.createIndex('accountId', 'accountId', { unique: false });
const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
certStore.createIndex('email', 'email', { unique: false });
certStore.createIndex('accountId', 'accountId', { unique: false });
}
if (oldVersion >= 1 && oldVersion < 2) {
// Add accountId index to existing stores
const tx = request.transaction!;
const keyStore = tx.objectStore(KEY_RECORDS_STORE);
if (!keyStore.indexNames.contains('accountId')) {
keyStore.createIndex('accountId', 'accountId', { unique: false });
}
const certStore = tx.objectStore(PUBLIC_CERTS_STORE);
if (!certStore.indexNames.contains('accountId')) {
certStore.createIndex('accountId', 'accountId', { unique: false });
}
}
};
request.onsuccess = () => resolve(request.result);
@@ -62,9 +75,11 @@ export async function getKeyRecordForEmail(email: string): Promise<SmimeKeyRecor
});
}
export async function listKeyRecords(): Promise<SmimeKeyRecord[]> {
export async function listKeyRecords(accountId?: string): Promise<SmimeKeyRecord[]> {
const db = await openDB();
return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
const all = await txPromise<SmimeKeyRecord[]>(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
if (!accountId) return all;
return all.filter((r) => r.accountId === accountId || !r.accountId);
}
export async function deleteKeyRecord(id: string): Promise<void> {
@@ -90,9 +105,11 @@ export async function getPublicCertForEmail(email: string): Promise<SmimePublicC
});
}
export async function listPublicCerts(): Promise<SmimePublicCert[]> {
export async function listPublicCerts(accountId?: string): Promise<SmimePublicCert[]> {
const db = await openDB();
return txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
const all = await txPromise<SmimePublicCert[]>(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
if (!accountId) return all;
return all.filter((c) => c.accountId === accountId || !c.accountId);
}
export async function deletePublicCert(id: string): Promise<void> {
+42 -3
View File
@@ -5,7 +5,7 @@ import {
classifyCapabilities,
} from './certificate-utils';
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
import { withLinerEngine } from './crypto-engine';
import { withLinerEngine, getLinerCrypto } from './crypto-engine';
const KDF_ITERATIONS = 600_000;
const AES_KEY_LENGTH = 256;
@@ -217,6 +217,8 @@ async function encryptPrivateKey(
export interface UnlockedKeyPair {
signingKey: CryptoKey;
decryptionKey?: CryptoKey;
/** Key imported via webcrypto-liner as RSAES-PKCS1-v1_5 for legacy S/MIME (3DES) messages */
legacyDecryptionKey?: CryptoKey;
}
/** Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys for signing and decryption. */
@@ -257,7 +259,22 @@ export async function unlockPrivateKey(
} catch {
// Key may only support decryption (key-encipherment-only cert)
const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
return { signingKey: decryptionKey, decryptionKey };
let legacyDecryptionKey: CryptoKey | undefined;
if (!isEcdsa) {
try {
const linerCrypto = getLinerCrypto();
legacyDecryptionKey = await linerCrypto.subtle.importKey(
'pkcs8',
pkcs8Bytes,
{ name: 'RSAES-PKCS1-v1_5' },
false,
['decrypt'],
);
} catch {
// webcrypto-liner may not be available
}
}
return { signingKey: decryptionKey, decryptionKey, legacyDecryptionKey };
}
// Also import for decryption (separate CryptoKey handle required by Web Crypto)
@@ -268,7 +285,29 @@ export async function unlockPrivateKey(
// Key may only support signing (digitalSignature-only cert)
}
return { signingKey, decryptionKey };
// Import a legacy decryption key via webcrypto-liner for RSAES-PKCS1-v1_5 key transport
// (used by older S/MIME messages encrypted with 3DES, RC2, etc.)
let legacyDecryptionKey: CryptoKey | undefined;
if (!isEcdsa) {
try {
const linerCrypto = getLinerCrypto();
legacyDecryptionKey = await linerCrypto.subtle.importKey(
'pkcs8',
pkcs8Bytes,
{ name: 'RSAES-PKCS1-v1_5' },
false,
['decrypt'],
);
console.debug('[S/MIME] legacy RSAES-PKCS1-v1_5 key imported successfully:', {
algorithm: legacyDecryptionKey.algorithm,
usages: legacyDecryptionKey.usages,
});
} catch (err) {
console.warn('[S/MIME] legacy RSAES-PKCS1-v1_5 key import failed:', err);
}
}
return { signingKey, decryptionKey, legacyDecryptionKey };
}
/** Get decrypted PKCS#8 bytes (for export flow). */
+49 -6
View File
@@ -15,8 +15,10 @@ export interface DecryptionInput {
cmsBytes: ArrayBuffer;
/** All imported key records to try matching against */
keyRecords: SmimeKeyRecord[];
/** Unlocked CryptoKey map: keyRecordId → CryptoKey */
/** Unlocked CryptoKey map: keyRecordId → CryptoKey (RSA-OAEP) */
unlockedKeys: Map<string, CryptoKey>;
/** Unlocked legacy CryptoKey map: keyRecordId → CryptoKey (RSAES-PKCS1-v1_5 via webcrypto-liner) */
legacyUnlockedKeys?: Map<string, CryptoKey>;
}
export interface DecryptionResult {
@@ -34,12 +36,24 @@ export interface DecryptionResult {
* @throws Error if no matching key is found, key is locked, or decryption fails
*/
export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> {
const { cmsBytes, keyRecords, unlockedKeys } = input;
const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input;
// Parse the CMS ContentInfo wrapper
const contentInfo = parseContentInfo(cmsBytes);
const envelopedData = extractEnvelopedData(contentInfo);
// Log CMS algorithm details for diagnostics
const contentEncOid = envelopedData.encryptedContentInfo?.contentEncryptionAlgorithm?.algorithmId;
const recipientAlgs = envelopedData.recipientInfos?.map((ri) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ri as any).value?.keyEncryptionAlgorithm?.algorithmId as string | undefined,
);
console.debug('[S/MIME] CMS algorithms:', {
contentEncryption: contentEncOid,
keyTransport: recipientAlgs,
legacyKeysAvailable: legacyUnlockedKeys?.size ?? 0,
});
// Find matching key records
const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords);
@@ -51,6 +65,19 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
for (const { keyRecord, recipientIndex } of matchedRecords) {
const privateKey = unlockedKeys.get(keyRecord.id);
if (!privateKey) {
// Try legacy key (RSAES-PKCS1-v1_5) if no RSA-OAEP key
const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
if (legacyKey) {
try {
const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
return {
mimeBytes: new Uint8Array(decrypted),
keyRecordId: keyRecord.id,
};
} catch {
continue;
}
}
continue; // Key exists but isn't unlocked — skip, caller should unlock first
}
@@ -60,16 +87,32 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
mimeBytes: new Uint8Array(decrypted),
keyRecordId: keyRecord.id,
};
} catch {
// This key didn't work, try the next one
} catch (oaepError) {
// RSA-OAEP key didn't work, try legacy RSAES-PKCS1-v1_5 key
console.debug('[S/MIME] RSA-OAEP decrypt failed:', oaepError instanceof Error ? oaepError.message : oaepError);
const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
console.debug('[S/MIME] legacy key available:', !!legacyKey, legacyKey ? { algorithm: (legacyKey as CryptoKey).algorithm } : undefined);
if (legacyKey) {
try {
const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
return {
mimeBytes: new Uint8Array(decrypted),
keyRecordId: keyRecord.id,
};
} catch (legacyError) {
// Legacy key also didn't work, try the next record
console.debug('[S/MIME] RSAES-PKCS1-v1_5 decrypt also failed:', legacyError instanceof Error ? legacyError.message : legacyError);
}
}
continue;
}
}
// Check if we had matching records but none were unlocked
const hasLockedMatch = matchedRecords.some(m => !unlockedKeys.has(m.keyRecord.id));
const isUnlocked = (id: string) => unlockedKeys.has(id) || (legacyUnlockedKeys?.has(id) ?? false);
const hasLockedMatch = matchedRecords.some(m => !isUnlocked(m.keyRecord.id));
if (hasLockedMatch) {
const lockedRecord = matchedRecords.find(m => !unlockedKeys.has(m.keyRecord.id))!;
const lockedRecord = matchedRecords.find(m => !isUnlocked(m.keyRecord.id))!;
throw new SmimeKeyLockedError(
'S/MIME key is locked. Unlock it to decrypt this message.',
lockedRecord.keyRecord.id,
+9 -3
View File
@@ -1,8 +1,8 @@
/**
* Verify CMS SignedData (opaque signed) and extract the inner content.
*
* v1 performs cryptographic signature validation and cert validity checks
* but does NOT implement full trust-chain or revocation validation.
* Performs cryptographic signature validation, cert validity checks,
* and trust-chain verification.
*/
import * as pkijs from 'pkijs';
@@ -61,7 +61,7 @@ export async function smimeVerify(
const verifyResult = await signedData.verify(
{
signer: 0,
checkChain: false, // v1: no trust-chain validation
checkChain: true,
},
cryptoEngine,
);
@@ -108,6 +108,11 @@ export async function smimeVerify(
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
}
// Detect self-signed certificates (issuer === subject)
const issuerDer = new Uint8Array(signerCert.issuer.toSchema().toBER(false));
const subjectDer = new Uint8Array(signerCert.subject.toSchema().toBER(false));
const selfSigned = arraysEqual(issuerDer, subjectDer);
return {
mimeBytes: innerContent,
status: {
@@ -117,6 +122,7 @@ export async function smimeVerify(
signatureError,
signerCert: signerPublicCert,
signerEmailMatch,
selfSigned,
},
};
}
+4
View File
@@ -1,6 +1,7 @@
/** Stored record for an imported S/MIME private key + certificate. */
export interface SmimeKeyRecord {
id: string;
accountId?: string;
email: string;
certificate: ArrayBuffer; // DER-encoded X.509 leaf cert
certificateChain: ArrayBuffer[]; // DER-encoded intermediates
@@ -34,6 +35,7 @@ export interface SmimeUnlockedKey {
/** A recipient or contact public certificate. */
export interface SmimePublicCert {
id: string;
accountId?: string;
email: string;
certificate: ArrayBuffer; // DER-encoded X.509
issuer: string;
@@ -53,6 +55,8 @@ export interface SmimeStatus {
signatureError?: string;
signerCert?: SmimePublicCert;
signerEmailMatch?: boolean;
/** True when the signer certificate is self-signed (not chained to a trusted CA). */
selfSigned?: boolean;
decryptionSuccess?: boolean;
decryptionError?: string;
unsupportedReason?: string;
+75
View File
@@ -0,0 +1,75 @@
import { cookies } from 'next/headers';
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
const STALWART_AUTH_CONTEXT_COOKIE = 'jmap_stalwart_ctx';
export interface StalwartAuthContext {
serverUrl: string;
username: string;
authHeader: string;
}
type CookieStore = Awaited<ReturnType<typeof cookies>>;
export function stalwartAuthContextCookieName(slot: number): string {
return slot === 0 ? STALWART_AUTH_CONTEXT_COOKIE : `${STALWART_AUTH_CONTEXT_COOKIE}_${slot}`;
}
function isValidContext(payload: unknown): payload is StalwartAuthContext {
if (!payload || typeof payload !== 'object') {
return false;
}
const candidate = payload as Record<string, unknown>;
return typeof candidate.serverUrl === 'string'
&& typeof candidate.username === 'string'
&& typeof candidate.authHeader === 'string';
}
function getSessionCookieOptions() {
const { maxAge: _maxAge, ...cookieOptions } = getCookieOptions();
return cookieOptions;
}
export function readStalwartAuthContextFromStore(
cookieStore: CookieStore,
slot: number,
): StalwartAuthContext | null {
const token = cookieStore.get(stalwartAuthContextCookieName(slot))?.value;
if (!token) return null;
const payload = decryptPayload(token);
return isValidContext(payload) ? payload : null;
}
export async function readStalwartAuthContext(slot: number): Promise<StalwartAuthContext | null> {
const cookieStore = await cookies();
return readStalwartAuthContextFromStore(cookieStore, slot);
}
export function setStalwartAuthContextInStore(
cookieStore: CookieStore,
slot: number,
context: StalwartAuthContext,
): void {
cookieStore.set(
stalwartAuthContextCookieName(slot),
encryptPayload(context as unknown as Record<string, unknown>),
getSessionCookieOptions(),
);
}
export async function setStalwartAuthContext(slot: number, context: StalwartAuthContext): Promise<void> {
const cookieStore = await cookies();
setStalwartAuthContextInStore(cookieStore, slot, context);
}
export function clearStalwartAuthContextInStore(cookieStore: CookieStore, slot: number): void {
cookieStore.delete(stalwartAuthContextCookieName(slot));
}
export async function clearStalwartAuthContext(slot: number): Promise<void> {
const cookieStore = await cookies();
clearStalwartAuthContextInStore(cookieStore, slot);
}
+30 -30
View File
@@ -1,7 +1,7 @@
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
export interface StalwartCredentials {
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
@@ -11,6 +11,7 @@ export interface StalwartCredentials {
authHeader: string;
username: string;
hasSessionCookie: boolean;
slot: number;
}
/**
@@ -30,39 +31,38 @@ function getStalwartApiUrl(jmapServerUrl: string): string {
/**
* Extract credentials from the incoming request.
*
* Tries the explicit headers first (`Authorization`, `X-JMAP-Server-URL`,
* `X-JMAP-Username`), then falls back to the encrypted session cookie.
* Credentials are read from a verified, httpOnly auth-context cookie that is
* populated after a successful JMAP login or token refresh.
*/
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? null : slot;
}
function getCandidateSlots(request: NextRequest): number[] {
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
?? parseSlot(request.nextUrl.searchParams.get('slot'));
return requestedSlot === null ? [0, 1, 2, 3, 4] : [requestedSlot];
}
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
const authHeader = request.headers.get('Authorization');
const serverUrl = request.headers.get('X-JMAP-Server-URL');
const username = request.headers.get('X-JMAP-Username');
const cookieStore = await cookies();
for (const slot of getCandidateSlots(request)) {
const context = readStalwartAuthContextFromStore(cookieStore, slot);
if (!context) continue;
if (authHeader && serverUrl && username) {
const cookieStore = await cookies();
const hasSessionCookie = !!cookieStore.get(SESSION_COOKIE)?.value;
return {
apiUrl: getStalwartApiUrl(serverUrl),
serverUrl,
authHeader,
username,
hasSessionCookie,
apiUrl: getStalwartApiUrl(context.serverUrl),
serverUrl: context.serverUrl,
authHeader: context.authHeader,
username: context.username,
hasSessionCookie: !!cookieStore.get(sessionCookieName(slot))?.value,
slot,
};
}
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
const credentials = decryptSession(token);
if (!credentials) return null;
const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
return {
apiUrl: getStalwartApiUrl(credentials.serverUrl),
serverUrl: credentials.serverUrl,
authHeader: basic,
username: credentials.username,
hasSessionCookie: true,
};
return null;
}
+27 -27
View File
@@ -236,11 +236,11 @@ export function parseTnef(data: Uint8Array): TnefResult {
attachments: [],
};
debug.group('TNEF Parser');
debug.log('Input data size:', data.byteLength, 'bytes');
debug.group('TNEF Parser', 'email');
debug.log('email', 'Input data size:', data.byteLength, 'bytes');
if (data.byteLength < 6) {
debug.warn('TNEF data too small (< 6 bytes), skipping');
debug.warn('email', 'TNEF data too small (< 6 bytes), skipping');
debug.groupEnd();
return result;
}
@@ -249,11 +249,11 @@ export function parseTnef(data: Uint8Array): TnefResult {
const signature = r.readUint32LE();
if (signature !== TNEF_SIGNATURE) {
debug.warn('Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
debug.warn('email', 'Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
debug.groupEnd();
return result;
}
debug.log('TNEF signature valid');
debug.log('email', 'TNEF signature valid');
r.skip(2); // legacy key
@@ -268,7 +268,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
attrCount++;
if (attrLen > r.remaining - 2) {
debug.warn('Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
debug.warn('email', 'Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
break;
}
@@ -276,17 +276,17 @@ export function parseTnef(data: Uint8Array): TnefResult {
r.skip(2); // checksum
const levelName = level === LVL_MESSAGE ? 'MESSAGE' : level === LVL_ATTACHMENT ? 'ATTACHMENT' : 'UNKNOWN(' + level + ')';
debug.log('Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
debug.log('email', 'Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
if (level === LVL_MESSAGE) {
if (attrID === attBody) {
result.body = new TextDecoder('utf-8').decode(attrData);
debug.log(' → Extracted plain text body (' + result.body.length + ' chars)');
debug.log('email', ' → Extracted plain text body (' + result.body.length + ' chars)');
} else if (attrID === attMAPIProps) {
const props = parseMAPIProps(attrData);
debug.log(' → Parsed', props.size, 'MAPI properties from message');
debug.log('email', ' → Parsed', props.size, 'MAPI properties from message');
props.forEach((val, propID) => {
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
});
// HTML body
@@ -298,9 +298,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
} else {
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
}
debug.log(' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
debug.log('email', ' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
} else {
debug.log(' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
debug.log('email', ' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
}
// Plain text body from MAPI props (fallback)
@@ -308,9 +308,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
const bodyProp = props.get(PR_BODY);
if (bodyProp?.value instanceof Uint8Array) {
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
debug.log(' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
debug.log('email', ' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
} else {
debug.log(' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
debug.log('email', ' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
}
}
}
@@ -318,7 +318,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
if (attrID === attAttachRenddata) {
// Start of a new attachment — flush previous
if (curAttach?.data) {
debug.log(' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
debug.log('email', ' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
result.attachments.push({
name: curAttach.name,
mimeType: curAttach.mimeType,
@@ -326,40 +326,40 @@ export function parseTnef(data: Uint8Array): TnefResult {
});
}
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
debug.log(' → New attachment started');
debug.log('email', ' → New attachment started');
} else if (attrID === attAttachTitle && curAttach) {
let len = attrData.byteLength;
if (len > 0 && attrData[len - 1] === 0) len--;
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
debug.log(' → Attachment short name:', curAttach.name);
debug.log('email', ' → Attachment short name:', curAttach.name);
} else if (attrID === attAttachData && curAttach) {
curAttach.data = attrData;
debug.log(' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
debug.log('email', ' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
} else if (attrID === attAttachment && curAttach) {
const props = parseMAPIProps(attrData);
debug.log(' → Parsed', props.size, 'MAPI properties from attachment');
debug.log('email', ' → Parsed', props.size, 'MAPI properties from attachment');
props.forEach((val, propID) => {
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
});
const longName = props.get(PR_ATTACH_LONG_FILENAME);
if (longName?.value instanceof Uint8Array) {
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
debug.log(' → Attachment long filename:', curAttach.name);
debug.log('email', ' → Attachment long filename:', curAttach.name);
}
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
if (mimeTag?.value instanceof Uint8Array) {
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
debug.log(' → Attachment MIME type:', curAttach.mimeType);
debug.log('email', ' → Attachment MIME type:', curAttach.mimeType);
}
const attachData = props.get(PR_ATTACH_DATA_BIN);
if (attachData?.value instanceof Uint8Array) {
curAttach.data = attachData.value;
debug.log(' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
debug.log('email', ' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
} else {
debug.log(' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
debug.log('email', ' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
}
}
}
@@ -367,7 +367,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
// Flush last attachment
if (curAttach?.data) {
debug.log('Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
debug.log('email', 'Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
result.attachments.push({
name: curAttach.name,
mimeType: curAttach.mimeType,
@@ -375,9 +375,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
});
}
debug.log('TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
debug.log('email', 'TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
if (result.attachments.length > 0) {
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })));
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })), 'email');
}
debug.groupEnd();
+63 -2
View File
@@ -1,6 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Mailbox } from "./jmap/types";
import { debug } from "./debug";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -98,6 +99,7 @@ const ROLE_PRIORITY: Record<string, number> = {
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
const result: Mailbox[] = [];
const removed: { id: string; name: string; matchedRole: string; parentId?: string }[] = [];
// Group role mailboxes by account so deduplication is scoped per-account
const rolesByAccount = new Map<string, Mailbox[]>();
@@ -109,6 +111,12 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
}
});
// Build a set of IDs that are referenced as parents
const referencedParentIds = new Set<string>();
mailboxes.forEach(mb => {
if (mb.parentId) referencedParentIds.add(mb.parentId);
});
// Filter out duplicates scoped to the same account
mailboxes.forEach(mb => {
// If this mailbox has a role, always keep it
@@ -117,30 +125,59 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
return;
}
// Check if this is a duplicate of a role-based mailbox in the SAME account
// Never deduplicate nested mailboxes — only root-level folders can be
// duplicates of role-based mailboxes. Removing a nested folder that happens
// to share a name with a role folder (e.g. a subfolder named "Sent") would
// orphan its children to root level. (GitHub #118)
if (mb.parentId) {
result.push(mb);
return;
}
// Check if this root-level mailbox is a duplicate of a role-based mailbox in the SAME account
const accountKey = mb.accountId || '';
const accountRoles = rolesByAccount.get(accountKey) || [];
const lowerName = mb.name.toLowerCase();
const isDuplicate = accountRoles.some(roleMb => {
const matchedRole = accountRoles.find(roleMb => {
const roleLowerName = roleMb.name.toLowerCase();
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
});
const isDuplicate = !!matchedRole;
// Only keep if not a duplicate
if (!isDuplicate) {
result.push(mb);
} else {
removed.push({ id: mb.id, name: mb.name, matchedRole: matchedRole!.name, parentId: mb.parentId });
// Warn if this removed mailbox is a parent of other mailboxes (orphan risk)
if (referencedParentIds.has(mb.id)) {
debug.warn('jmap', `[Mailbox Tree] Deduplication removed mailbox "${mb.name}" (id: ${mb.id}) which is a parent of other mailboxes. ` +
`Matched role mailbox: "${matchedRole!.name}" (role: ${matchedRole!.role}). ` +
`Children referencing parentId "${mb.id}" will be orphaned to root level.`
);
}
}
});
if (removed.length > 0) {
debug.log('jmap', `[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed);
}
return result;
}
// Build a hierarchical tree structure from flat mailbox array
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
debug.log('jmap', `[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`);
// Deduplicate mailboxes first
const deduplicated = deduplicateMailboxes(mailboxes);
if (deduplicated.length !== mailboxes.length) {
debug.log('jmap', `[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`);
}
// Separate own and shared mailboxes
const ownMailboxes = deduplicated.filter(mb => !mb.isShared);
const sharedMailboxes = deduplicated.filter(mb => mb.isShared);
@@ -168,6 +205,7 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
};
// Second pass: build tree structure for own mailboxes
const orphanedMailboxes: { id: string; name: string; parentId: string }[] = [];
ownMailboxes.forEach(mailbox => {
const node = mailboxMap.get(mailbox.id)!;
@@ -176,13 +214,36 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
parent.children.push(node);
} else {
// Root level mailbox or orphaned mailbox
if (mailbox.parentId) {
orphanedMailboxes.push({ id: mailbox.id, name: mailbox.name, parentId: mailbox.parentId });
}
rootMailboxes.push(node);
}
});
if (orphanedMailboxes.length > 0) {
debug.warn('jmap', `[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`,
orphanedMailboxes
);
}
// Third pass: correctly calculate depths from the root down
recalculateDepths(rootMailboxes, 0);
// Log tree depth statistics
const maxDepth = (nodes: MailboxNode[]): number => {
let max = 0;
for (const node of nodes) {
max = Math.max(max, node.depth);
if (node.children.length > 0) max = Math.max(max, maxDepth(node.children));
}
return max;
};
debug.log('jmap', `[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` +
`max depth: ${maxDepth(rootMailboxes)}, ` +
`total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}`
);
// If we have shared mailboxes, create a virtual "Shared Folders" parent
if (sharedMailboxes.length > 0) {
// Group shared mailboxes by account

Some files were not shown because too many files have changed in this diff Show More