Compare commits

...
43 Commits
Author SHA1 Message Date
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
102 changed files with 6483 additions and 920 deletions
+8 -1
View File
@@ -9,9 +9,16 @@
# App name displayed in the UI # App name displayed in the UI
APP_NAME=Bulwark Webmail 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 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 # 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
+44
View File
@@ -1,5 +1,49 @@
# Changelog # Changelog
## 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) ## 1.4.9 (2026-03-27)
### Features ### Features
+1 -1
View File
@@ -195,7 +195,7 @@ webmail/
- **Never commit sensitive data** (API keys, passwords, etc.) - **Never commit sensitive data** (API keys, passwords, etc.)
- **Sanitize user input** and email content - **Sanitize user input** and email content
- **Block external content** by default for privacy - **Block external content** by default for privacy
- Report security vulnerabilities privately - Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
## Questions? ## 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) [![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) [![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) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div> </div>
+1 -1
View File
@@ -1 +1 @@
1.4.9 1.4.10
+36 -7
View File
@@ -39,6 +39,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
@@ -66,9 +67,9 @@ export default function CalendarPage() {
isLoading, isLoadingEvents, supportsCalendar, error, isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent, fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions, refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore(); } = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore(); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
const taskStore = useTaskStore(); const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks); const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
@@ -82,6 +83,7 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false); const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null); const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>(); const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>(); const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -337,18 +339,28 @@ export default function CalendarPage() {
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return; if (isMobile) return;
if (calendarHoverPreview === 'off') return;
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event // Don't show hover popover if the sidebar is already open for this event
if (showEventModal && editEvent?.id === event.id) return; if (showEventModal && editEvent?.id === event.id) return;
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); setDetailEvent(event);
setDetailAnchorRect(anchorRect); setDetailAnchorRect(anchorRect);
}, [isMobile, showEventModal, editEvent]); }, ms);
} else {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
const handleHoverLeave = useCallback(() => { const handleHoverLeave = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
hoverTimerRef.current = setTimeout(() => { hoverTimerRef.current = setTimeout(() => {
setDetailEvent(null); setDetailEvent(null);
setDetailAnchorRect(null); setDetailAnchorRect(null);
}, 200); }, 300);
}, []); }, []);
const handleEditFromDetail = useCallback(() => { const handleEditFromDetail = useCallback(() => {
@@ -385,12 +397,16 @@ export default function CalendarPage() {
} }
}, [client, fetchEvents]); }, [client, fetchEvents]);
const focusCalendarOnEvent = useCallback((event: Pick<CalendarEvent, "start">) => { const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
if (!event.start) { if (!event.start) {
return; 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())) { if (Number.isNaN(eventDate.getTime())) {
return; return;
} }
@@ -873,7 +889,7 @@ export default function CalendarPage() {
})(); })();
return ( 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} {viewContent}
{isLoadingEvents && calendars.length > 0 && events.length === 0 && ( {isLoadingEvents && calendars.length > 0 && events.length === 0 && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none"> <div className="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 }); updateCalendar(client, calendarId, { color });
} : undefined} } : undefined}
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client} client={client}
/> />
</div> </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} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<RecurrenceScopeDialog <RecurrenceScopeDialog
isOpen={!!pendingScopeAction} isOpen={!!pendingScopeAction}
+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 { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.9"; const APP_VERSION = "1.4.10";
const THEME_OPTIONS = [ const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" }, { value: "light" as const, icon: Sun, label: "Light" },
@@ -32,13 +32,14 @@ export default function LoginPage() {
const isAddAccountMode = searchParams.get("mode") === "add-account"; const isAddAccountMode = searchParams.get("mode") === "add-account";
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); 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 { 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 resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: "", username: "",
password: "", password: "",
}); });
const [jmapEndpoint, setJmapEndpoint] = useState("");
const [totpCode, setTotpCode] = useState(""); const [totpCode, setTotpCode] = useState("");
const [showTotpField, setShowTotpField] = useState(false); const [showTotpField, setShowTotpField] = useState(false);
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
@@ -73,6 +74,12 @@ export default function LoginPage() {
} }
}, [appName, serverUrl]); }, [appName, serverUrl]);
useEffect(() => {
if (serverUrl && !jmapEndpoint) {
setJmapEndpoint(serverUrl);
}
}, [serverUrl, jmapEndpoint]);
useEffect(() => { useEffect(() => {
try { try {
if (sessionStorage.getItem('session_expired') === 'true') { if (sessionStorage.getItem('session_expired') === 'true') {
@@ -91,6 +98,14 @@ export default function LoginPage() {
prevError.current = error; prevError.current = error;
}, [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(() => { useEffect(() => {
if (!serverUrl) return; if (!serverUrl) return;
const saved = localStorage.getItem("webmail_usernames"); const saved = localStorage.getItem("webmail_usernames");
@@ -264,7 +279,7 @@ export default function LoginPage() {
); );
} }
if (!serverUrl && !demoMode) { if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30"> <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"> <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_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state); sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", serverUrl!); sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
if (isAddAccountMode) { if (isAddAccountMode) {
sessionStorage.setItem("oauth_add_account_mode", "true"); sessionStorage.setItem("oauth_add_account_mode", "true");
} }
@@ -382,8 +397,9 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
const success = await login( const success = await login(
serverUrl, effectiveServerUrl,
formData.username, formData.username,
formData.password, formData.password,
totpCode || undefined, totpCode || undefined,
@@ -749,6 +765,27 @@ export default function LoginPage() {
/* Login Form */ /* Login Form */
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<fieldset disabled={isLoading} className="space-y-4"> <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 */} {/* Username field */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label htmlFor="username" className="block text-sm font-medium text-foreground"> <label htmlFor="username" className="block text-sm font-medium text-foreground">
@@ -861,7 +898,10 @@ export default function LoginPage() {
maxLength={6} maxLength={6}
value={totpCode} value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} 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")} placeholder={t("totp_placeholder")}
autoComplete="one-time-code" autoComplete="one-time-code"
aria-label={t("totp_label")} aria-label={t("totp_label")}
+37 -18
View File
@@ -30,6 +30,7 @@ import {
ComposerErrorFallback, ComposerErrorFallback,
} from "@/components/error"; } from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context"; import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils"; import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner"; import { WelcomeBanner } from "@/components/ui/welcome-banner";
@@ -53,6 +54,7 @@ export default function Home() {
const t = useTranslations(); const t = useTranslations();
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const { appName } = useConfig(); const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout);
const [showComposer, setShowComposer] = useState(false); const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState(""); const [composerDraftText, setComposerDraftText] = useState("");
@@ -519,7 +521,10 @@ export default function Home() {
const handleEditDraft = async (email?: Email) => { const handleEditDraft = async (email?: Email) => {
if (!client) return; 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; if (!draft) return;
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc). // 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 // Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox"; 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 // Handle email selection with mobile view switching
const handleEmailSelect = async (email: { id: string }) => { const handleEmailSelect = async (email: { id: string }) => {
@@ -1017,6 +1026,9 @@ export default function Home() {
setConversationEmails([]); setConversationEmails([]);
} }
selectEmail(null); selectEmail(null);
if (isTablet) {
setTabletListVisible(true);
}
setActiveView("list"); setActiveView("list");
}; };
@@ -1199,10 +1211,9 @@ export default function Home() {
// Tablet/Desktop: fixed width with collapse animation // Tablet/Desktop: fixed width with collapse animation
"md:flex-shrink-0 md:shadow-sm", "md:flex-shrink-0 md:shadow-sm",
!isResizing && "transition-all duration-200 ease-out", !isResizing && "transition-all duration-200 ease-out",
// Tablet: collapse when email selected shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
isTablet && !tabletListVisible && "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 */} {/* Mobile Header for List View */}
<MobileHeader <MobileHeader
@@ -1487,24 +1498,27 @@ export default function Home() {
</ErrorBoundary> </ErrorBoundary>
</div> </div>
{/* Floating Compose Button (mobile) */} {/* Floating Compose Button */}
{isMobile && (
<Button <Button
onClick={() => { onClick={() => {
setComposerMode('compose'); setComposerMode('compose');
setShowComposer(true); setShowComposer(true);
setActiveView('viewer'); if (isMobile) setActiveView('viewer');
}} }}
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg" className={cn(
aria-label={t('sidebar.compose')} "absolute z-40 rounded-full shadow-lg",
> isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
<PenSquare className="h-6 w-6" />
</Button>
)} )}
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> </div>
{/* Email list resize handle (desktop only) */} {/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && ( {!isMobile && !isTablet && !isFocusedMailLayout && (
<ResizeHandle <ResizeHandle
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }} onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)} 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", "max-md:fixed max-md:inset-0 max-md:z-30",
isMobile && activeView !== "viewer" && "max-md:hidden", isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: relative // Tablet/Desktop: relative
"md:relative" "md:relative",
shouldHideViewerPane && "md:hidden"
)} )}
> >
{/* Inline Composer - shown in viewer pane */} {/* Inline Composer - shown in viewer pane */}
@@ -1537,8 +1552,10 @@ export default function Home() {
mode={pendingDraft?.mode ?? composerMode} mode={pendingDraft?.mode ?? composerMode}
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? { replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
from: selectedEmail.from, from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to, to: selectedEmail.to,
cc: selectedEmail.cc, cc: selectedEmail.cc,
bcc: selectedEmail.bcc,
subject: selectedEmail.subject, subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
@@ -1648,14 +1665,15 @@ export default function Home() {
}} }}
onDownloadAttachment={handleDownloadAttachment} onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply} onQuickReply={handleQuickReply}
onBack={() => { onBack={handleMobileBack}
setTabletListVisible(true);
selectEmail(null);
}}
onNavigateNext={handleNavigateNext} onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev} onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)} onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft} onEditDraft={handleEditDraft}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
}}
currentUserEmail={client?.getUsername()} currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]} currentUserName={client?.getUsername()?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
@@ -1709,6 +1727,7 @@ export default function Home() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
<TotpReauthDialog />
</div> </div>
</DragDropProvider> </DragDropProvider>
); );
+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'; 'use client';
import { useEffect, useState, useRef } from 'react'; 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 type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types';
@@ -396,6 +397,13 @@ export default function AdminPluginsPage() {
</div> </div>
<div className="flex items-center gap-2"> <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 <button
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)} onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'} title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
+2
View File
@@ -28,10 +28,12 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] }, { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, { 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: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] }, { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' }, { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] }, { 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: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' }, { key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' }, { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
+8
View File
@@ -112,6 +112,14 @@ export default function AdminSettingsPage() {
<SettingsSection title="General"> <SettingsSection title="General">
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} /> <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" /> <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} /> <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" /> <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} /> <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} />
+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 });
}
}
+3
View File
@@ -157,6 +157,9 @@ export async function POST(request: NextRequest) {
permissions: (manifest.permissions as string[]) || [], permissions: (manifest.permissions as string[]) || [],
entrypoint: manifest.entrypoint as string, entrypoint: manifest.entrypoint as string,
enabled: true, enabled: true,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
installedAt: now, installedAt: now,
updatedAt: now, updatedAt: now,
}; };
+39
View File
@@ -59,6 +59,45 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
} }
// 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 Sec-Fetch-Site to ensure only same-origin browser requests succeed.
*/
export async function PUT(request: NextRequest) {
try {
// Block non-browser and cross-origin requests
const secFetchSite = request.headers.get('sec-fetch-site');
if (secFetchSite !== 'same-origin') {
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);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
return NextResponse.json(credentials, { return NextResponse.json(credentials, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' }, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
}); });
+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', ''), loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''), loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false), demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false), autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''), parentOrigin: configManager.get<string>('parentOrigin', ''),
+2 -7
View File
@@ -53,16 +53,14 @@ function isEnabled(): boolean {
/** /**
* Verify identity against session cookies across all account slots. * Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4). * 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> { async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies(); const cookieStore = await cookies();
let hasAnyCookie = false;
for (let slot = 0; slot <= 4; slot++) { for (let slot = 0; slot <= 4; slot++) {
const token = cookieStore.get(sessionCookieName(slot))?.value; const token = cookieStore.get(sessionCookieName(slot))?.value;
if (!token) continue; if (!token) continue;
hasAnyCookie = true;
const session = decryptSession(token); const session = decryptSession(token);
if (session && session.username === username && session.serverUrl === serverUrl) { 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) // No matching session found (or no cookies at all)reject
if (!hasAnyCookie) return true;
// Cookies exist but none matched → identity mismatch
return false; return false;
} }
+5 -5
View File
@@ -2,11 +2,11 @@
import { useMemo, useRef, useEffect, useCallback } from "react"; import { useMemo, useRef, useEffect, useCallback } from "react";
import { useTranslations, useFormatter } from "next-intl"; 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 { MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card"; 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 { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -49,7 +49,7 @@ export function CalendarAgendaView({
const grouped = useMemo(() => { const grouped = useMemo(() => {
const sorted = [...events].sort((a, b) => const sorted = [...events].sort((a, b) =>
parseISO(a.start).getTime() - parseISO(b.start).getTime() getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
); );
const groups: DayGroup[] = []; const groups: DayGroup[] = [];
@@ -144,9 +144,9 @@ export function CalendarAgendaView({
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
const calendar = calId ? calendarMap.get(calId) : undefined; const calendar = calId ? calendarMap.get(calId) : undefined;
const color = getEventColor(ev, calendar); const color = getEventColor(ev, calendar);
const start = parseISO(ev.start); const start = getEventStartDate(ev);
const durMin = parseDuration(ev.duration); const durMin = parseDuration(ev.duration);
const end = new Date(start.getTime() + durMin * 60000); const end = getEventEndDate(ev);
const locationName = ev.locations const locationName = ev.locations
? Object.values(ev.locations)[0]?.name ? Object.values(ev.locations)[0]?.name
: null; : null;
+9 -11
View File
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
import { format, isSameDay, isToday, parseISO } from "date-fns"; import { format, isSameDay, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card"; import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input"; 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 type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal"; import type { PendingEventPreview } from "./event-modal";
@@ -66,7 +66,7 @@ export function CalendarDayView({
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime(); const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
if (!spansThisDay) return; if (!spansThisDay) return;
if (ev.showWithoutTime) allDay.push(ev); if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
else timed.push(ev); else timed.push(ev);
} catch { /* skip invalid dates */ } } catch { /* skip invalid dates */ }
}); });
@@ -127,10 +127,10 @@ export function CalendarDayView({
return format(new Date(2000, 0, 1, h), "HH:mm"); 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 ( 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")}> <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")}> <h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile {isMobile
@@ -200,7 +200,7 @@ export function CalendarDayView({
</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="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}> <div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
{HOURS.map((h) => ( {HOURS.map((h) => (
@@ -241,11 +241,9 @@ export function CalendarDayView({
/> />
))} ))}
{layouted.map(({ event: ev, column, totalColumns }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const start = parseISO(ev.start); const durMin = Math.max(15, endMinutes - startMinutes);
const startMin = start.getHours() * 60 + start.getMinutes(); const top = (startMinutes / 60) * HOUR_HEIGHT;
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT); const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
+13 -1
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useMemo } from "react"; import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl"; 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 { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -18,6 +18,7 @@ interface CalendarSidebarPanelProps {
onToggleVisibility: (id: string) => void; onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void; onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void; onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null; client?: IJMAPClient | null;
} }
@@ -27,6 +28,7 @@ export function CalendarSidebarPanel({
onToggleVisibility, onToggleVisibility,
onColorChange, onColorChange,
onSubscribe, onSubscribe,
onEditSubscription,
client, client,
}: CalendarSidebarPanelProps) { }: CalendarSidebarPanelProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
@@ -173,6 +175,16 @@ export function CalendarSidebarPanel({
ref={contextMenuRef} 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" 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 <button
onClick={() => handleRefreshSubscription(sub.id)} onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors" className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
+20 -14
View File
@@ -7,9 +7,9 @@ import {
} from "date-fns"; } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card"; import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input"; 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 type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal"; import type { PendingEventPreview } from "./event-modal";
@@ -77,7 +77,7 @@ export function CalendarWeekView({
const cursor = new Date(startDay); const cursor = new Date(startDay);
while (cursor <= endDay) { while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd"); const key = format(cursor, "yyyy-MM-dd");
if (!ev.showWithoutTime) { if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
const arr = timed.get(key) || []; const arr = timed.get(key) || [];
arr.push(ev); arr.push(ev);
timed.set(key, arr); timed.set(key, arr);
@@ -89,10 +89,18 @@ export function CalendarWeekView({
return timed; return timed;
}, [events]); }, [events]);
const allDaySegments = useMemo(() => buildWeekSegments( const allDaySegments = useMemo(() => {
const explicitAllDay = buildWeekSegmentsRaw(
events.filter((event) => event.showWithoutTime), events.filter((event) => event.showWithoutTime),
weekDays, weekDays,
), [events, weekDays]); );
const timedFullDay = buildTimedFullDayWeekSegments(
events.filter((event) => !event.showWithoutTime),
weekDays,
);
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
}, [events, weekDays]);
const allDayRowCount = useMemo(() => { const allDayRowCount = useMemo(() => {
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0); return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
@@ -189,13 +197,13 @@ export function CalendarWeekView({
<div <div
ref={rootRef} ref={rootRef}
className={cn( 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" isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
)} )}
role="grid" role="grid"
aria-label={t("views.week")} 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="flex border-b border-border">
<div <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")} 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> </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="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")}> <div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
{HOURS.map((h) => ( {HOURS.map((h) => (
@@ -344,7 +352,7 @@ export function CalendarWeekView({
const key = format(day, "yyyy-MM-dd"); const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || []; const dayEvents = timedEvents.get(key) || [];
const todayCol = isToday(day); const todayCol = isToday(day);
const layouted = layoutOverlappingEvents(dayEvents); const layouted = layoutOverlappingEvents(dayEvents, day);
return ( return (
<div <div
@@ -371,11 +379,9 @@ export function CalendarWeekView({
/> />
))} ))}
{layouted.map(({ event: ev, column, totalColumns }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const start = parseISO(ev.start); const durMin = Math.max(15, endMinutes - startMinutes);
const startMin = start.getHours() * 60 + start.getMinutes(); const top = (startMinutes / 60) * HOUR_HEIGHT;
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT); const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev); 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 { useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; 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 { Users } from "lucide-react";
import { getParticipantCount } from "@/lib/calendar-participants"; import { getParticipantCount } from "@/lib/calendar-participants";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
interface EventCardProps { interface EventCardProps {
@@ -68,14 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false); const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar); const color = getEventColor(event, calendar);
const startDate = parseISO(event.start); const startDate = getEventStartDate(event);
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView); const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const calendarName = calendar?.name || ""; const calendarName = calendar?.name || "";
const durationMinutes = parseDuration(event.duration); 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 timeString = `${format(startDate, timeFmt)} ${format(endTime, timeFmt)}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`; 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 { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { import {
isOrganizer, isOrganizer,
getUserParticipantId, getUserParticipantId,
@@ -138,9 +139,9 @@ export function EventDetailPopover({
const [isSavingNote, setIsSavingNote] = useState(false); const [isSavingNote, setIsSavingNote] = useState(false);
const color = getEventColor(event, calendar); const color = getEventColor(event, calendar);
const startDate = parseISO(event.start); const startDate = getEventStartDate(event);
const durationMinutes = parseDuration(event.duration); const durationMinutes = parseDuration(event.duration);
const endDate = new Date(startDate.getTime() + durationMinutes * 60000); const endDate = getEventEndDate(event);
const locationName = useMemo(() => { const locationName = useMemo(() => {
if (!event.locations) return null; 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 { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card"; 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 { ParticipantInput } from "./participant-input";
import { import {
isOrganizer, isOrganizer,
@@ -18,6 +18,7 @@ import {
getStatusCounts, getStatusCounts,
buildParticipantMap, buildParticipantMap,
} from "@/lib/calendar-participants"; } from "@/lib/calendar-participants";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
export interface PendingEventPreview { export interface PendingEventPreview {
@@ -159,7 +160,7 @@ export function EventModal({
}, [event, existingParticipants]); }, [event, existingParticipants]);
const getInitialStart = (): Date => { const getInitialStart = (): Date => {
if (event?.start) return parseISO(event.start); if (event?.start) return getEventStartDate(event);
if (defaultDate) { if (defaultDate) {
const d = new Date(defaultDate); const d = new Date(defaultDate);
if (defaultEndDate) return d; if (defaultEndDate) return d;
@@ -177,9 +178,7 @@ export function EventModal({
if (event.showWithoutTime) { if (event.showWithoutTime) {
return getEventDisplayEndDate(event); return getEventDisplayEndDate(event);
} }
const s = parseISO(event.start); return getEventEndDate(event);
const dur = parseDuration(event.duration);
return new Date(s.getTime() + dur * 60000);
} }
if (defaultEndDate) return new Date(defaultEndDate); if (defaultEndDate) return new Date(defaultEndDate);
return addHours(getInitialStart(), 1); return addHours(getInitialStart(), 1);
@@ -190,6 +189,9 @@ export function EventModal({
const [location, setLocation] = useState( const [location, setLocation] = useState(
event?.locations ? Object.values(event.locations)[0]?.name || "" : "" 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 [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart())); const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd())); const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
@@ -315,6 +317,20 @@ export function EventModal({
data.locations = null; 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") { if (recurrence !== "none") {
data.recurrenceRules = [{ data.recurrenceRules = [{
"@type": "RecurrenceRule", "@type": "RecurrenceRule",
@@ -374,7 +390,7 @@ export function EventModal({
} finally { } finally {
setIsSaving(false); 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']) => { const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return; if (!event || !userParticipantId || !onRsvp) return;
@@ -384,7 +400,7 @@ export function EventModal({
const handleDuplicate = useCallback(() => { const handleDuplicate = useCallback(() => {
if (!event || !onDuplicate) return; if (!event || !onDuplicate) return;
const start = parseISO(event.start); const start = getEventStartDate(event);
const newStart = addDays(start, 1); const newStart = addDays(start, 1);
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID() ? crypto.randomUUID()
@@ -403,6 +419,7 @@ export function EventModal({
privacy: event.privacy, privacy: event.privacy,
}; };
if (event.locations) data.locations = structuredClone(event.locations); 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.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts); if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants); 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); const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
if (isAttendeeMode && event) { if (isAttendeeMode && event) {
const startD = parseISO(event.start); const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration); 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 locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event); const participants = getParticipantList(event);
@@ -567,9 +584,9 @@ export function EventModal({
// View mode: read-only display of event details with Edit button // View mode: read-only display of event details with Edit button
if (mode === "view" && event) { if (mode === "view" && event) {
const startD = parseISO(event.start); const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration); 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 locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null; const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event); const viewParticipants = getParticipantList(event);
@@ -780,6 +797,39 @@ export function EventModal({
/> />
</div> </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> <div>
<label className="text-sm font-medium mb-1 block"> <label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5"> <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 { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
@@ -17,7 +18,7 @@ interface ICalImportModalProps {
onClose: () => void; onClose: () => void;
} }
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing"; type ImportStep = "select" | "preview" | "importing";
@@ -195,7 +196,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const formatEventDate = (event: Partial<CalendarEvent>): string => { const formatEventDate = (event: Partial<CalendarEvent>): string => {
if (!event.start) return ""; if (!event.start) return "";
try { try {
const date = parseISO(event.start); const date = getEventStartDate(event as CalendarEvent);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
return event.showWithoutTime return event.showWithoutTime
? format(date, "MMM d, yyyy") ? format(date, "MMM d, yyyy")
+25 -10
View File
@@ -5,24 +5,28 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react"; import { X, Loader2, Globe } from "lucide-react";
import type { IJMAPClient } from '@/lib/jmap/client-interface'; 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 { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps { interface ICalSubscriptionModalProps {
client: IJMAPClient; client: IJMAPClient;
onClose: () => void; onClose: () => void;
editSubscription?: ICalSubscription;
} }
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) { export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription"); const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription); const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
const [url, setUrl] = useState(""); const isEdit = !!editSubscription;
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6"); const [url, setUrl] = useState(editSubscription?.url || "");
const [refreshInterval, setRefreshInterval] = useState(60); 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 [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
@@ -49,6 +53,16 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
setIsSubmitting(true); setIsSubmitting(true);
try { try {
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 {
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval); const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) { if (subscription) {
toast.success(t("success", { name: name.trim() })); toast.success(t("success", { name: name.trim() }));
@@ -56,8 +70,9 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
} else { } else {
setError(t("error")); setError(t("error"));
} }
}
} catch { } catch {
setError(t("error")); setError(isEdit ? t("update_error") : t("error"));
} finally { } finally {
setIsSubmitting(false); 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 justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" /> <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> </div>
<button <button
onClick={onClose} onClick={onClose}
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="w-4 h-4 animate-spin mr-2" /> <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> </Button>
</div> </div>
+10 -2
View File
@@ -7,9 +7,10 @@ import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear, addMonths, subMonths, addYears, subYears, setMonth, setYear,
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek, eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
isSameDay, isSameMonth, isToday, format, parseISO, isSameDay, isSameMonth, isToday, format,
} from "date-fns"; } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types"; import type { CalendarEvent } from "@/lib/jmap/types";
type PickerView = "days" | "months" | "years"; type PickerView = "days" | "months" | "years";
@@ -54,7 +55,14 @@ export function MiniCalendar({
const eventDates = useMemo(() => { const eventDates = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
events.forEach(e => { 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; return set;
}, [events]); }, [events]);
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
useSettingsStore.setState({ useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS], emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false, showPreview: false,
mailLayout: 'split',
}); });
useEmailStore.setState({ useEmailStore.setState({
selectedEmailIds: new Set<string>(), selectedEmailIds: new Set<string>(),
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
render(<EmailListItem email={email} />); render(<EmailListItem email={email} />);
expect(screen.getByText('Hello World')).toBeInTheDocument(); 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();
});
}); });
+35 -4
View File
@@ -29,6 +29,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form"; import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types"; import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { findReplyIdentityId } from "@/lib/reply-identity";
import { RichTextEditor } from "@/components/email/rich-text-editor"; import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */ /** Strip HTML tags and decode entities to get a plain-text version */
@@ -76,8 +77,10 @@ interface EmailComposerProps {
mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: { replyTo?: {
from?: { email?: string; name?: string }[]; from?: { email?: string; name?: string }[];
replyToAddresses?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[]; to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[]; cc?: { email?: string; name?: string }[];
bcc?: { email?: string; name?: string }[];
subject?: string; subject?: string;
body?: string; body?: string;
htmlBody?: string; htmlBody?: string;
@@ -100,17 +103,20 @@ export function EmailComposer({
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode); const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
// Initialize with reply/forward data if provided // Initialize with reply/forward data if provided
const getInitialTo = () => { const getInitialTo = () => {
if (!replyTo) return ""; 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') { if (mode === 'reply') {
const email = replyTo.from?.[0]?.email || ""; return replyTarget ? replyTarget + ', ' : "";
return email ? email + ', ' : "";
} else if (mode === 'replyAll') { } else if (mode === 'replyAll') {
const from = replyTo.from?.[0]?.email || "";
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || ""; 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 combined ? combined + ', ' : "";
} }
return ""; return "";
@@ -226,6 +232,31 @@ export function EmailComposer({
const currentIdentity = selectedIdentityId const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: 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 const composerSignatureHtml = currentIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>` ? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
: currentIdentity?.textSignature : currentIdentity?.textSignature
+15 -3
View File
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
interface EmailHoverActionsProps { interface EmailHoverActionsProps {
email: Email; email: Email;
backgroundClassName?: string;
onToggleStar?: () => void; onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void; onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void; onDelete?: () => void;
@@ -63,6 +64,7 @@ const CORNER_CLASSES = {
export function EmailHoverActions({ export function EmailHoverActions({
email, email,
backgroundClassName = "bg-muted",
onToggleStar, onToggleStar,
onMarkAsRead, onMarkAsRead,
onDelete, onDelete,
@@ -77,6 +79,7 @@ export function EmailHoverActions({
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const hoverBackgroundClassName = backgroundClassName;
if (hoverActions.length === 0) return null; if (hoverActions.length === 0) return null;
@@ -144,7 +147,10 @@ export function EmailHoverActions({
CORNER_CLASSES[hoverActionsCorner], 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} {actionButtons}
</div> </div>
</div> </div>
@@ -155,8 +161,14 @@ export function EmailHoverActions({
<div <div
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center" 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
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5"> 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} {actionButtons}
</div> </div>
</div> </div>
+58 -4
View File
@@ -35,6 +35,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore(); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { identities } = useAuthStore(); const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
@@ -44,6 +45,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded; const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0]; const sender = email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tag using keyword definitions from settings // Resolve color tag using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords); const colorTagId = getEmailColorTag(email.keywords);
@@ -114,15 +117,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
} }
}} }}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<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)' }}
> >
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
{/* Checkbox - only visible when in selection mode */} {/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
<button <button
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
className={cn( 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", "hover:bg-muted/50 hover:scale-110",
"active:scale-95", "active:scale-95",
"animate-in fade-in zoom-in-95 duration-150", "animate-in fade-in zoom-in-95 duration-150",
@@ -145,7 +152,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
)} )}
{/* Avatar */} {/* Avatar */}
{density !== 'extra-compact' && ( {!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar <Avatar
name={sender?.name} name={sender?.name}
email={sender?.email} email={sender?.email}
@@ -156,6 +163,50 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{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>
{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" />}
{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>
) : (
<>
{/* First Line: Sender and Date */} {/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
@@ -236,12 +287,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{email.preview || "No preview available"} {email.preview || "No preview available"}
</p> </p>
)} )}
</>
)}
</div> </div>
</div> </div>
{/* Hover Quick Actions */} {/* Hover Quick Actions */}
<EmailHoverActions <EmailHoverActions
email={email} email={email}
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
onToggleStar={onToggleStar} onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead} onMarkAsRead={onMarkAsRead}
onDelete={onDelete} onDelete={onDelete}
+19 -3
View File
@@ -103,11 +103,16 @@ export function EmailList({
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isFocusedMailLayout = mailLayout === 'focus';
const estimateSize = useCallback(() => { 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]; const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
return (showPreview && density !== 'extra-compact') ? base + 36 : base; return (showPreview && density !== 'extra-compact') ? base + 36 : base;
}, [density, showPreview]); }, [density, isFocusedMailLayout, showPreview]);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: threadGroups.length, count: threadGroups.length,
@@ -216,15 +221,26 @@ export function EmailList({
} }
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]); }, [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 virtualItems = virtualizer.getVirtualItems();
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index; const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
if (lastVirtualItemIndex === undefined) return; if (lastVirtualItemIndex === undefined) return;
if (lastVirtualItemIndex >= threadGroups.length - 5) { if (lastVirtualItemIndex >= threadGroups.length - 5) {
// Clear any pending timer so we don't stack calls
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
loadMoreTimerRef.current = setTimeout(() => {
handleLoadMore(); handleLoadMore();
loadMoreTimerRef.current = null;
}, 150);
} }
return () => {
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
};
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]); }, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
// Scroll to the thread group containing the selected email // Scroll to the thread group containing the selected email
@@ -244,7 +260,7 @@ export function EmailList({
useEffect(() => { useEffect(() => {
virtualizer.measure(); virtualizer.measure();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [density, showPreview]); }, [density, isFocusedMailLayout, showPreview]);
return ( return (
<div className={cn("flex flex-col min-h-0", className)}> <div className={cn("flex flex-col min-h-0", className)}>
+96 -19
View File
@@ -68,6 +68,7 @@ import {
HelpCircle, HelpCircle,
EditIcon, EditIcon,
PlayCircle, PlayCircle,
PenSquare,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -121,6 +122,7 @@ interface EmailViewerProps {
onNavigatePrev?: () => void; onNavigatePrev?: () => void;
onShowShortcuts?: () => void; onShowShortcuts?: () => void;
onEditDraft?: () => void; onEditDraft?: () => void;
onCompose?: () => void;
currentUserEmail?: string; currentUserEmail?: string;
currentUserName?: string; currentUserName?: string;
currentMailboxRole?: string; currentMailboxRole?: string;
@@ -865,6 +867,7 @@ export function EmailViewer({
onNavigatePrev, onNavigatePrev,
onShowShortcuts, onShowShortcuts,
onEditDraft, onEditDraft,
onCompose,
currentUserEmail, currentUserEmail,
currentUserName, currentUserName,
currentMailboxRole, currentMailboxRole,
@@ -887,8 +890,10 @@ export function EmailViewer({
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
// Detect if current mailbox is Junk folder // Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
@@ -906,7 +911,7 @@ export function EmailViewer({
// Tablet list visibility // Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection(); const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore(); const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode } = useAuthStore(); const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour(); const { startTour } = useTour();
const [showFullHeaders, setShowFullHeaders] = useState(false); const [showFullHeaders, setShowFullHeaders] = useState(false);
@@ -960,9 +965,9 @@ export function EmailViewer({
// Ensure S/MIME key records are loaded from IndexedDB // Ensure S/MIME key records are loaded from IndexedDB
useLayoutEffect(() => { useLayoutEffect(() => {
smimeStore.load(); smimeStore.load(activeAccountId ?? undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [activeAccountId]);
// Build mailbox tree for move-to dropdown // Build mailbox tree for move-to dropdown
const moveTargetIds = useMemo(() => new Set( const moveTargetIds = useMemo(() => new Set(
@@ -1540,8 +1545,25 @@ export function EmailViewer({
if (detection.type === 'enveloped-data') { if (detection.type === 'enveloped-data') {
// Encrypted message // Encrypted message
const { keyRecords, unlockedDecryptionKeys } = smimeStore; const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) }); 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 { try {
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null; let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
let lastError: unknown = null; let lastError: unknown = null;
@@ -1552,6 +1574,7 @@ export function EmailViewer({
cmsBytes: candidate.normalized, cmsBytes: candidate.normalized,
keyRecords, keyRecords,
unlockedKeys: unlockedDecryptionKeys, unlockedKeys: unlockedDecryptionKeys,
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
}); });
smimeDebug('[S/MIME] decrypt success with candidate:', { smimeDebug('[S/MIME] decrypt success with candidate:', {
source: candidate.source, source: candidate.source,
@@ -1633,8 +1656,15 @@ export function EmailViewer({
if (!existing) { if (!existing) {
try { try {
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email'); 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) { } catch (error) {
smimeError('[S/MIME] nested signature verify failed:', { smimeError('[S/MIME] nested signature verify failed:', {
@@ -1672,10 +1702,12 @@ export function EmailViewer({
decryptionError: 'locked', decryptionError: 'locked',
}); });
} else { } else {
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
setSmimeStatus({ setSmimeStatus({
isSigned: false, isSigned: false,
isEncrypted: true, isEncrypted: true,
decryptionError: err instanceof Error ? err.message : 'Decryption failed', decryptionError: isNoKeyError ? 'no-key' : errMsg,
}); });
} }
} }
@@ -1737,8 +1769,15 @@ export function EmailViewer({
if (!existing) { if (!existing) {
try { try {
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email'); 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) { } catch (err) {
if (cancelled) return; if (cancelled) return;
@@ -1771,6 +1810,7 @@ export function EmailViewer({
smimeStore.autoImportSignerCerts, smimeStore.autoImportSignerCerts,
smimeStore.keyRecords, smimeStore.keyRecords,
smimeStore.unlockedDecryptionKeys, smimeStore.unlockedDecryptionKeys,
smimeStore.unlockedLegacyDecryptionKeys,
smimeStore, smimeStore,
]); ]);
@@ -2526,14 +2566,21 @@ export function EmailViewer({
// If email has native dark mode, let it handle its own theming // If email has native dark mode, let it handle its own theming
// Otherwise, use CSS filter inversion for dark mode (preserves layout) // 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 ? ` const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #1a1a1a; } html { background: #1a1a1a; }
body { filter: invert(1) hue-rotate(180deg); } body { filter: invert(1) hue-rotate(180deg); }
img, video, picture, svg, canvas, object, embed, img, video, svg, canvas, object, embed, input[type="image"] {
[style*="background-image"], [style*="background:"], filter: invert(1) hue-rotate(180deg);
[background], [bgcolor], }
td[background], table[background], [style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
img[src], input[type="image"] { [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); filter: invert(1) hue-rotate(180deg);
} }
` : ''; ` : '';
@@ -2572,11 +2619,35 @@ export function EmailViewer({
a.setAttribute('target', '_blank'); a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer'); 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 { } catch {
// Cross-origin restrictions - iframe will still display content // Cross-origin restrictions - iframe will still display content
} }
}, []); }, [isDark, emailHasNativeDarkMode]);
// Export email as .eml file // Export email as .eml file
const handleExportEmail = async () => { const handleExportEmail = async () => {
@@ -2775,6 +2846,12 @@ export function EmailViewer({
</div> </div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3> <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> <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>
</div> </div>
); );
@@ -2790,7 +2867,7 @@ export function EmailViewer({
<> <>
{/* Left: Reply actions */} {/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}> <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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -2805,7 +2882,7 @@ export function EmailViewer({
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
onClick={onEditDraft} onClick={() => onEditDraft()}
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0" className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.edit_draft')} 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="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start justify-between gap-2 lg:gap-4"> <div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */} {/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && ( {toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -4403,7 +4480,7 @@ export function EmailViewer({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={onEditDraft} onClick={() => onEditDraft()}
className="gap-1.5" className="gap-1.5"
> >
<EditIcon className="w-3.5 h-3.5" /> <EditIcon className="w-3.5 h-3.5" />
@@ -4672,7 +4749,7 @@ export function EmailViewer({
</button> </button>
{isDraft && onEditDraft ? ( {isDraft && onEditDraft ? (
<button <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" 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')} aria-label={t('tooltips.edit_draft')}
> >
+6
View File
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
text: t('unlock_key_desc'), text: t('unlock_key_desc'),
variant: 'warning', 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 { } else {
items.push({ items.push({
icon: <ShieldX className="w-4 h-4" />, icon: <ShieldX className="w-4 h-4" />,
+126 -10
View File
@@ -59,7 +59,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isChecked = selectedEmailIds.has(email.id); 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 // Resolve color and keyword definition from keyword definitions if not passed directly
const tagId = getEmailColorTag(email.keywords); const tagId = getEmailColorTag(email.keywords);
@@ -131,15 +134,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)} )}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<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)' }}
> >
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
{/* Checkbox - only visible when in selection mode */} {/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
<button <button
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
className={cn( 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", "hover:bg-muted/50 hover:scale-110",
"active:scale-95", "active:scale-95",
"animate-in fade-in zoom-in-95 duration-150", "animate-in fade-in zoom-in-95 duration-150",
@@ -160,7 +167,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div> </div>
)} )}
{density !== 'extra-compact' && ( {!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar <Avatar
name={sender?.name} name={sender?.name}
email={sender?.email} email={sender?.email}
@@ -170,6 +177,49 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{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" />}
{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 justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn( <span className={cn(
@@ -241,12 +291,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{email.preview || "No preview available"} {email.preview || "No preview available"}
</p> </p>
)} )}
</>
)}
</div> </div>
</div> </div>
{/* Hover Quick Actions */} {/* Hover Quick Actions */}
<EmailHoverActions <EmailHoverActions
email={email} email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar} onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead} onMarkAsRead={onMarkAsRead}
onDelete={onDelete} onDelete={onDelete}
@@ -280,8 +333,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const t = useTranslations('threads'); const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; 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(); const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
@@ -405,15 +461,19 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)} )}
onClick={handleHeaderClick} onClick={handleHeaderClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<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)' }}
> >
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
{/* Checkbox for thread selection - only visible when in selection mode */} {/* Checkbox for thread selection - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
<button <button
onClick={handleThreadCheckboxClick} onClick={handleThreadCheckboxClick}
className={cn( 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", "hover:bg-muted/50 hover:scale-110",
"active:scale-95", "active:scale-95",
"animate-in fade-in zoom-in-95 duration-150", "animate-in fade-in zoom-in-95 duration-150",
@@ -428,7 +488,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</button> </button>
)} )}
{!isMobile && ( {!isMobile && !isFocusedMailLayout && (
<button <button
data-expand-toggle data-expand-toggle
onClick={(e) => { onClick={(e) => {
@@ -460,7 +520,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div> </div>
)} )}
{density !== 'extra-compact' && ( {!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar <Avatar
name={latestEmail.from?.[0]?.name} name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email} email={latestEmail.from?.[0]?.email}
@@ -470,6 +530,59 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{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" />}
{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 justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn( <span className={cn(
@@ -553,12 +666,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{latestEmail.preview || "No preview available"} {latestEmail.preview || "No preview available"}
</p> </p>
)} )}
</>
)}
</div> </div>
</div> </div>
{/* Hover Quick Actions for thread header */} {/* Hover Quick Actions for thread header */}
<EmailHoverActions <EmailHoverActions
email={latestEmail} email={latestEmail}
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined} onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
@@ -568,7 +684,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
/> />
</div> </div>
{isExpanded && !isMobile && ( {isExpanded && !isMobile && !isFocusedMailLayout && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200"> <div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? ( {isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground"> <div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
+138 -11
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom"; 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 { AccountSwitcher } from "./account-switcher";
import { icons as lucideIcons, type LucideIcon } from "lucide-react"; import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
@@ -16,8 +16,11 @@ import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store"; import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { getInitials } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
interface NavItem { interface NavItem {
id: string; id: string;
@@ -164,11 +167,55 @@ export function NavigationRail({
const { mailboxes } = useEmailStore(); const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore(); const { supportsWebDAV } = useWebDAVStore();
const sidebarApps = useSettingsStore((s) => s.sidebarApps); const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : []; const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const { client } = useAuthStore.getState(); const { client } = useAuthStore.getState();
@@ -504,6 +551,22 @@ export function NavigationRail({
<Keyboard className="w-[18px] h-[18px]" /> <Keyboard className="w-[18px] h-[18px]" />
</button> </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 && ( {quota && quota.total > 0 && (
<div data-tour="storage-quota"> <div data-tour="storage-quota">
@@ -511,21 +574,85 @@ export function NavigationRail({
</div> </div>
)} )}
{isPushConnected != null && ( {onLogout && showRailAccountList && accounts.length > 0 && (
<span <>
className="relative group" <div className="w-8 border-t my-1" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
> {/* Account circles */}
<span <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( className={cn(
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300", "relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40" 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> </span>
)} )}
</button>
);
})}
</div>
{onLogout && ( {/* 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 && !showRailAccountList && (
<AccountSwitcher variant="rail" /> <AccountSwitcher variant="rail" />
)} )}
</div> </div>
+115 -20
View File
@@ -12,7 +12,6 @@ import {
Star, Star,
Trash2, Trash2,
Archive, Archive,
PenSquare,
ChevronsLeft, ChevronsLeft,
ChevronsRight, ChevronsRight,
ChevronRight, 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")} /> <span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
{!isCollapsed && ( {!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"> <span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className={cn( <span className={cn(
@@ -428,9 +427,16 @@ export function Sidebar({
onUnreadFilterClick, onUnreadFilterClick,
className, className,
}: SidebarProps) { }: SidebarProps) {
const router = useRouter();
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity: _primaryIdentity } = useAuthStore(); const { primaryIdentity: _primaryIdentity } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); 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(() => { const [tagsExpanded, setTagsExpanded] = useState(() => {
try { try {
const stored = localStorage.getItem('sidebarTagsExpanded'); const stored = localStorage.getItem('sidebarTagsExpanded');
@@ -438,6 +444,7 @@ export function Sidebar({
} catch { return true; } } catch { return true; }
}); });
const emailKeywords = useSettingsStore(s => s.emailKeywords); const emailKeywords = useSettingsStore(s => s.emailKeywords);
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher);
const tagCounts = useEmailStore(s => s.tagCounts); const tagCounts = useEmailStore(s => s.tagCounts);
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
@@ -452,10 +459,17 @@ export function Sidebar({
} }
} else { } else {
const tree = buildMailboxTree(mailboxes); const tree = buildMailboxTree(mailboxes);
const defaultExpanded = tree const collectExpandable = (nodes: MailboxNode[]): string[] => {
.filter(node => node.children.length > 0) const ids: string[] = [];
.map(node => node.id); for (const node of nodes) {
setExpandedFolders(new Set(defaultExpanded)); if (node.children.length > 0) {
ids.push(node.id);
ids.push(...collectExpandable(node.children));
}
}
return ids;
};
setExpandedFolders(new Set(collectExpandable(tree)));
} }
}, [mailboxes]); }, [mailboxes]);
@@ -539,7 +553,7 @@ export function Sidebar({
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />} {isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
</Button> </Button>
{!isCollapsed && ( {!isCollapsed && !hideAccountSwitcher && (
<AccountSwitcher variant="expanded" className="flex-1" /> <AccountSwitcher variant="expanded" className="flex-1" />
)} )}
</div> </div>
@@ -553,6 +567,83 @@ export function Sidebar({
{/* Mailbox List */} {/* Mailbox List */}
<div className="flex-1 overflow-y-auto" data-tour="sidebar"> <div className="flex-1 overflow-y-auto" data-tour="sidebar">
<div className="py-1"> <div className="py-1">
{/* 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) && (
<>
{mailboxes.length === 0 ? ( {mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground"> <div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")} {!isCollapsed && t("loading_mailboxes")}
@@ -573,6 +664,8 @@ export function Sidebar({
))} ))}
</> </>
)} )}
</>
)}
</div> </div>
{/* Tags Section */} {/* Tags Section */}
@@ -635,6 +728,20 @@ export function Sidebar({
<span className="flex-1 truncate">{t("tags")}</span> <span className="flex-1 truncate">{t("tags")}</span>
)} )}
</button> </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> </div>
{((tagsExpanded && !isCollapsed) || isCollapsed) && ( {((tagsExpanded && !isCollapsed) || isCollapsed) && (
@@ -661,19 +768,7 @@ export function Sidebar({
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />} {!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div> </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> </div>
); );
} }
+17 -1
View File
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
const t = useTranslations('settings.appearance'); const t = useTranslations('settings.appearance');
const tTour = useTranslations('tour'); const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore(); 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 { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
@@ -145,6 +145,22 @@ export function AppearanceSettings() {
/> />
</SettingItem> </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 */} {/* Animations */}
{!isSettingHidden('animationsEnabled') && ( {!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}> <SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
@@ -164,6 +164,7 @@ export function CalendarManagementSettings() {
const [colorPickerId, setColorPickerId] = useState<string | null>(null); const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
const [deletingSubId, setDeletingSubId] = useState<string | null>(null); const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null); const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const tImport = useTranslations('calendar.import'); const tImport = useTranslations('calendar.import');
@@ -629,6 +630,14 @@ export function CalendarManagementSettings() {
)} )}
</div> </div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <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 <button
type="button" type="button"
onClick={() => handleRefreshSubscription(sub.id)} onClick={() => handleRefreshSubscription(sub.id)}
@@ -667,6 +676,14 @@ export function CalendarManagementSettings() {
onClose={() => setShowSubscriptionModal(false)} onClose={() => setShowSubscriptionModal(false)}
/> />
)} )}
{editingSubscription && client && (
<ICalSubscriptionModal
client={client}
editSubscription={editingSubscription}
onClose={() => setEditingSubscription(null)}
/>
)}
</SettingsSection> </SettingsSection>
); );
} }
+18
View File
@@ -19,6 +19,7 @@ export function CalendarSettings() {
showWeekNumbers, showWeekNumbers,
enableCalendarTasks, enableCalendarTasks,
showTasksOnCalendar, showTasksOnCalendar,
calendarHoverPreview,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore(); const { isFeatureEnabled } = usePolicyStore();
@@ -80,6 +81,23 @@ export function CalendarSettings() {
/> />
</SettingItem> </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') && ( {isFeatureEnabled('calendarTasksEnabled') && (
<> <>
<SettingItem <SettingItem
+110 -3
View File
@@ -4,16 +4,92 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store'; 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 { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils'; 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 { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react'; import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; 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() { export function EmailSettings() {
const t = useTranslations('settings.email_behavior'); const t = useTranslations('settings.email_behavior');
const { appName } = useConfig(); const { appName } = useConfig();
@@ -39,7 +115,9 @@ export function EmailSettings() {
deleteAction, deleteAction,
permanentlyDeleteJunk, permanentlyDeleteJunk,
showPreview, showPreview,
mailLayout,
disableThreading, disableThreading,
autoSelectReplyIdentity,
plainTextMode, plainTextMode,
emailsPerPage, emailsPerPage,
externalContentPolicy, externalContentPolicy,
@@ -62,6 +140,8 @@ export function EmailSettings() {
return t('trusted_senders.count_other', { count }); return t('trusted_senders.count_other', { count });
}; };
const isFocusedLayout = mailLayout === 'focus';
const handleReorganizeArchive = async () => { const handleReorganizeArchive = async () => {
const { client } = useAuthStore.getState(); const { client } = useAuthStore.getState();
const { mailboxes, fetchMailboxes } = useEmailStore.getState(); const { mailboxes, fetchMailboxes } = useEmailStore.getState();
@@ -207,9 +287,29 @@ export function EmailSettings() {
/> />
</SettingItem> </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 */} {/* Show Preview */}
{!isSettingHidden('showPreview') && ( {!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)} /> <ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem> </SettingItem>
)} )}
@@ -230,6 +330,13 @@ export function EmailSettings() {
/> />
</SettingItem> </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 */} {/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && ( {isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3"> <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 { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
import { useSmimeStore } from "@/stores/smime-store"; import { useSmimeStore } from "@/stores/smime-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useAuthStore } from "@/stores/auth-store";
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export"; import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types"; import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
@@ -50,6 +51,7 @@ export function SmimeSettings() {
} = useSmimeStore(); } = useSmimeStore();
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
// Local UI state // Local UI state
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
@@ -75,8 +77,8 @@ export function SmimeSettings() {
const [exportError, setExportError] = useState<string | null>(null); const [exportError, setExportError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
load(); load(activeAccountId ?? undefined);
}, [load]); }, [load, activeAccountId]);
// ── PKCS#12 import flow ──────────────────────────────────────── // ── PKCS#12 import flow ────────────────────────────────────────
+7 -3
View File
@@ -174,11 +174,13 @@ interface ThemeCardProps {
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) { function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
return ( return (
<div className="relative">
<button <button
type="button"
onClick={onActivate} onClick={onActivate}
disabled={disabled} disabled={disabled}
className={cn( 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', '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 isActive
? 'border-primary bg-primary/5 ring-1 ring-primary/20' ? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-primary/40 bg-card', : 'border-border hover:border-primary/40 bg-card',
@@ -221,17 +223,19 @@ function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled,
</div> </div>
)} )}
</div> </div>
</button>
{/* Remove button */} {/* Remove button */}
{onRemove && !isActive && ( {onRemove && !isActive && (
<button <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" 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" title="Remove theme"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </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/**", ".next/**",
"node_modules/**", "node_modules/**",
"repos/**", "repos/**",
"data/admin/plugins/**",
"*.config.js", "*.config.js",
"*.config.mjs", "*.config.mjs",
"e2e/**", "e2e/**",
+4
View File
@@ -25,6 +25,7 @@ interface ConfigData {
loginWebsiteUrl: string; loginWebsiteUrl: string;
demoMode: boolean; demoMode: boolean;
autoSsoEnabled: boolean; autoSsoEnabled: boolean;
allowCustomJmapEndpoint: boolean;
embeddedMode: boolean; embeddedMode: boolean;
parentOrigin: string; parentOrigin: string;
} }
@@ -100,6 +101,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: configCache?.loginWebsiteUrl || '', loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
demoMode: configCache?.demoMode || false, demoMode: configCache?.demoMode || false,
autoSsoEnabled: configCache?.autoSsoEnabled || false, autoSsoEnabled: configCache?.autoSsoEnabled || false,
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
embeddedMode: configCache?.embeddedMode || false, embeddedMode: configCache?.embeddedMode || false,
parentOrigin: configCache?.parentOrigin || '', parentOrigin: configCache?.parentOrigin || '',
isLoading: !configCache, isLoading: !configCache,
@@ -131,6 +133,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: configCache.loginWebsiteUrl, loginWebsiteUrl: configCache.loginWebsiteUrl,
demoMode: configCache.demoMode, demoMode: configCache.demoMode,
autoSsoEnabled: configCache.autoSsoEnabled, autoSsoEnabled: configCache.autoSsoEnabled,
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
embeddedMode: configCache.embeddedMode, embeddedMode: configCache.embeddedMode,
parentOrigin: configCache.parentOrigin, parentOrigin: configCache.parentOrigin,
isLoading: false, isLoading: false,
@@ -163,6 +166,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: data.loginWebsiteUrl, loginWebsiteUrl: data.loginWebsiteUrl,
demoMode: data.demoMode, demoMode: data.demoMode,
autoSsoEnabled: data.autoSsoEnabled, autoSsoEnabled: data.autoSsoEnabled,
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
embeddedMode: data.embeddedMode, embeddedMode: data.embeddedMode,
parentOrigin: data.parentOrigin, parentOrigin: data.parentOrigin,
isLoading: false, isLoading: false,
@@ -45,4 +45,43 @@ describe('calendar event normalization', () => {
showWithoutTime: true, 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 { describe, expect, it } from 'vitest';
import type { CalendarEvent } from '@/lib/jmap/types'; import type { CalendarEvent } from '@/lib/jmap/types';
import { import {
buildTimedFullDayWeekSegments,
buildWeekSegments, buildWeekSegments,
buildAllDayDuration, buildAllDayDuration,
getEventDayBounds, getEventDayBounds,
getEventDisplayEndDate, getEventDisplayEndDate,
getEventEndDate, getEventEndDate,
getEventStartDate,
getTimedEventBoundsForDay,
isTimedEventFullDayOnDate,
layoutOverlappingEvents,
normalizeAllDayDuration, normalizeAllDayDuration,
} from '../calendar-utils'; } from '../calendar-utils';
@@ -92,7 +97,110 @@ describe('calendar-utils all-day handling', () => {
utcEnd: '2026-03-14T11:00:00Z', 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', () => { 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);
});
});
+395
View File
@@ -0,0 +1,395 @@
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('mailbox deduplication side effects on nesting', () => {
it('should not remove an intermediate parent whose name matches a role mailbox', () => {
// Scenario: A non-role folder named "Sent" is an intermediate parent.
// The deduplication logic might remove it because it matches the role "sent" mailbox name.
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
// A user-created folder also named "Sent" that's a child of Inbox
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
// Child of the custom "Sent" folder
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
// The custom "Sent" (sent-custom) might be filtered by deduplication.
// If it IS removed, then "sent-child" loses its parent and becomes root — BAD.
const sentChild = flat.find(n => n.id === 'sent-child');
expect(sentChild).toBeDefined();
// Check if sent-child is orphaned at root (the bug)
const rootIds = tree.map(n => n.id);
const isOrphaned = rootIds.includes('sent-child');
if (isOrphaned) {
// This demonstrates the deduplication bug: removing an intermediate parent
// causes its children to become orphaned at root level
console.warn(
'BUG DETECTED: Deduplication removed intermediate parent "sent-custom", ' +
'orphaning "sent-child" to root level.'
);
}
// Document the current behavior (this test is diagnostic)
// Ideally: sent-child should be nested under sent-custom at depth 2
// If dedup removes sent-custom: sent-child ends up at root with depth 0
expect(sentChild!.depth).toBeGreaterThanOrEqual(0);
});
it('should not remove a parent folder whose name is a substring of a role name', () => {
// Folder named "Draft" (substring of "Drafts" role) used as intermediate parent
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();
const isOrphaned = rootIds.includes('draft-child');
if (isOrphaned) {
console.warn(
'BUG DETECTED: Deduplication removed "draft-folder" (substring match with "Drafts"), ' +
'orphaning "draft-child" to root level.'
);
}
expect(draftChild!.depth).toBeGreaterThanOrEqual(0);
});
});
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': [], 'settings-section': [],
'context-menu-email': [], 'context-menu-email': [],
'navigation-rail-bottom': [], 'navigation-rail-bottom': [],
'calendar-event-actions': [],
'admin-plugin-page': [],
}, },
initialized: false, 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 { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { useIdentityStore } from '@/stores/identity-store'; import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store'; import { useVacationStore } from '@/stores/vacation-store';
import { useSmimeStore } from '@/stores/smime-store';
// Minimal snapshot shapes — we only capture what we need // Minimal snapshot shapes — we only capture what we need
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -117,6 +118,7 @@ export function clearAllStores(): void {
useVacationStore.getState().clearState(); useVacationStore.getState().clearState();
useCalendarStore.getState().clearState(); useCalendarStore.getState().clearState();
useFilterStore.getState().clearState(); useFilterStore.getState().clearState();
useSmimeStore.getState().clearState();
} }
/** Evict cached state for one account */ /** 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 ─────────────────────────────────────────────────── // ─── 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 { export interface ServerPlugin {
id: string; id: string;
name: string; name: string;
@@ -28,6 +38,7 @@ export interface ServerPlugin {
entrypoint: string; entrypoint: string;
enabled: boolean; enabled: boolean;
forceEnabled?: boolean; forceEnabled?: boolean;
configSchema?: Record<string, PluginConfigField>;
installedAt: string; installedAt: string;
updatedAt: string; updatedAt: string;
} }
+1
View File
@@ -120,6 +120,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' }, oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' }, oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', 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 }, autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] }, cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' }, allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
+46 -4
View File
@@ -74,15 +74,57 @@ export function isAllDayEventLike(event: Pick<Partial<CalendarEvent>, 'start' |
&& end.getMilliseconds() === 0; && 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 { export function normalizeCalendarEventLike<T extends Partial<CalendarEvent>>(event: T): T {
if (!isAllDayEventLike(event)) { // First normalize Stalwart's singular property names to RFC 8984 plural forms
return event; const normalized = normalizeStalwartPropertyNames(event);
if (!isAllDayEventLike(normalized)) {
return normalized;
} }
return { return {
...event, ...normalized,
showWithoutTime: true, showWithoutTime: true,
duration: normalizeAllDayDurationValue(event.duration), duration: normalizeAllDayDurationValue(normalized.duration),
} as T; } 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 { parseDuration } from "@/components/calendar/event-card";
import type { CalendarEvent } from "@/lib/jmap/types"; import type { CalendarEvent } from "@/lib/jmap/types";
@@ -11,15 +11,63 @@ export interface CalendarWeekSegment {
continuesAfter: boolean; 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 { 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; if (!event.duration) return start;
return new Date(start.getTime() + parseDuration(event.duration) * 60000); return new Date(start.getTime() + parseDuration(event.duration) * 60000);
} }
export function getEventDisplayEndDate(event: CalendarEvent): Date { export function getEventDisplayEndDate(event: CalendarEvent): Date {
const end = getEventEndDate(event); 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 end;
} }
return subMilliseconds(end, 1); return subMilliseconds(end, 1);
@@ -27,11 +75,44 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } { export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } {
return { return {
startDay: startOfDay(parseISO(event.start)), startDay: startOfDay(getEventStartDate(event)),
endDay: startOfDay(getEventDisplayEndDate(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 { export function normalizeAllDayDuration(duration: string | undefined): string | undefined {
if (!duration) return undefined; if (!duration) return undefined;
const totalMinutes = parseDuration(duration); const totalMinutes = parseDuration(duration);
@@ -44,7 +125,7 @@ export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
return `P${dayCount}D`; 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 []; if (weekDays.length === 0) return [];
const weekStart = startOfDay(weekDays[0]); const weekStart = startOfDay(weekDays[0]);
@@ -71,59 +152,89 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca
} satisfies CalendarWeekSegment]; } satisfies CalendarWeekSegment];
}); });
rawSegments.sort((left, right) => { return rawSegments;
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) { export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
return left.event.showWithoutTime ? -1 : 1; 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; const segments: CalendarWeekSegment[] = [];
return (left.event.title || "").localeCompare(right.event.title || ""); 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 packWeekSegments(rawSegments);
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 layoutOverlappingEvents( export function layoutOverlappingEvents(
events: CalendarEvent[], events: CalendarEvent[],
): { event: CalendarEvent; column: number; totalColumns: number }[] { day: Date,
const sorted = [...events].sort((a, b) => { ): TimedEventLayout[] {
const diff = parseISO(a.start).getTime() - parseISO(b.start).getTime(); 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; 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 columns: { event: CalendarEvent; end: number }[][] = [];
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = []; const result: TimedEventLayout[] = [];
for (const event of sorted) { 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; let placed = false;
for (let col = 0; col < columns.length; col++) { for (let col = 0; col < columns.length; col++) {
if (columns[col].every(e => e.end <= startMin)) { if (columns[col].every(e => e.end <= event.startMinutes)) {
columns[col].push({ event, end: endMin }); columns[col].push({ event: event.event, end: event.endMinutes });
result.push({ event, column: col, totalColumns: 0 }); result.push({ ...event, column: col, totalColumns: 0 });
placed = true; placed = true;
break; break;
} }
} }
if (!placed) { if (!placed) {
columns.push([{ event, end: endMin }]); columns.push([{ event: event.event, end: event.endMinutes }]);
result.push({ event, column: columns.length - 1, totalColumns: 0 }); result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
} }
} }
+12
View File
@@ -41,6 +41,9 @@ export class DemoJMAPClient implements IJMAPClient {
getServerUrl(): string { return 'https://demo.example.com'; } getServerUrl(): string { return 'https://demo.example.com'; }
getAuthHeader(): string { return 'Bearer demo-token'; } getAuthHeader(): string { return 'Bearer demo-token'; }
updateAccessToken(): void { /* no-op */ } updateAccessToken(): void { /* no-op */ }
upgradeToBearer(): void { /* no-op */ }
enableTotpReauth(): void { /* no-op */ }
updateBasicAuth(): void { /* no-op */ }
getAccountId(): string { return 'demo-account'; } getAccountId(): string { return 'demo-account'; }
getUsername(): string { return 'demo@example.com'; } getUsername(): string { return 'demo@example.com'; }
@@ -597,6 +600,15 @@ export class DemoJMAPClient implements IJMAPClient {
return full; 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> { async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
const event = this.data.calendarEvents.find(e => e.id === eventId); const event = this.data.calendarEvents.find(e => e.id === eventId);
if (!event) throw new Error('Event not found'); if (!event) throw new Error('Event not found');
+4
View File
@@ -19,6 +19,9 @@ export interface IJMAPClient {
getServerUrl(): string; getServerUrl(): string;
getAuthHeader(): string; getAuthHeader(): string;
updateAccessToken(token: string): void; 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; getAccountId(): string;
getUsername(): string; getUsername(): string;
@@ -193,6 +196,7 @@ export interface IJMAPClient {
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>; getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>; getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>; createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
batchCreateCalendarEvents(events: Partial<CalendarEvent>[], targetAccountId?: string): Promise<{ created: CalendarEvent[]; failed: string[] }>;
updateCalendarEvent( updateCalendarEvent(
eventId: string, eventId: string,
updates: Partial<CalendarEvent>, updates: Partial<CalendarEvent>,
+611 -38
View File
@@ -95,6 +95,24 @@ const EMAIL_LIST_PROPERTIES = [
"hasAttachment", "hasAttachment",
] as const; ] as const;
/**
* Detect whether a calendar object returned by the server is actually a
* task (VTODO) rather than an event (VEVENT). CalDAV clients like
* Thunderbird create VTODOs that Stalwart exposes through the
* CalendarEvent endpoints without a reliable `@type` discriminator.
*/
function isTaskObject(obj: { '@type'?: string; progress?: unknown; due?: unknown; percentComplete?: unknown }): boolean {
const type = obj['@type'];
if (typeof type === 'string' && type.toLowerCase() === 'task') return true;
// CalDAV-created tasks may lack @type='Task' — detect by task-specific fields
if (type !== 'Event' && (
('progress' in obj && typeof obj.progress === 'string') ||
('due' in obj && obj.due != null) ||
('percentComplete' in obj)
)) return true;
return false;
}
const CALENDAR_EVENT_PROPERTIES = [ const CALENDAR_EVENT_PROPERTIES = [
'id', 'id',
'@type', '@type',
@@ -127,9 +145,9 @@ const CALENDAR_EVENT_PROPERTIES = [
'hideAttendees', 'hideAttendees',
'recurrenceId', 'recurrenceId',
'recurrenceIdTimeZone', 'recurrenceIdTimeZone',
'recurrenceRules', 'recurrenceRule',
'recurrenceOverrides', 'recurrenceOverrides',
'excludedRecurrenceRules', 'excludedRecurrenceRule',
'useDefaultAlerts', 'useDefaultAlerts',
'alerts', 'alerts',
'locations', 'locations',
@@ -140,6 +158,74 @@ const CALENDAR_EVENT_PROPERTIES = [
'isOrigin', 'isOrigin',
] as const; ] as const;
// Task-specific properties for CalendarEvent/get when fetching Task objects
const CALENDAR_TASK_PROPERTIES = [
'id',
'@type',
'uid',
'calendarIds',
'title',
'description',
'descriptionContentType',
'created',
'updated',
'start',
'due',
'duration',
'timeZone',
'showWithoutTime',
'utcStart',
'utcEnd',
'progress',
'progressUpdated',
'priority',
'privacy',
'color',
'keywords',
'categories',
'recurrenceRule',
'recurrenceOverrides',
'excludedRecurrenceRule',
'useDefaultAlerts',
'alerts',
'relatedTo',
] as const;
/**
* Stalwart's calcard crate uses singular property names ("recurrenceRule")
* instead of the RFC 8984 plural forms ("recurrenceRules").
* JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object,
* not an array. This function converts our internal array form to a single
* object, cleans null values, and renames the properties.
*/
function cleanRecurrenceRules(event: Record<string, unknown>): void {
const keyMap: Record<string, string> = {
recurrenceRules: 'recurrenceRule',
excludedRecurrenceRules: 'excludedRecurrenceRule',
};
for (const [pluralKey, singularKey] of Object.entries(keyMap)) {
const rules = event[pluralKey];
if (rules === undefined) continue;
delete event[pluralKey];
if (!Array.isArray(rules)) {
// null means "remove recurrence" — pass through with the correct key
event[singularKey] = rules;
continue;
}
if (rules.length === 0) {
event[singularKey] = null;
continue;
}
// JSCalendar 2.0: recurrenceRule is a single object, use first rule
const rule = rules[0] as Record<string, unknown>;
const cleaned: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rule)) {
if (v !== null) cleaned[k] = v;
}
event[singularKey] = cleaned;
}
}
function getCalendarEventDebugSnapshot(event: Partial<CalendarEvent> | null | undefined): Record<string, unknown> | null { function getCalendarEventDebugSnapshot(event: Partial<CalendarEvent> | null | undefined): Record<string, unknown> | null {
if (!event) { if (!event) {
return null; return null;
@@ -194,9 +280,11 @@ export class JMAPClient implements IJMAPClient {
private serverUrl: string; private serverUrl: string;
private username: string; private username: string;
private password: string; private password: string;
private basePassword: string = '';
private authHeader: string; private authHeader: string;
private authMode: 'basic' | 'bearer' = 'basic'; private authMode: 'basic' | 'bearer' = 'basic';
private onTokenRefresh?: () => Promise<string | null>; private onTokenRefresh?: () => Promise<string | null>;
private onTotpRequired?: () => Promise<string | null>;
private apiUrl: string = ""; private apiUrl: string = "";
private accountId: string = ""; private accountId: string = "";
private downloadUrl: string = ""; private downloadUrl: string = "";
@@ -239,6 +327,29 @@ export class JMAPClient implements IJMAPClient {
this.authHeader = `Bearer ${token}`; this.authHeader = `Bearer ${token}`;
} }
/** Upgrade an existing basic-auth client to bearer-token auth (e.g. after TOTP token exchange). */
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void {
this.authMode = 'bearer';
this.authHeader = `Bearer ${accessToken}`;
this.onTokenRefresh = onRefresh;
}
/**
* Enable TOTP re-authentication for basic-auth sessions.
* When a 401 is received, the callback is invoked to get a fresh TOTP code.
* The base password (without TOTP) is stored so we can construct new credentials.
*/
enableTotpReauth(basePassword: string, callback: () => Promise<string | null>): void {
this.basePassword = basePassword;
this.onTotpRequired = callback;
}
/** Update basic-auth credentials with a new password (e.g. password$newTotp). */
updateBasicAuth(newPassword: string): void {
this.password = newPassword;
this.authHeader = `Basic ${btoa(`${this.username}:${newPassword}`)}`;
}
getAuthHeader(): string { getAuthHeader(): string {
return this.authHeader; return this.authHeader;
} }
@@ -291,7 +402,21 @@ export class JMAPClient implements IJMAPClient {
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader }; const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
response = await fetch(url, { ...init, headers: retryHeaders }); response = await fetch(url, { ...init, headers: retryHeaders });
} catch { } catch {
// Session refresh failed — return original 401 response // Session refresh failed — if TOTP was used, try re-auth with fresh TOTP
if (this.onTotpRequired && this.basePassword) {
try {
const newTotp = await this.onTotpRequired();
if (newTotp) {
this.updateBasicAuth(`${this.basePassword}$${newTotp}`);
await this.refreshSession();
this.connectionChangeCallback?.(true);
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
response = await fetch(url, { ...init, headers: retryHeaders });
}
} catch {
// TOTP re-auth also failed — return original 401
}
}
} finally { } finally {
this.reconnecting = false; this.reconnecting = false;
} }
@@ -335,6 +460,16 @@ export class JMAPClient implements IJMAPClient {
? 'Authentication failed - token may be expired' ? 'Authentication failed - token may be expired'
: 'Invalid username or password'); : 'Invalid username or password');
} }
if (sessionResponse.status === 402) {
try {
const body = await sessionResponse.json();
if (body?.title?.toLowerCase().includes('totp')) {
throw new Error('TOTP_REQUIRED');
}
} catch (e) {
if (e instanceof Error && e.message === 'TOTP_REQUIRED') throw e;
}
}
throw new Error(`Failed to get session: ${sessionResponse.status}`); throw new Error(`Failed to get session: ${sessionResponse.status}`);
} }
@@ -535,6 +670,27 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") { if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[]; const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
debug.log(`[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`);
// Warn if response might be truncated
const maxObjects = this.getMaxObjectsInGet();
if (rawMailboxes.length >= maxObjects) {
debug.warn(
`[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
`Some mailboxes may be missing — nested folders could appear orphaned at root level.`
);
}
// Log parentId references to detect potential orphans
const returnedIds = new Set(rawMailboxes.map(mb => mb.id));
const missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId));
if (missingParents.length > 0) {
debug.warn(
`[JMAP Mailbox] ${missingParents.length} mailbox(es) reference parentId not in response (will be orphaned):`,
missingParents.map(mb => ({ id: mb.id, name: mb.name, parentId: mb.parentId }))
);
}
return rawMailboxes.map((mb) => ({ return rawMailboxes.map((mb) => ({
id: mb.id, id: mb.id,
originalId: undefined, originalId: undefined,
@@ -599,6 +755,17 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") { if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[]; const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
debug.log(`[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`);
// Warn if response might be truncated
const maxObjects = this.getMaxObjectsInGet();
if (rawMailboxes.length >= maxObjects) {
debug.warn(
`[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
`Some mailboxes may be missing.`
);
}
const mailboxes = rawMailboxes.map((mb) => ({ const mailboxes = rawMailboxes.map((mb) => ({
id: isPrimary ? mb.id : `${accountId}:${mb.id}`, id: isPrimary ? mb.id : `${accountId}:${mb.id}`,
originalId: mb.id, originalId: mb.id,
@@ -649,6 +816,7 @@ export class JMAPClient implements IJMAPClient {
sort: [{ property: "receivedAt", isAscending: false }], sort: [{ property: "receivedAt", isAscending: false }],
limit, limit,
position, position,
calculateTotal: true,
}, "0"], }, "0"],
["Email/get", { ["Email/get", {
accountId: targetAccountId, accountId: targetAccountId,
@@ -661,7 +829,12 @@ export class JMAPClient implements IJMAPClient {
const getResponse = response.methodResponses?.[1]?.[1]; const getResponse = response.methodResponses?.[1]?.[1];
if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) { if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) {
const emails = getResponse.list || []; const emails = (getResponse.list || []) as Email[];
// Sort client-side as safety net — some servers may not honour
// the query sort for large mailboxes without additional filters.
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
const total = queryResponse?.total || 0; const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit); const hasMore = computeHasMore(position, emails.length, total, limit);
@@ -1181,6 +1354,7 @@ export class JMAPClient implements IJMAPClient {
sort: [{ property: "receivedAt", isAscending: false }], sort: [{ property: "receivedAt", isAscending: false }],
limit, limit,
position, position,
calculateTotal: true,
}, "0"], }, "0"],
["Email/get", { ["Email/get", {
accountId: targetAccountId, accountId: targetAccountId,
@@ -1190,7 +1364,10 @@ export class JMAPClient implements IJMAPClient {
]); ]);
const queryResponse = response.methodResponses?.[0]?.[1]; const queryResponse = response.methodResponses?.[0]?.[1];
const emails = response.methodResponses?.[1]?.[1]?.list || []; const emails = (response.methodResponses?.[1]?.[1]?.list || []) as Email[];
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
const total = queryResponse?.total || 0; const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit); const hasMore = computeHasMore(position, emails.length, total, limit);
@@ -1217,6 +1394,7 @@ export class JMAPClient implements IJMAPClient {
sort: [{ property: "receivedAt", isAscending: false }], sort: [{ property: "receivedAt", isAscending: false }],
limit, limit,
position, position,
calculateTotal: true,
}, "0"], }, "0"],
["Email/get", { ["Email/get", {
accountId: targetAccountId, accountId: targetAccountId,
@@ -1226,7 +1404,10 @@ export class JMAPClient implements IJMAPClient {
]); ]);
const queryResponse = response.methodResponses?.[0]?.[1]; const queryResponse = response.methodResponses?.[0]?.[1];
const emails = response.methodResponses?.[1]?.[1]?.list || []; const emails = (response.methodResponses?.[1]?.[1]?.list || []) as Email[];
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
const total = queryResponse?.total || 0; const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit); const hasMore = computeHasMore(position, emails.length, total, limit);
@@ -1601,19 +1782,28 @@ export class JMAPClient implements IJMAPClient {
} }
let finalIdentityId = identityId; let finalIdentityId = identityId;
if (!finalIdentityId) { let identityReplyTo: EmailAddress[] | undefined;
{
const identityResponse = await this.request([ const identityResponse = await this.request([
["Identity/get", { accountId: this.accountId }, "0"] ["Identity/get", { accountId: this.accountId }, "0"]
]); ]);
if (!finalIdentityId) {
finalIdentityId = this.accountId; finalIdentityId = this.accountId;
}
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") { if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[]; const identities = (identityResponse.methodResponses[0][1].list || []) as Identity[];
if (identities.length > 0) { if (identities.length > 0) {
if (!identityId) {
const target = fromEmail || this.username; const target = fromEmail || this.username;
const matchingIdentity = identities.find((id) => id.email === target) const matchingIdentity = identities.find((id) => id.email === target)
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined); || (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
finalIdentityId = matchingIdentity?.id || identities[0].id; finalIdentityId = matchingIdentity?.id || identities[0].id;
identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo;
} else {
const matchedIdentity = identities.find((id) => id.id === identityId);
identityReplyTo = matchedIdentity?.replyTo;
}
} }
} }
} }
@@ -1621,6 +1811,7 @@ export class JMAPClient implements IJMAPClient {
// Always create a new email with the final body content // Always create a new email with the final body content
const emailCreate: Record<string, unknown> = { const emailCreate: Record<string, unknown> = {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }], from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
to: to.map(email => ({ email })), to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })), cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })), bcc: bcc?.map(email => ({ email })),
@@ -3052,6 +3243,7 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") { if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
return ((response.methodResponses[1][1].list || []) as CalendarEvent[]) return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
.filter((event) => !isTaskObject(event))
.map((event) => normalizeCalendarEventLike(event)); .map((event) => normalizeCalendarEventLike(event));
} }
return []; return [];
@@ -3112,10 +3304,9 @@ export class JMAPClient implements IJMAPClient {
filter, filter,
limit: limit || 1000, limit: limit || 1000,
}; };
// Expand recurring events into individual occurrences when a date range is provided // NOTE: We do NOT use expandRecurrences because Stalwart returns synthetic
if (filter.after || filter.before) { // IDs that cannot be used for CalendarEvent/set (update/destroy).
queryArgs.expandRecurrences = true; // Recurrence expansion is done client-side instead.
}
if (sort) { if (sort) {
queryArgs.sort = sort; queryArgs.sort = sort;
} }
@@ -3131,6 +3322,7 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") { if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
return ((response.methodResponses[1][1].list || []) as CalendarEvent[]) return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
.filter((event) => !isTaskObject(event))
.map((event) => normalizeCalendarEventLike(event)); .map((event) => normalizeCalendarEventLike(event));
} }
return []; return [];
@@ -3167,12 +3359,13 @@ export class JMAPClient implements IJMAPClient {
// Strip client-only shared fields before sending to JMAP // Strip client-only shared fields before sending to JMAP
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent; const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
debug.group('CalendarEvent/create'); debug.group('CalendarEvent/create');
debug.log('CalendarEvent/create outgoing payload', { debug.log('CalendarEvent/create outgoing payload', {
accountId, accountId,
sendSchedulingMessages, sendSchedulingMessages,
event: getCalendarEventDebugSnapshot(cleanEvent), eventKeys: Object.keys(cleanEvent),
}); });
const setArgs: Record<string, unknown> = { const setArgs: Record<string, unknown> = {
@@ -3197,6 +3390,8 @@ export class JMAPClient implements IJMAPClient {
if (result.notCreated?.["new-event"]) { if (result.notCreated?.["new-event"]) {
const error = result.notCreated["new-event"]; const error = result.notCreated["new-event"];
debug.warn('CalendarEvent/create notCreated', error); debug.warn('CalendarEvent/create notCreated', error);
debug.warn('CalendarEvent/create invalid properties', error.properties);
debug.warn('CalendarEvent/create sent keys', Object.keys(cleanEvent));
debug.groupEnd(); debug.groupEnd();
throw new Error(error.description || "Failed to create calendar event"); throw new Error(error.description || "Failed to create calendar event");
} }
@@ -3241,6 +3436,75 @@ export class JMAPClient implements IJMAPClient {
throw new Error("Failed to create calendar event"); throw new Error("Failed to create calendar event");
} }
/**
* Batch-create multiple calendar events in a single JMAP request.
* Returns arrays of successfully created events and failed creation keys.
*/
async batchCreateCalendarEvents(
events: Partial<CalendarEvent>[],
targetAccountId?: string,
): Promise<{ created: CalendarEvent[]; failed: string[] }> {
if (events.length === 0) return { created: [], failed: [] };
const accountId = targetAccountId || this.getCalendarsAccountId();
// Build the create map: { "new-0": event0, "new-1": event1, ... }
const createMap: Record<string, Partial<CalendarEvent>> = {};
for (let i = 0; i < events.length; i++) {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent;
createMap[`new-${i}`] = clean;
}
debug.log('CalendarEvent/batchCreate', { count: events.length, accountId });
const response = await this.request([
["CalendarEvent/set", { accountId, create: createMap }, "0"]
], this.calendarUsing());
const createdIds: string[] = [];
const failed: string[] = [];
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
for (let i = 0; i < events.length; i++) {
const key = `new-${i}`;
if (result.created?.[key]?.id) {
createdIds.push(result.created[key].id);
} else if (result.notCreated?.[key]) {
debug.warn(`CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
failed.push(key);
}
}
}
if (createdIds.length === 0) {
return { created: [], failed };
}
// Fetch all created events in a single CalendarEvent/get
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
ids: createdIds,
}, "0"]
], this.calendarUsing());
let createdEvents: CalendarEvent[] = [];
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const list = getResponse.methodResponses[0][1].list || [];
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
}
debug.log('CalendarEvent/batchCreate result', {
requested: events.length,
created: createdEvents.length,
failed: failed.length,
});
return { created: createdEvents, failed };
}
async updateCalendarEvent( async updateCalendarEvent(
eventId: string, eventId: string,
updates: Partial<CalendarEvent>, updates: Partial<CalendarEvent>,
@@ -3251,6 +3515,7 @@ export class JMAPClient implements IJMAPClient {
// Strip client-only shared fields before sending to JMAP // Strip client-only shared fields before sending to JMAP
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent; const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
cleanRecurrenceRules(cleanUpdates as unknown as Record<string, unknown>);
const setArgs: Record<string, unknown> = { const setArgs: Record<string, unknown> = {
accountId, accountId,
@@ -3262,20 +3527,33 @@ export class JMAPClient implements IJMAPClient {
setArgs.sendSchedulingMessages = sendSchedulingMessages; setArgs.sendSchedulingMessages = sendSchedulingMessages;
} }
debug.log('CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages });
const response = await this.request([ const response = await this.request([
["CalendarEvent/set", setArgs, "0"] ["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing()); ], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const methodName = response.methodResponses?.[0]?.[0];
const result = response.methodResponses[0][1]; const result = response.methodResponses?.[0]?.[1];
if (methodName === "error") {
const errorType = result?.type || 'unknown';
const errorDesc = result?.description || '';
debug.error('CalendarEvent/set update returned JMAP error', { type: errorType, description: errorDesc });
throw new Error(`JMAP error (${errorType}): ${errorDesc}`);
}
if (methodName === "CalendarEvent/set") {
if (result.notUpdated?.[eventId]) { if (result.notUpdated?.[eventId]) {
const error = result.notUpdated[eventId]; const error = result.notUpdated[eventId];
debug.error('CalendarEvent/set notUpdated', { eventId, error });
throw new Error(error.description || "Failed to update calendar event"); throw new Error(error.description || "Failed to update calendar event");
} }
debug.log('CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null });
return; return;
} }
debug.error('CalendarEvent/set update unexpected response', { methodName, result });
throw new Error("Failed to update calendar event"); throw new Error("Failed to update calendar event");
} }
@@ -3322,20 +3600,33 @@ export class JMAPClient implements IJMAPClient {
setArgs.sendSchedulingMessages = sendSchedulingMessages; setArgs.sendSchedulingMessages = sendSchedulingMessages;
} }
debug.log('CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages });
const response = await this.request([ const response = await this.request([
["CalendarEvent/set", setArgs, "0"] ["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing()); ], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const methodName = response.methodResponses?.[0]?.[0];
const result = response.methodResponses[0][1]; const result = response.methodResponses?.[0]?.[1];
if (methodName === "error") {
const errorType = result?.type || 'unknown';
const errorDesc = result?.description || '';
debug.error('CalendarEvent/set destroy returned JMAP error', { type: errorType, description: errorDesc });
throw new Error(`JMAP error (${errorType}): ${errorDesc}`);
}
if (methodName === "CalendarEvent/set") {
if (result.notDestroyed?.[eventId]) { if (result.notDestroyed?.[eventId]) {
const error = result.notDestroyed[eventId]; const error = result.notDestroyed[eventId];
debug.error('CalendarEvent/set notDestroyed', { eventId, error });
throw new Error(error.description || "Failed to delete calendar event"); throw new Error(error.description || "Failed to delete calendar event");
} }
debug.log('CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed });
return; return;
} }
debug.error('CalendarEvent/set destroy unexpected response', { methodName, result });
throw new Error("Failed to delete calendar event"); throw new Error("Failed to delete calendar event");
} }
@@ -3362,36 +3653,245 @@ export class JMAPClient implements IJMAPClient {
// ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ─── // ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ───
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> { async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
debug.group('CalendarTask/fetch');
debug.log('CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
try { try {
const events = await this.getCalendarEvents(calendarIds, targetAccountId); // Strategy 1: query with types filter (JMAP spec compliant)
return events.filter((e) => { const filter: Record<string, unknown> = { types: ['Task'] };
const obj = e as unknown as Record<string, unknown>; if (calendarIds && calendarIds.length > 0) {
const type = obj['@type']; filter.inCalendars = calendarIds;
// Explicit @type check (case-insensitive to handle server variations)
if (typeof type === 'string' && type.toLowerCase() === 'task') return true;
// Fallback: detect tasks created via CalDAV (e.g. Thunderbird) where @type
// may be missing. The "progress" property is exclusive to JSCalendar Task
// objects and never appears on Event objects.
if (type !== 'Event' && 'progress' in obj && typeof obj.progress === 'string') return true;
return false;
}).map((e) => {
const task = { ...e } as unknown as CalendarTask;
// Normalize @type for tasks detected by fallback heuristic
if (task['@type'] !== 'Task') {
(task as unknown as Record<string, unknown>)['@type'] = 'Task';
} }
return task;
debug.log('CalendarTask/fetch query filter', filter);
const response = await this.request([
["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"],
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_TASK_PROPERTIES],
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
], this.calendarUsing());
const queryResponse = response.methodResponses?.[0];
const getResponse = response.methodResponses?.[1];
debug.log('CalendarTask/fetch query method', queryResponse?.[0]);
debug.log('CalendarTask/fetch query result', queryResponse?.[1]);
if (queryResponse?.[0] === "error") {
debug.warn('CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]);
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.groupEnd();
return tasks;
}
if (getResponse?.[0] === "CalendarEvent/get") {
const list = (getResponse[1].list || []) as CalendarTask[];
const queryIds = queryResponse?.[1]?.ids || [];
debug.log('CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds);
debug.log('CalendarTask/fetch get returned', list.length, 'objects');
// If the types filter returned 0 results, the server may have silently
// ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to
// a full scan so we can detect tasks by their properties.
if (queryIds.length === 0) {
debug.warn('CalendarTask/fetch types filter returned 0 results, falling back to full scan');
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.groupEnd();
return tasks;
}
list.forEach((task, i) => {
debug.log(`CalendarTask/fetch [${i}]`, {
id: task.id,
uid: task.uid,
'@type': task['@type'],
title: task.title,
due: task.due,
start: task.start,
progress: task.progress,
showWithoutTime: task.showWithoutTime,
calendarIds: task.calendarIds,
}); });
});
const results = list.map((task) => ({
...task,
'@type': 'Task' as const,
}));
debug.log('CalendarTask/fetch complete,', results.length, 'tasks');
debug.groupEnd();
return results;
}
debug.warn('CalendarTask/fetch unexpected response shape', response.methodResponses);
debug.groupEnd();
return [];
} catch (error) { } catch (error) {
console.error('Failed to get calendar tasks:', error); debug.error('CalendarTask/fetch failed', error);
debug.groupEnd();
return []; return [];
} }
} }
/**
* Fallback for servers that don't support the `types` filter in CalendarEvent/query.
* Uses CalendarEvent/get with ids:null to fetch ALL calendar objects (JMAP spec),
* since CalendarEvent/query may only return Event-type objects on some servers.
*/
private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
debug.log('CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects');
// CalendarEvent/get with ids:null returns ALL calendar objects regardless of @type
const response = await this.request([
["CalendarEvent/get", {
accountId,
ids: null,
properties: [...CALENDAR_TASK_PROPERTIES],
}, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") {
debug.warn('CalendarTask/fallback unexpected response', response.methodResponses?.[0]);
return [];
}
const allObjects = (response.methodResponses[0][1].list || []) as Record<string, unknown>[];
debug.log('CalendarTask/fallback total calendar objects returned:', allObjects.length);
const tasks: CalendarTask[] = [];
const calendarIdSet = calendarIds ? new Set(calendarIds) : null;
allObjects.forEach((obj) => {
const type = obj['@type'];
const isExplicitTask = typeof type === 'string' && type.toLowerCase() === 'task';
// CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type
// set to something other than 'Event'. Detect them by the presence of
// task-specific fields: progress, due, or percentComplete.
const hasTaskFields = ('progress' in obj && typeof obj.progress === 'string')
|| ('due' in obj && obj.due != null)
|| ('percentComplete' in obj);
const isCalDavTask = type !== 'Event' && hasTaskFields;
debug.log('CalendarTask/fallback scan', {
id: obj.id,
'@type': type,
title: obj.title,
hasProgress: 'progress' in obj,
progress: obj.progress,
due: obj.due,
isExplicitTask,
isCalDavTask,
});
if (!isExplicitTask && !isCalDavTask) return;
// Filter by calendar if requested
if (calendarIdSet) {
const objCalendarIds = obj.calendarIds as Record<string, boolean> | undefined;
if (objCalendarIds && !Object.keys(objCalendarIds).some(id => calendarIdSet.has(id))) {
debug.log('CalendarTask/fallback skipping task (not in requested calendars)', obj.id);
return;
}
}
tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask);
});
debug.log('CalendarTask/fallback detected', tasks.length, 'tasks');
tasks.forEach((t, i) => {
debug.log(`CalendarTask/fallback [${i}]`, {
id: t.id,
uid: t.uid,
title: t.title,
due: t.due,
progress: t.progress,
showWithoutTime: t.showWithoutTime,
calendarIds: t.calendarIds,
});
});
return tasks;
}
async createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> { async createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
const event = { ...task, '@type': 'Task' } as unknown as Partial<CalendarEvent>; const accountId = targetAccountId || this.getCalendarsAccountId();
const created = await this.createCalendarEvent(event, false, targetAccountId); const { '@type': _type, ...taskData } = task;
return created as unknown as CalendarTask; const cleanTask = { ...taskData, '@type': 'Task' };
debug.group('CalendarTask/create');
debug.log('CalendarTask/create accountId', accountId);
debug.log('CalendarTask/create outgoing payload', cleanTask);
const response = await this.request([
["CalendarEvent/set", {
accountId,
sendSchedulingMessages: false,
create: { "new-task": cleanTask },
}, "0"]
], this.calendarUsing());
const result = response.methodResponses?.[0]?.[1];
debug.log('CalendarTask/create raw set response', result);
if (result?.notCreated?.["new-task"]) {
const error = result.notCreated["new-task"];
debug.warn('CalendarTask/create REJECTED by server', error);
debug.groupEnd();
throw new Error(error.description || "Failed to create task");
}
const createdId = result?.created?.["new-task"]?.id;
const serverCreated = result?.created?.["new-task"];
debug.log('CalendarTask/create server acknowledged', { createdId, serverCreated });
if (!createdId) {
debug.warn('CalendarTask/create no id in server response');
debug.groupEnd();
throw new Error("Failed to create task — no id returned");
}
// Fetch back with task-specific properties
debug.log('CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] });
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_TASK_PROPERTIES],
ids: [createdId],
}, "0"]
], this.calendarUsing());
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const list = getResponse.methodResponses[0][1].list || [];
const notFound = getResponse.methodResponses[0][1].notFound || [];
debug.log('CalendarTask/create get response', { found: list.length, notFound });
if (list[0]) {
const created = { ...list[0], '@type': 'Task' as const } as CalendarTask;
debug.log('CalendarTask/create final task object', {
id: created.id,
uid: created.uid,
'@type': created['@type'],
title: created.title,
due: created.due,
start: created.start,
progress: created.progress,
showWithoutTime: created.showWithoutTime,
calendarIds: created.calendarIds,
});
debug.groupEnd();
return created;
}
}
debug.warn('CalendarTask/create re-fetch returned nothing for id', createdId);
debug.groupEnd();
throw new Error("Failed to fetch created task");
} }
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void> { async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void> {
@@ -3682,6 +4182,10 @@ export class JMAPClient implements IJMAPClient {
private pollingStates: { [key: string]: string } = {}; private pollingStates: { [key: string]: string } = {};
private sseAbortController: AbortController | null = null; private sseAbortController: AbortController | null = null;
private sseReconnectTimeout: NodeJS.Timeout | null = null; private sseReconnectTimeout: NodeJS.Timeout | null = null;
private ssePingTimer: NodeJS.Timeout | null = null;
private lastSSEActivity: number = 0;
private visibilityHandler: (() => void) | null = null;
private onlineHandler: (() => void) | null = null;
private static readonly STATE_TYPE_MAP: Record<string, string> = { private static readonly STATE_TYPE_MAP: Record<string, string> = {
'Mailbox/get': 'Mailbox', 'Mailbox/get': 'Mailbox',
@@ -3693,6 +4197,7 @@ export class JMAPClient implements IJMAPClient {
private static readonly POLLING_INTERVAL = 3_000; private static readonly POLLING_INTERVAL = 3_000;
private static readonly SSE_RECONNECT_DELAY = 3_000; private static readonly SSE_RECONNECT_DELAY = 3_000;
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
setupPushNotifications(): boolean { setupPushNotifications(): boolean {
const eventSourceUrl = this.getEventSourceUrl(); const eventSourceUrl = this.getEventSourceUrl();
@@ -3701,6 +4206,7 @@ export class JMAPClient implements IJMAPClient {
} else { } else {
this.startPollingFallback(); this.startPollingFallback();
} }
this.setupBrowserEventListeners();
return true; return true;
} }
@@ -3741,11 +4247,16 @@ export class JMAPClient implements IJMAPClient {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
this.lastSSEActivity = Date.now();
this.startSSEPingMonitor();
try { try {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
this.lastSSEActivity = Date.now();
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n'); const parts = buffer.split('\n\n');
buffer = parts.pop() || ''; buffer = parts.pop() || '';
@@ -3758,6 +4269,8 @@ export class JMAPClient implements IJMAPClient {
if (error instanceof DOMException && error.name === 'AbortError') return; if (error instanceof DOMException && error.name === 'AbortError') return;
} }
this.stopSSEPingMonitor();
// Stream ended — reconnect unless we were intentionally closed // Stream ended — reconnect unless we were intentionally closed
if (this.sseAbortController && !this.sseAbortController.signal.aborted) { if (this.sseAbortController && !this.sseAbortController.signal.aborted) {
this.scheduleSSEReconnect(); this.scheduleSSEReconnect();
@@ -3930,10 +4443,70 @@ export class JMAPClient implements IJMAPClient {
this.eventSource.close(); this.eventSource.close();
this.eventSource = null; this.eventSource = null;
} }
this.stopSSEPingMonitor();
this.cleanupBrowserEventListeners();
this.stateChangeCallback = null; this.stateChangeCallback = null;
this.pollingStates = {}; this.pollingStates = {};
} }
private startSSEPingMonitor(): void {
this.stopSSEPingMonitor();
this.ssePingTimer = setInterval(() => {
if (Date.now() - this.lastSSEActivity > JMAPClient.SSE_PING_TIMEOUT) {
// SSE connection is stale — abort and reconnect
this.stopSSEPingMonitor();
if (this.sseAbortController) {
this.sseAbortController.abort();
this.sseAbortController = null;
}
this.scheduleSSEReconnect();
}
}, 30_000);
}
private stopSSEPingMonitor(): void {
if (this.ssePingTimer) {
clearInterval(this.ssePingTimer);
this.ssePingTimer = null;
}
}
private setupBrowserEventListeners(): void {
if (typeof document !== 'undefined') {
this.visibilityHandler = () => {
if (!document.hidden) {
// Tab became visible — immediately check for state changes
this.checkForStateChanges();
}
};
document.addEventListener('visibilitychange', this.visibilityHandler);
}
if (typeof window !== 'undefined') {
this.onlineHandler = () => {
// Network reconnected — reconnect SSE or force a poll
const eventSourceUrl = this.getEventSourceUrl();
if (eventSourceUrl && !this.sseAbortController) {
this.connectSSE(eventSourceUrl);
} else {
this.checkForStateChanges();
}
};
window.addEventListener('online', this.onlineHandler);
}
}
private cleanupBrowserEventListeners(): void {
if (this.visibilityHandler && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
if (this.onlineHandler && typeof window !== 'undefined') {
window.removeEventListener('online', this.onlineHandler);
this.onlineHandler = null;
}
}
onConnectionChange(callback: (connected: boolean) => void): void { onConnectionChange(callback: (connected: boolean) => void): void {
this.connectionChangeCallback = callback; this.connectionChangeCallback = callback;
} }
+1
View File
@@ -660,6 +660,7 @@ export interface CalendarEventFilter {
attendee?: string; attendee?: string;
participationStatus?: string; participationStatus?: string;
uid?: string; uid?: string;
types?: string[];
} }
// JMAP Push Notification Types (RFC 8620 Section 7) // JMAP Push Notification Types (RFC 8620 Section 7)
+81 -1
View File
@@ -11,11 +11,13 @@ import type {
SidebarWidget, SidebarWidget,
ContextMenuItem, ContextMenuItem,
KeyboardShortcut, KeyboardShortcut,
AdminPageSection,
CalendarEventAction,
SlotName, SlotName,
} from './plugin-types'; } from './plugin-types';
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types'; import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
import { import {
emailHooks, calendarHooks, contactHooks, fileHooks, emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks, authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
@@ -116,6 +118,8 @@ export interface PluginAPI {
registerDetailSidebar: (widget: SidebarWidget) => Disposable; registerDetailSidebar: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable; registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => Disposable; registerNavigationRailItem: (component: React.ComponentType) => Disposable;
registerCalendarEventAction: (action: CalendarEventAction) => Disposable;
registerAdminPage: (page: AdminPageSection) => Disposable;
}; };
hooks: PluginHooksAPI; hooks: PluginHooksAPI;
toast: { toast: {
@@ -126,6 +130,12 @@ export interface PluginAPI {
}; };
storage: ReturnType<typeof createPluginStorage>; storage: ReturnType<typeof createPluginStorage>;
log: ReturnType<typeof createPluginLogger>; 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) // Simplified hooks API type (all hooks return Disposable)
@@ -176,6 +186,9 @@ export interface PluginHooksAPI {
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable; onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlertAcknowledge: (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 // Contacts
onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeContactCreate: (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', onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read', onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read', onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write', onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write', onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
onBeforeEventDelete: 'calendar:write', onAfterEventDelete: '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)), ...Object.fromEntries(Object.entries(emailHooks)),
// Calendar // Calendar
...Object.fromEntries(Object.entries(calendarHooks)), ...Object.fromEntries(Object.entries(calendarHooks)),
// Calendar Form
...Object.fromEntries(Object.entries(calendarFormHooks)),
// Contacts // Contacts
...Object.fromEntries(Object.entries(contactHooks)), ...Object.fromEntries(Object.entries(contactHooks)),
// Files // Files
@@ -581,6 +597,38 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
requirePermission(plugin, 'ui:navigation-rail'); requirePermission(plugin, 'ui:navigation-rail');
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100); 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, hooks,
@@ -594,5 +642,37 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
storage: createPluginStorage(plugin.id), storage: createPluginStorage(plugin.id),
log: createPluginLogger(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(), onCalendarAlertAcknowledge: new HookBus(),
}; };
// §7.2b Calendar Form Hooks (UI integration)
export const calendarFormHooks = {
onCalendarEventFormOpen: new HookBus(),
onCalendarEventFormSave: new HookBus(),
};
// §7.3 Contact Hooks // §7.3 Contact Hooks
export const contactHooks = { export const contactHooks = {
onContactOpen: new HookBus(), onContactOpen: new HookBus(),
@@ -396,7 +402,7 @@ export const sidebarAppHooks = {
// ─── Aggregate: remove all handlers for a plugin across all buses ─── // ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [ const allHookGroups = [
emailHooks, calendarHooks, contactHooks, fileHooks, emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks, authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
+31 -1
View File
@@ -94,7 +94,9 @@ export type SlotName =
| 'email-detail-sidebar' | 'email-detail-sidebar'
| 'settings-section' | 'settings-section'
| 'context-menu-email' | 'context-menu-email'
| 'navigation-rail-bottom'; | 'navigation-rail-bottom'
| 'calendar-event-actions'
| 'admin-plugin-page';
export interface SlotRegistration { export interface SlotRegistration {
pluginId: string; pluginId: string;
@@ -147,6 +149,32 @@ export interface ContextMenuItem {
order?: number; 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 { export interface KeyboardShortcut {
id: string; id: string;
keys: string; keys: string;
@@ -377,6 +405,8 @@ export const ALL_PERMISSIONS = [
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer', 'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section', 'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard', 'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
'ui:calendar-action', 'ui:admin-page',
'admin:config',
'app:lifecycle', 'app:lifecycle',
] as const; ] as const;
+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;
}
+46
View File
@@ -148,4 +148,50 @@ describe('key-storage', () => {
expect(certs.find(c => c.id === id)).toBeUndefined(); 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: {}, defaultSignIdentity: {},
defaultEncrypt: false, defaultEncrypt: false,
rememberUnlockedKeys: false, rememberUnlockedKeys: false,
autoImportSignerCerts: false, autoImportSignerCerts: true,
accountPreferences: {},
currentAccountId: null,
isLoading: false, isLoading: false,
error: null, error: null,
}); });
+6
View File
@@ -274,6 +274,12 @@ export function getLinerCryptoEngine(): pkijs.CryptoEngine {
return linerEngine!; 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, * Run an async operation with the global PKI.js engine set to webcrypto-liner,
* then restore the previous engine afterwards. * then restore the previous engine afterwards.
+26 -9
View File
@@ -1,22 +1,35 @@
import type { SmimeKeyRecord, SmimePublicCert } from './types'; import type { SmimeKeyRecord, SmimePublicCert } from './types';
const DB_NAME = 'smime-store'; const DB_NAME = 'smime-store';
const DB_VERSION = 1; const DB_VERSION = 2;
const KEY_RECORDS_STORE = 'key-records'; const KEY_RECORDS_STORE = 'key-records';
const PUBLIC_CERTS_STORE = 'public-certs'; const PUBLIC_CERTS_STORE = 'public-certs';
function openDB(): Promise<IDBDatabase> { function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION); const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => { request.onupgradeneeded = (event) => {
const db = request.result; 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' }); const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
keyStore.createIndex('email', 'email', { unique: false }); keyStore.createIndex('email', 'email', { unique: false });
} keyStore.createIndex('accountId', 'accountId', { unique: false });
if (!db.objectStoreNames.contains(PUBLIC_CERTS_STORE)) {
const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' }); const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
certStore.createIndex('email', 'email', { unique: false }); 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); 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(); 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> { 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(); 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> { export async function deletePublicCert(id: string): Promise<void> {
+42 -3
View File
@@ -5,7 +5,7 @@ import {
classifyCapabilities, classifyCapabilities,
} from './certificate-utils'; } from './certificate-utils';
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types'; import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
import { withLinerEngine } from './crypto-engine'; import { withLinerEngine, getLinerCrypto } from './crypto-engine';
const KDF_ITERATIONS = 600_000; const KDF_ITERATIONS = 600_000;
const AES_KEY_LENGTH = 256; const AES_KEY_LENGTH = 256;
@@ -217,6 +217,8 @@ async function encryptPrivateKey(
export interface UnlockedKeyPair { export interface UnlockedKeyPair {
signingKey: CryptoKey; signingKey: CryptoKey;
decryptionKey?: 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. */ /** Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys for signing and decryption. */
@@ -257,7 +259,22 @@ export async function unlockPrivateKey(
} catch { } catch {
// Key may only support decryption (key-encipherment-only cert) // Key may only support decryption (key-encipherment-only cert)
const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages); 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) // 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) // 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). */ /** Get decrypted PKCS#8 bytes (for export flow). */
+49 -6
View File
@@ -15,8 +15,10 @@ export interface DecryptionInput {
cmsBytes: ArrayBuffer; cmsBytes: ArrayBuffer;
/** All imported key records to try matching against */ /** All imported key records to try matching against */
keyRecords: SmimeKeyRecord[]; keyRecords: SmimeKeyRecord[];
/** Unlocked CryptoKey map: keyRecordId → CryptoKey */ /** Unlocked CryptoKey map: keyRecordId → CryptoKey (RSA-OAEP) */
unlockedKeys: Map<string, CryptoKey>; unlockedKeys: Map<string, CryptoKey>;
/** Unlocked legacy CryptoKey map: keyRecordId → CryptoKey (RSAES-PKCS1-v1_5 via webcrypto-liner) */
legacyUnlockedKeys?: Map<string, CryptoKey>;
} }
export interface DecryptionResult { export interface DecryptionResult {
@@ -34,12 +36,24 @@ export interface DecryptionResult {
* @throws Error if no matching key is found, key is locked, or decryption fails * @throws Error if no matching key is found, key is locked, or decryption fails
*/ */
export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> { export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> {
const { cmsBytes, keyRecords, unlockedKeys } = input; const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input;
// Parse the CMS ContentInfo wrapper // Parse the CMS ContentInfo wrapper
const contentInfo = parseContentInfo(cmsBytes); const contentInfo = parseContentInfo(cmsBytes);
const envelopedData = extractEnvelopedData(contentInfo); 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 // Find matching key records
const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords); const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords);
@@ -51,6 +65,19 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
for (const { keyRecord, recipientIndex } of matchedRecords) { for (const { keyRecord, recipientIndex } of matchedRecords) {
const privateKey = unlockedKeys.get(keyRecord.id); const privateKey = unlockedKeys.get(keyRecord.id);
if (!privateKey) { 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 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), mimeBytes: new Uint8Array(decrypted),
keyRecordId: keyRecord.id, keyRecordId: keyRecord.id,
}; };
} catch { } catch (oaepError) {
// This key didn't work, try the next one // 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; continue;
} }
} }
// Check if we had matching records but none were unlocked // 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) { if (hasLockedMatch) {
const lockedRecord = matchedRecords.find(m => !unlockedKeys.has(m.keyRecord.id))!; const lockedRecord = matchedRecords.find(m => !isUnlocked(m.keyRecord.id))!;
throw new SmimeKeyLockedError( throw new SmimeKeyLockedError(
'S/MIME key is locked. Unlock it to decrypt this message.', 'S/MIME key is locked. Unlock it to decrypt this message.',
lockedRecord.keyRecord.id, lockedRecord.keyRecord.id,
+2
View File
@@ -1,6 +1,7 @@
/** Stored record for an imported S/MIME private key + certificate. */ /** Stored record for an imported S/MIME private key + certificate. */
export interface SmimeKeyRecord { export interface SmimeKeyRecord {
id: string; id: string;
accountId?: string;
email: string; email: string;
certificate: ArrayBuffer; // DER-encoded X.509 leaf cert certificate: ArrayBuffer; // DER-encoded X.509 leaf cert
certificateChain: ArrayBuffer[]; // DER-encoded intermediates certificateChain: ArrayBuffer[]; // DER-encoded intermediates
@@ -34,6 +35,7 @@ export interface SmimeUnlockedKey {
/** A recipient or contact public certificate. */ /** A recipient or contact public certificate. */
export interface SmimePublicCert { export interface SmimePublicCert {
id: string; id: string;
accountId?: string;
email: string; email: string;
certificate: ArrayBuffer; // DER-encoded X.509 certificate: ArrayBuffer; // DER-encoded X.509
issuer: string; issuer: string;
+56 -1
View File
@@ -1,6 +1,7 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Mailbox } from "./jmap/types"; import { Mailbox } from "./jmap/types";
import { debug } from "./debug";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@@ -98,6 +99,7 @@ const ROLE_PRIORITY: Record<string, number> = {
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail") // Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] { function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
const result: 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 // Group role mailboxes by account so deduplication is scoped per-account
const rolesByAccount = new Map<string, Mailbox[]>(); 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 // Filter out duplicates scoped to the same account
mailboxes.forEach(mb => { mailboxes.forEach(mb => {
// If this mailbox has a role, always keep it // If this mailbox has a role, always keep it
@@ -121,26 +129,47 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
const accountKey = mb.accountId || ''; const accountKey = mb.accountId || '';
const accountRoles = rolesByAccount.get(accountKey) || []; const accountRoles = rolesByAccount.get(accountKey) || [];
const lowerName = mb.name.toLowerCase(); const lowerName = mb.name.toLowerCase();
const isDuplicate = accountRoles.some(roleMb => { const matchedRole = accountRoles.find(roleMb => {
const roleLowerName = roleMb.name.toLowerCase(); const roleLowerName = roleMb.name.toLowerCase();
// Check for common duplicates: "Sent Mail" vs "Sent", etc. // Check for common duplicates: "Sent Mail" vs "Sent", etc.
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName); return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
}); });
const isDuplicate = !!matchedRole;
// Only keep if not a duplicate // Only keep if not a duplicate
if (!isDuplicate) { if (!isDuplicate) {
result.push(mb); 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(
`[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(`[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed);
}
return result; return result;
} }
// Build a hierarchical tree structure from flat mailbox array // Build a hierarchical tree structure from flat mailbox array
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] { export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
debug.log(`[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`);
// Deduplicate mailboxes first // Deduplicate mailboxes first
const deduplicated = deduplicateMailboxes(mailboxes); const deduplicated = deduplicateMailboxes(mailboxes);
if (deduplicated.length !== mailboxes.length) {
debug.log(`[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`);
}
// Separate own and shared mailboxes // Separate own and shared mailboxes
const ownMailboxes = deduplicated.filter(mb => !mb.isShared); const ownMailboxes = deduplicated.filter(mb => !mb.isShared);
const sharedMailboxes = deduplicated.filter(mb => mb.isShared); const sharedMailboxes = deduplicated.filter(mb => mb.isShared);
@@ -168,6 +197,7 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
}; };
// Second pass: build tree structure for own mailboxes // Second pass: build tree structure for own mailboxes
const orphanedMailboxes: { id: string; name: string; parentId: string }[] = [];
ownMailboxes.forEach(mailbox => { ownMailboxes.forEach(mailbox => {
const node = mailboxMap.get(mailbox.id)!; const node = mailboxMap.get(mailbox.id)!;
@@ -176,13 +206,38 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
parent.children.push(node); parent.children.push(node);
} else { } else {
// Root level mailbox or orphaned mailbox // Root level mailbox or orphaned mailbox
if (mailbox.parentId) {
orphanedMailboxes.push({ id: mailbox.id, name: mailbox.name, parentId: mailbox.parentId });
}
rootMailboxes.push(node); rootMailboxes.push(node);
} }
}); });
if (orphanedMailboxes.length > 0) {
debug.warn(
`[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`,
orphanedMailboxes
);
}
// Third pass: correctly calculate depths from the root down // Third pass: correctly calculate depths from the root down
recalculateDepths(rootMailboxes, 0); 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(
`[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 we have shared mailboxes, create a virtual "Shared Folders" parent
if (sharedMailboxes.length > 0) { if (sharedMailboxes.length > 0) {
// Group shared mailboxes by account // Group shared mailboxes by account
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "benutzer@beispiel.de", "username_placeholder": "benutzer@beispiel.de",
"password_label": "Passwort", "password_label": "Passwort",
"password_placeholder": "Geben Sie Ihr Passwort ein", "password_placeholder": "Geben Sie Ihr Passwort ein",
"jmap_endpoint_label": "JMAP-Server",
"jmap_endpoint_placeholder": "https://mail.example.com",
"jmap_endpoint_cors_hint": "Der Server muss CORS-Anfragen von dieser Domain zulassen.",
"sign_in": "Anmelden", "sign_in": "Anmelden",
"signing_in": "Wird angemeldet...", "signing_in": "Wird angemeldet...",
"loading": "Lädt...", "loading": "Lädt...",
@@ -115,6 +118,7 @@
"demo_reset": "Zurücksetzen", "demo_reset": "Zurücksetzen",
"demo_tour": "Tour", "demo_tour": "Tour",
"tags": "Tags", "tags": "Tags",
"folders": "Ordner",
"mail": "E-Mail", "mail": "E-Mail",
"nav_label": "Navigation", "nav_label": "Navigation",
"add_app": "Apps" "add_app": "Apps"
@@ -196,6 +200,8 @@
"no_email_description": "Wählen Sie eine E-Mail aus der Liste aus, um sie hier anzuzeigen", "no_email_description": "Wählen Sie eine E-Mail aus der Liste aus, um sie hier anzuzeigen",
"no_conversation_selected": "Keine Unterhaltung ausgewählt", "no_conversation_selected": "Keine Unterhaltung ausgewählt",
"no_conversation_description": "Wählen Sie eine Unterhaltung aus der Liste aus, um sie hier zu lesen", "no_conversation_description": "Wählen Sie eine Unterhaltung aus der Liste aus, um sie hier zu lesen",
"compose": "Verfassen",
"compose_hint": "Neue Nachricht verfassen",
"no_subject": "(Kein Betreff)", "no_subject": "(Kein Betreff)",
"loading_email": "E-Mail wird geladen...", "loading_email": "E-Mail wird geladen...",
"loading": "Lädt...", "loading": "Lädt...",
@@ -477,7 +483,7 @@
"smime_sign_off": "S/MIME-Signatur deaktiviert", "smime_sign_off": "S/MIME-Signatur deaktiviert",
"smime_encrypt_on": "S/MIME-Verschlüsselung aktiviert", "smime_encrypt_on": "S/MIME-Verschlüsselung aktiviert",
"smime_encrypt_off": "S/MIME-Verschlüsselung deaktiviert", "smime_encrypt_off": "S/MIME-Verschlüsselung deaktiviert",
"smime_encrypt_unavailable": "S/MIME-Verschlüsselung nicht möglich: {reason}", "smime_encrypt_unavailable": "S/MIME-Verschlüsselung nicht möglich fehlende Empfängerzertifikate",
"smime_unlock_title": "S/MIME-Schlüssel entsperren", "smime_unlock_title": "S/MIME-Schlüssel entsperren",
"smime_unlock_message": "Geben Sie die Passphrase ein, um Ihren S/MIME-Schlüssel zu entsperren.", "smime_unlock_message": "Geben Sie die Passphrase ein, um Ihren S/MIME-Schlüssel zu entsperren.",
"smime_unlock_button": "Entsperren", "smime_unlock_button": "Entsperren",
@@ -683,6 +689,14 @@
"toolbar_labels": { "toolbar_labels": {
"label": "Show Toolbar Labels", "label": "Show Toolbar Labels",
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons." "description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
},
"hide_account_switcher": {
"label": "Kontoumschalter in der Seitenleiste ausblenden",
"description": "Den Kontowähler oben in der Ordner-Seitenleiste ausblenden. Sie können weiterhin über die untere Navigationsleiste zwischen Konten wechseln."
},
"show_rail_account_list": {
"label": "Konto-Avatare in der Navigationsleiste anzeigen",
"description": "Individuelle Kontokreise am unteren Rand der Navigationsleiste für schnelles Umschalten anzeigen, mit einer Abmelde-Schaltfläche darunter."
} }
}, },
"keywords": { "keywords": {
@@ -799,9 +813,18 @@
"label": "Spam dauerhaft löschen", "label": "Spam dauerhaft löschen",
"description": "E-Mails aus dem Spam-Ordner dauerhaft löschen, anstatt sie in den Papierkorb zu verschieben" "description": "E-Mails aus dem Spam-Ordner dauerhaft löschen, anstatt sie in den Papierkorb zu verschieben"
}, },
"mail_layout": {
"label": "Mail-Layout",
"description": "Wählen Sie zwischen dem klassischen geteilten Lesebereich und einem fokussierten Lesefluss im Gmail-Stil.",
"split": "Geteilter Bereich",
"split_description": "Nachrichtenliste und Lesebereich nebeneinander sichtbar halten.",
"focus": "Fokussierte Liste",
"focus_description": "Eine Zeile pro Nachricht anzeigen und E-Mails in voller Breite öffnen, während die Ordner-Seitenleiste sichtbar bleibt."
},
"show_preview": { "show_preview": {
"label": "Vorschautext anzeigen", "label": "Vorschautext anzeigen",
"description": "E-Mail-Vorschau in der Liste anzeigen" "description": "E-Mail-Vorschau in der Liste anzeigen",
"focus_description": "Inline-Vorschautext in der fokussierten einzeiligen Nachrichtenliste anzeigen"
}, },
"disable_threading": { "disable_threading": {
"label": "Konversationsgruppierung deaktivieren", "label": "Konversationsgruppierung deaktivieren",
@@ -811,6 +834,10 @@
"label": "Nur Klartext", "label": "Nur Klartext",
"description": "Rich-Text-Editor deaktivieren und alle E-Mails nur als Klartext senden, einschließlich Antworten und Weiterleitungen" "description": "Rich-Text-Editor deaktivieren und alle E-Mails nur als Klartext senden, einschließlich Antworten und Weiterleitungen"
}, },
"auto_select_reply_identity": {
"label": "Antwortadresse automatisch wählen",
"description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat"
},
"attachment_click_action": { "attachment_click_action": {
"label": "Aktion beim Klick auf Anhänge", "label": "Aktion beim Klick auf Anhänge",
"description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird", "description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird",
@@ -1023,6 +1050,14 @@
"enabled": "Verschlüsselung im Ruhezustand aktiviert", "enabled": "Verschlüsselung im Ruhezustand aktiviert",
"disabled_success": "Verschlüsselung im Ruhezustand deaktiviert", "disabled_success": "Verschlüsselung im Ruhezustand deaktiviert",
"error": "Verschlüsselungseinstellungen konnten nicht aktualisiert werden" "error": "Verschlüsselungseinstellungen konnten nicht aktualisiert werden"
},
"email_client": {
"title": "E-Mail-Client-Einrichtung",
"description": "Verwenden Sie diese Anmeldedaten, um Ihren Desktop- oder Mobil-E-Mail-Client zu konfigurieren (Thunderbird, Apple Mail, Outlook usw.)",
"jmap_username_label": "JMAP-Benutzername",
"copy": "Kopieren",
"copied": "Kopiert",
"password_instructions": "Verwenden Sie Ihren oben angezeigten JMAP-Benutzernamen zusammen mit einem App-Passwort, um sich bei Ihrem E-Mail-Client anzumelden. Erstellen Sie im obigen Abschnitt ein App-Passwort, falls Sie noch keines haben."
} }
}, },
"identities": { "identities": {
@@ -1859,6 +1894,7 @@
"title": "Titel", "title": "Titel",
"description": "Beschreibung", "description": "Beschreibung",
"location": "Ort", "location": "Ort",
"meeting_link": "Meeting-Link",
"start_date": "Startdatum", "start_date": "Startdatum",
"end_date": "Enddatum", "end_date": "Enddatum",
"start_time": "Startzeit", "start_time": "Startzeit",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Aufgaben aktivieren", "enable_tasks": "Aufgaben aktivieren",
"enable_tasks_desc": "Eine Aufgabenansicht im Kalender zum Verwalten von To-dos anzeigen", "enable_tasks_desc": "Eine Aufgabenansicht im Kalender zum Verwalten von To-dos anzeigen",
"show_tasks_on_calendar": "Aufgaben im Kalender anzeigen", "show_tasks_on_calendar": "Aufgaben im Kalender anzeigen",
"show_tasks_on_calendar_desc": "Aufgabenmarkierungen in der Tages- und Wochenansicht anzeigen" "show_tasks_on_calendar_desc": "Aufgabenmarkierungen in der Tages- und Wochenansicht anzeigen",
"hover_preview": "Ereignis-Hover-Vorschau",
"hover_preview_desc": "Ein Detail-Popover anzeigen, wenn über Kalender-Ereignisse gehovert wird",
"hover_preview_instant": "Sofort",
"hover_preview_delay_500ms": "0,5 Sekunden Verzögerung",
"hover_preview_delay_1s": "1 Sekunde Verzögerung",
"hover_preview_delay_2s": "2 Sekunden Verzögerung",
"hover_preview_off": "Deaktiviert"
}, },
"days": { "days": {
"monday": "Montag", "monday": "Montag",
@@ -2015,7 +2058,7 @@
"importing": "Termine werden importiert...", "importing": "Termine werden importiert...",
"success": "{count} Termine erfolgreich importiert", "success": "{count} Termine erfolgreich importiert",
"error": "Kalender konnte nicht importiert werden", "error": "Kalender konnte nicht importiert werden",
"file_too_large": "Datei überschreitet das 5-MB-Limit", "file_too_large": "Datei überschreitet das 10-MB-Limit",
"invalid_format": "Ungültiges Kalenderdateiformat" "invalid_format": "Ungültiges Kalenderdateiformat"
}, },
"management": { "management": {
+45 -3
View File
@@ -5,6 +5,9 @@
"username_placeholder": "user@example.com", "username_placeholder": "user@example.com",
"password_label": "Password", "password_label": "Password",
"password_placeholder": "Enter your password", "password_placeholder": "Enter your password",
"jmap_endpoint_label": "JMAP Server",
"jmap_endpoint_placeholder": "https://mail.example.com",
"jmap_endpoint_cors_hint": "The server must allow CORS requests from this domain.",
"sign_in": "Sign in", "sign_in": "Sign in",
"signing_in": "Signing in...", "signing_in": "Signing in...",
"loading": "Loading...", "loading": "Loading...",
@@ -15,6 +18,7 @@
"cors_blocked": "The server is reachable but is blocking cross-origin requests. Check your JMAP server's CORS settings and allow this domain.", "cors_blocked": "The server is reachable but is blocking cross-origin requests. Check your JMAP server's CORS settings and allow this domain.",
"server_error": "The server is temporarily unavailable. Please try again later.", "server_error": "The server is temporarily unavailable. Please try again later.",
"generic": "An unexpected error occurred. If this persists, contact your administrator.", "generic": "An unexpected error occurred. If this persists, contact your administrator.",
"totp_required": "A two-factor authentication code is required. Please enter your code below.",
"totp_invalid": "Invalid authentication code. Please check your authenticator app and try again.", "totp_invalid": "Invalid authentication code. Please check your authenticator app and try again.",
"oauth_discovery_failed": "SSO is enabled but the identity provider could not be reached. Check your OAuth configuration." "oauth_discovery_failed": "SSO is enabled but the identity provider could not be reached. Check your OAuth configuration."
}, },
@@ -115,6 +119,7 @@
"demo_reset": "Reset", "demo_reset": "Reset",
"demo_tour": "Tour", "demo_tour": "Tour",
"tags": "Tags", "tags": "Tags",
"folders": "Folders",
"mail": "Mail", "mail": "Mail",
"nav_label": "Navigation", "nav_label": "Navigation",
"add_app": "Apps" "add_app": "Apps"
@@ -196,6 +201,8 @@
"no_email_description": "Select an email from the list to view it here", "no_email_description": "Select an email from the list to view it here",
"no_conversation_selected": "No conversation selected", "no_conversation_selected": "No conversation selected",
"no_conversation_description": "Choose a conversation from the list to read it here", "no_conversation_description": "Choose a conversation from the list to read it here",
"compose": "Compose",
"compose_hint": "Compose new message",
"no_subject": "(No Subject)", "no_subject": "(No Subject)",
"loading_email": "Loading email...", "loading_email": "Loading email...",
"loading": "Loading...", "loading": "Loading...",
@@ -683,6 +690,14 @@
"toolbar_labels": { "toolbar_labels": {
"label": "Show Toolbar Labels", "label": "Show Toolbar Labels",
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons." "description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
},
"hide_account_switcher": {
"label": "Hide Sidebar Account Switcher",
"description": "Hide the account selector at the top of the folder sidebar. You can still switch accounts from the bottom navigation rail."
},
"show_rail_account_list": {
"label": "Show Account Avatars on Navigation Rail",
"description": "Display individual account circles at the bottom of the navigation rail for quick switching, with a sign-out button below."
} }
}, },
"keywords": { "keywords": {
@@ -799,9 +814,18 @@
"label": "Permanently Delete Junk", "label": "Permanently Delete Junk",
"description": "Permanently delete emails from the Junk/Spam folder instead of moving them to Trash" "description": "Permanently delete emails from the Junk/Spam folder instead of moving them to Trash"
}, },
"mail_layout": {
"label": "Mail Layout",
"description": "Choose between the classic split reading pane and a Gmail-style focused reading flow.",
"split": "Split pane",
"split_description": "Keep the message list and reading pane visible side by side.",
"focus": "Focused list",
"focus_description": "Show one line per message and open mail full-width while keeping the folder sidebar visible."
},
"show_preview": { "show_preview": {
"label": "Show Preview Text", "label": "Show Preview Text",
"description": "Display email preview in the list" "description": "Display email preview in the list",
"focus_description": "Display inline preview text inside the focused one-line message list"
}, },
"disable_threading": { "disable_threading": {
"label": "Disable Conversation Grouping", "label": "Disable Conversation Grouping",
@@ -811,6 +835,10 @@
"label": "Plain Text Only", "label": "Plain Text Only",
"description": "Disable the rich text editor and send all emails as plain text only, including replies and forwards" "description": "Disable the rich text editor and send all emails as plain text only, including replies and forwards"
}, },
"auto_select_reply_identity": {
"label": "Auto-select Reply Address",
"description": "When replying, automatically switch the From address to the identity that originally received the message"
},
"attachment_click_action": { "attachment_click_action": {
"label": "Attachment Click Action", "label": "Attachment Click Action",
"description": "Choose whether clicking a file attachment previews it or downloads it immediately", "description": "Choose whether clicking a file attachment previews it or downloads it immediately",
@@ -1867,6 +1895,7 @@
"title": "Title", "title": "Title",
"description": "Description", "description": "Description",
"location": "Location", "location": "Location",
"meeting_link": "Meeting link",
"start_date": "Start date", "start_date": "Start date",
"end_date": "End date", "end_date": "End date",
"start_time": "Start time", "start_time": "Start time",
@@ -1954,7 +1983,14 @@
"enable_tasks": "Enable tasks", "enable_tasks": "Enable tasks",
"enable_tasks_desc": "Show a tasks view in the calendar for managing to-dos", "enable_tasks_desc": "Show a tasks view in the calendar for managing to-dos",
"show_tasks_on_calendar": "Show tasks on calendar", "show_tasks_on_calendar": "Show tasks on calendar",
"show_tasks_on_calendar_desc": "Display task chips on the day and week calendar views" "show_tasks_on_calendar_desc": "Display task chips on the day and week calendar views",
"hover_preview": "Event hover preview",
"hover_preview_desc": "Show a detail popover when hovering over calendar events",
"hover_preview_instant": "Instant",
"hover_preview_delay_500ms": "0.5-second delay",
"hover_preview_delay_1s": "1-second delay",
"hover_preview_delay_2s": "2-second delay",
"hover_preview_off": "Disabled"
}, },
"days": { "days": {
"monday": "Monday", "monday": "Monday",
@@ -2023,7 +2059,7 @@
"importing": "Importing events...", "importing": "Importing events...",
"success": "{count} events imported successfully", "success": "{count} events imported successfully",
"error": "Failed to import calendar", "error": "Failed to import calendar",
"file_too_large": "File exceeds 5MB limit", "file_too_large": "File exceeds 10MB limit",
"invalid_format": "Invalid calendar file format" "invalid_format": "Invalid calendar file format"
}, },
"management": { "management": {
@@ -2073,6 +2109,12 @@
"interval_1440": "Every day", "interval_1440": "Every day",
"subscribe": "Subscribe", "subscribe": "Subscribe",
"subscribing": "Subscribing...", "subscribing": "Subscribing...",
"save": "Save changes",
"saving": "Saving...",
"edit": "Edit",
"edit_title": "Edit Subscription",
"updated": "Updated \"{name}\"",
"update_error": "Failed to update subscription",
"invalid_url": "Please enter a valid URL", "invalid_url": "Please enter a valid URL",
"success": "Subscribed to \"{name}\"", "success": "Subscribed to \"{name}\"",
"error": "Failed to add subscription", "error": "Failed to add subscription",
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "usuario@ejemplo.com", "username_placeholder": "usuario@ejemplo.com",
"password_label": "Contraseña", "password_label": "Contraseña",
"password_placeholder": "Ingrese su contraseña", "password_placeholder": "Ingrese su contraseña",
"jmap_endpoint_label": "Servidor JMAP",
"jmap_endpoint_placeholder": "https://mail.example.com",
"jmap_endpoint_cors_hint": "El servidor debe permitir solicitudes CORS desde este dominio.",
"sign_in": "Iniciar sesión", "sign_in": "Iniciar sesión",
"signing_in": "Iniciando sesión...", "signing_in": "Iniciando sesión...",
"loading": "Cargando...", "loading": "Cargando...",
@@ -115,6 +118,7 @@
"demo_reset": "Restablecer", "demo_reset": "Restablecer",
"demo_tour": "Tour", "demo_tour": "Tour",
"tags": "Etiquetas", "tags": "Etiquetas",
"folders": "Carpetas",
"mail": "Correo", "mail": "Correo",
"nav_label": "Navegación", "nav_label": "Navegación",
"add_app": "Apps" "add_app": "Apps"
@@ -196,6 +200,8 @@
"no_email_description": "Seleccione un correo de la lista para verlo aquí", "no_email_description": "Seleccione un correo de la lista para verlo aquí",
"no_conversation_selected": "Ninguna conversación seleccionada", "no_conversation_selected": "Ninguna conversación seleccionada",
"no_conversation_description": "Elija una conversación de la lista para leerla aquí", "no_conversation_description": "Elija una conversación de la lista para leerla aquí",
"compose": "Redactar",
"compose_hint": "Redactar nuevo mensaje",
"no_subject": "(Sin Asunto)", "no_subject": "(Sin Asunto)",
"loading_email": "Cargando correo...", "loading_email": "Cargando correo...",
"loading": "Cargando...", "loading": "Cargando...",
@@ -477,7 +483,7 @@
"smime_sign_off": "Firma S/MIME desactivada", "smime_sign_off": "Firma S/MIME desactivada",
"smime_encrypt_on": "Cifrado S/MIME activado", "smime_encrypt_on": "Cifrado S/MIME activado",
"smime_encrypt_off": "Cifrado S/MIME desactivado", "smime_encrypt_off": "Cifrado S/MIME desactivado",
"smime_encrypt_unavailable": "El cifrado S/MIME no está disponible: {reason}", "smime_encrypt_unavailable": "Cifrado S/MIME no disponible faltan certificados de destinatario",
"smime_unlock_title": "Desbloquear clave S/MIME", "smime_unlock_title": "Desbloquear clave S/MIME",
"smime_unlock_message": "Introduce la contraseña para desbloquear tu clave S/MIME.", "smime_unlock_message": "Introduce la contraseña para desbloquear tu clave S/MIME.",
"smime_unlock_button": "Desbloquear", "smime_unlock_button": "Desbloquear",
@@ -683,6 +689,14 @@
"toolbar_labels": { "toolbar_labels": {
"label": "Show Toolbar Labels", "label": "Show Toolbar Labels",
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons." "description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
},
"hide_account_switcher": {
"label": "Ocultar selector de cuentas de la barra lateral",
"description": "Ocultar el selector de cuentas en la parte superior de la barra lateral de carpetas. Aún puede cambiar de cuenta desde la barra de navegación inferior."
},
"show_rail_account_list": {
"label": "Mostrar avatares de cuentas en la barra de navegación",
"description": "Mostrar círculos de cuentas individuales en la parte inferior de la barra de navegación para un cambio rápido, con un botón de cerrar sesión debajo."
} }
}, },
"keywords": { "keywords": {
@@ -799,6 +813,14 @@
"label": "Eliminar spam permanentemente", "label": "Eliminar spam permanentemente",
"description": "Eliminar permanentemente los correos de la carpeta Spam en lugar de moverlos a la Papelera" "description": "Eliminar permanentemente los correos de la carpeta Spam en lugar de moverlos a la Papelera"
}, },
"mail_layout": {
"label": "Diseño del correo",
"description": "Elija entre el panel de lectura dividido clásico y un flujo de lectura enfocado estilo Gmail.",
"split": "Panel dividido",
"split_description": "Mantener la lista de mensajes y el panel de lectura visibles uno al lado del otro.",
"focus": "Lista enfocada",
"focus_description": "Mostrar una línea por mensaje y abrir el correo a ancho completo manteniendo visible la barra lateral de carpetas."
},
"disable_threading": { "disable_threading": {
"label": "Desactivar agrupación de conversaciones", "label": "Desactivar agrupación de conversaciones",
"description": "Mostrar correos como mensajes individuales en lugar de agruparlos por conversación" "description": "Mostrar correos como mensajes individuales en lugar de agruparlos por conversación"
@@ -807,9 +829,14 @@
"label": "Solo texto sin formato", "label": "Solo texto sin formato",
"description": "Desactivar el editor de texto enriquecido y enviar todos los correos solo como texto sin formato, incluyendo respuestas y reenvíos" "description": "Desactivar el editor de texto enriquecido y enviar todos los correos solo como texto sin formato, incluyendo respuestas y reenvíos"
}, },
"auto_select_reply_identity": {
"label": "Seleccionar dirección de respuesta automáticamente",
"description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original"
},
"show_preview": { "show_preview": {
"label": "Mostrar Vista Previa", "label": "Mostrar Vista Previa",
"description": "Mostrar vista previa del correo en la lista" "description": "Mostrar vista previa del correo en la lista",
"focus_description": "Mostrar texto de vista previa en línea dentro de la lista de mensajes enfocada de una línea"
}, },
"attachment_click_action": { "attachment_click_action": {
"label": "Acción al hacer clic en adjuntos", "label": "Acción al hacer clic en adjuntos",
@@ -1023,6 +1050,14 @@
"enabled": "Cifrado en reposo habilitado", "enabled": "Cifrado en reposo habilitado",
"disabled_success": "Cifrado en reposo deshabilitado", "disabled_success": "Cifrado en reposo deshabilitado",
"error": "No se pudieron actualizar las configuraciones de cifrado" "error": "No se pudieron actualizar las configuraciones de cifrado"
},
"email_client": {
"title": "Configuración del cliente de correo",
"description": "Use estas credenciales para configurar su cliente de correo de escritorio o móvil (Thunderbird, Apple Mail, Outlook, etc.)",
"jmap_username_label": "Nombre de usuario JMAP",
"copy": "Copiar",
"copied": "Copiado",
"password_instructions": "Use su nombre de usuario JMAP indicado arriba junto con una contraseña de aplicación para iniciar sesión en su cliente de correo. Cree una contraseña de aplicación en la sección anterior si aún no lo ha hecho."
} }
}, },
"identities": { "identities": {
@@ -1859,6 +1894,7 @@
"title": "Título", "title": "Título",
"description": "Descripción", "description": "Descripción",
"location": "Ubicación", "location": "Ubicación",
"meeting_link": "Enlace de reunión",
"start_date": "Fecha de inicio", "start_date": "Fecha de inicio",
"end_date": "Fecha de fin", "end_date": "Fecha de fin",
"start_time": "Hora de inicio", "start_time": "Hora de inicio",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Activar tareas", "enable_tasks": "Activar tareas",
"enable_tasks_desc": "Mostrar una vista de tareas en el calendario para gestionar tareas pendientes", "enable_tasks_desc": "Mostrar una vista de tareas en el calendario para gestionar tareas pendientes",
"show_tasks_on_calendar": "Mostrar tareas en el calendario", "show_tasks_on_calendar": "Mostrar tareas en el calendario",
"show_tasks_on_calendar_desc": "Mostrar etiquetas de tareas en las vistas diarias y semanales del calendario" "show_tasks_on_calendar_desc": "Mostrar etiquetas de tareas en las vistas diarias y semanales del calendario",
"hover_preview": "Vista previa al pasar el ratón",
"hover_preview_desc": "Mostrar un popover de detalles al pasar el ratón sobre eventos del calendario",
"hover_preview_instant": "Instantáneo",
"hover_preview_delay_500ms": "Retraso de 0,5 segundos",
"hover_preview_delay_1s": "Retraso de 1 segundo",
"hover_preview_delay_2s": "Retraso de 2 segundos",
"hover_preview_off": "Desactivado"
}, },
"days": { "days": {
"monday": "Lunes", "monday": "Lunes",
@@ -2015,7 +2058,7 @@
"importing": "Importando eventos...", "importing": "Importando eventos...",
"success": "{count} eventos importados correctamente", "success": "{count} eventos importados correctamente",
"error": "Error al importar el calendario", "error": "Error al importar el calendario",
"file_too_large": "El archivo supera el límite de 5 MB", "file_too_large": "El archivo supera el límite de 10 MB",
"invalid_format": "Formato de archivo de calendario no válido" "invalid_format": "Formato de archivo de calendario no válido"
}, },
"management": { "management": {
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "utilisateur@exemple.com", "username_placeholder": "utilisateur@exemple.com",
"password_label": "Mot de passe", "password_label": "Mot de passe",
"password_placeholder": "Entrez votre mot de passe", "password_placeholder": "Entrez votre mot de passe",
"jmap_endpoint_label": "Serveur JMAP",
"jmap_endpoint_placeholder": "https://mail.example.com",
"jmap_endpoint_cors_hint": "Le serveur doit autoriser les requêtes CORS depuis ce domaine.",
"sign_in": "Se connecter", "sign_in": "Se connecter",
"signing_in": "Connexion en cours...", "signing_in": "Connexion en cours...",
"loading": "Chargement...", "loading": "Chargement...",
@@ -115,6 +118,7 @@
"demo_reset": "Réinitialiser", "demo_reset": "Réinitialiser",
"demo_tour": "Visite", "demo_tour": "Visite",
"tags": "Étiquettes", "tags": "Étiquettes",
"folders": "Dossiers",
"mail": "Messagerie", "mail": "Messagerie",
"nav_label": "Navigation", "nav_label": "Navigation",
"add_app": "Apps" "add_app": "Apps"
@@ -196,6 +200,8 @@
"no_email_description": "Sélectionnez un email dans la liste pour le voir ici", "no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
"no_conversation_selected": "Aucune conversation sélectionnée", "no_conversation_selected": "Aucune conversation sélectionnée",
"no_conversation_description": "Choisissez une conversation dans la liste pour la lire ici", "no_conversation_description": "Choisissez une conversation dans la liste pour la lire ici",
"compose": "Rédiger",
"compose_hint": "Rédiger un nouveau message",
"no_subject": "(Sans objet)", "no_subject": "(Sans objet)",
"loading_email": "Chargement de l'email...", "loading_email": "Chargement de l'email...",
"loading": "Chargement...", "loading": "Chargement...",
@@ -477,7 +483,7 @@
"smime_sign_off": "Signature S/MIME désactivée", "smime_sign_off": "Signature S/MIME désactivée",
"smime_encrypt_on": "Chiffrement S/MIME activé", "smime_encrypt_on": "Chiffrement S/MIME activé",
"smime_encrypt_off": "Chiffrement S/MIME désactivé", "smime_encrypt_off": "Chiffrement S/MIME désactivé",
"smime_encrypt_unavailable": "Le chiffrement S/MIME n'est pas disponible : {reason}", "smime_encrypt_unavailable": "Chiffrement S/MIME indisponible certificats des destinataires manquants",
"smime_unlock_title": "Déverrouiller la clé S/MIME", "smime_unlock_title": "Déverrouiller la clé S/MIME",
"smime_unlock_message": "Entrez la phrase secrète pour déverrouiller votre clé S/MIME.", "smime_unlock_message": "Entrez la phrase secrète pour déverrouiller votre clé S/MIME.",
"smime_unlock_button": "Déverrouiller", "smime_unlock_button": "Déverrouiller",
@@ -683,6 +689,14 @@
"toolbar_labels": { "toolbar_labels": {
"label": "Show Toolbar Labels", "label": "Show Toolbar Labels",
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons." "description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
},
"hide_account_switcher": {
"label": "Masquer le sélecteur de compte de la barre latérale",
"description": "Masquer le sélecteur de compte en haut de la barre latérale des dossiers. Vous pouvez toujours changer de compte depuis la barre de navigation inférieure."
},
"show_rail_account_list": {
"label": "Afficher les avatars de compte sur la barre de navigation",
"description": "Afficher les cercles de comptes individuels en bas de la barre de navigation pour un changement rapide, avec un bouton de déconnexion en dessous."
} }
}, },
"keywords": { "keywords": {
@@ -799,6 +813,14 @@
"label": "Supprimer définitivement les indésirables", "label": "Supprimer définitivement les indésirables",
"description": "Supprimer définitivement les e-mails du dossier Indésirables au lieu de les déplacer vers la corbeille" "description": "Supprimer définitivement les e-mails du dossier Indésirables au lieu de les déplacer vers la corbeille"
}, },
"mail_layout": {
"label": "Disposition du courrier",
"description": "Choisissez entre le volet de lecture divisé classique et un flux de lecture focalisé style Gmail.",
"split": "Volet divisé",
"split_description": "Garder la liste des messages et le volet de lecture visibles côte à côte.",
"focus": "Liste focalisée",
"focus_description": "Afficher une ligne par message et ouvrir le courrier en pleine largeur tout en gardant la barre latérale des dossiers visible."
},
"disable_threading": { "disable_threading": {
"label": "Désactiver le regroupement par conversation", "label": "Désactiver le regroupement par conversation",
"description": "Afficher les e-mails comme messages individuels plutôt que groupés par conversation" "description": "Afficher les e-mails comme messages individuels plutôt que groupés par conversation"
@@ -807,9 +829,14 @@
"label": "Texte brut uniquement", "label": "Texte brut uniquement",
"description": "Désactiver l'éditeur de texte enrichi et envoyer tous les e-mails en texte brut uniquement, y compris les réponses et les transferts" "description": "Désactiver l'éditeur de texte enrichi et envoyer tous les e-mails en texte brut uniquement, y compris les réponses et les transferts"
}, },
"auto_select_reply_identity": {
"label": "Sélection automatique de l'adresse de réponse",
"description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine"
},
"show_preview": { "show_preview": {
"label": "Afficher l'aperçu", "label": "Afficher l'aperçu",
"description": "Afficher l'aperçu de l'email dans la liste" "description": "Afficher l'aperçu de l'email dans la liste",
"focus_description": "Afficher le texte d'aperçu en ligne dans la liste de messages focalisée sur une seule ligne"
}, },
"attachment_click_action": { "attachment_click_action": {
"label": "Action au clic sur les pièces jointes", "label": "Action au clic sur les pièces jointes",
@@ -1023,6 +1050,14 @@
"enabled": "Chiffrement au repos activé", "enabled": "Chiffrement au repos activé",
"disabled_success": "Chiffrement au repos désactivé", "disabled_success": "Chiffrement au repos désactivé",
"error": "Impossible de mettre à jour les paramètres de chiffrement" "error": "Impossible de mettre à jour les paramètres de chiffrement"
},
"email_client": {
"title": "Configuration du client de messagerie",
"description": "Utilisez ces identifiants pour configurer votre client de messagerie de bureau ou mobile (Thunderbird, Apple Mail, Outlook, etc.)",
"jmap_username_label": "Nom d'utilisateur JMAP",
"copy": "Copier",
"copied": "Copié",
"password_instructions": "Utilisez votre nom d'utilisateur JMAP ci-dessus avec un mot de passe d'application pour vous connecter à votre client de messagerie. Créez un mot de passe d'application dans la section ci-dessus si vous ne l'avez pas encore fait."
} }
}, },
"identities": { "identities": {
@@ -1859,6 +1894,7 @@
"title": "Titre", "title": "Titre",
"description": "Description", "description": "Description",
"location": "Lieu", "location": "Lieu",
"meeting_link": "Lien de réunion",
"start_date": "Date de début", "start_date": "Date de début",
"end_date": "Date de fin", "end_date": "Date de fin",
"start_time": "Heure de début", "start_time": "Heure de début",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Activer les tâches", "enable_tasks": "Activer les tâches",
"enable_tasks_desc": "Afficher une vue des tâches dans le calendrier pour gérer les choses à faire", "enable_tasks_desc": "Afficher une vue des tâches dans le calendrier pour gérer les choses à faire",
"show_tasks_on_calendar": "Afficher les tâches dans le calendrier", "show_tasks_on_calendar": "Afficher les tâches dans le calendrier",
"show_tasks_on_calendar_desc": "Afficher les pastilles des tâches dans les vues jour et semaine du calendrier" "show_tasks_on_calendar_desc": "Afficher les pastilles des tâches dans les vues jour et semaine du calendrier",
"hover_preview": "Aperçu au survol",
"hover_preview_desc": "Afficher un popover de détails au survol des événements du calendrier",
"hover_preview_instant": "Instantané",
"hover_preview_delay_500ms": "Délai de 0,5 seconde",
"hover_preview_delay_1s": "Délai de 1 seconde",
"hover_preview_delay_2s": "Délai de 2 secondes",
"hover_preview_off": "Désactivé"
}, },
"days": { "days": {
"monday": "Lundi", "monday": "Lundi",
@@ -2015,7 +2058,7 @@
"importing": "Importation en cours...", "importing": "Importation en cours...",
"success": "{count} événements importés avec succès", "success": "{count} événements importés avec succès",
"error": "Échec de l'importation du calendrier", "error": "Échec de l'importation du calendrier",
"file_too_large": "Le fichier dépasse la limite de 5 Mo", "file_too_large": "Le fichier dépasse la limite de 10 Mo",
"invalid_format": "Format de fichier calendrier invalide" "invalid_format": "Format de fichier calendrier invalide"
}, },
"management": { "management": {
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "utente@esempio.com", "username_placeholder": "utente@esempio.com",
"password_label": "Password", "password_label": "Password",
"password_placeholder": "Inserisci la tua password", "password_placeholder": "Inserisci la tua password",
"jmap_endpoint_label": "Server JMAP",
"jmap_endpoint_placeholder": "https://mail.example.com",
"jmap_endpoint_cors_hint": "Il server deve consentire le richieste CORS da questo dominio.",
"sign_in": "Accedi", "sign_in": "Accedi",
"signing_in": "Accesso in corso...", "signing_in": "Accesso in corso...",
"loading": "Caricamento...", "loading": "Caricamento...",
@@ -115,6 +118,7 @@
"demo_reset": "Reimposta", "demo_reset": "Reimposta",
"demo_tour": "Tour", "demo_tour": "Tour",
"tags": "Etichette", "tags": "Etichette",
"folders": "Cartelle",
"mail": "Posta", "mail": "Posta",
"nav_label": "Navigazione", "nav_label": "Navigazione",
"add_app": "App" "add_app": "App"
@@ -196,6 +200,8 @@
"no_email_description": "Seleziona un messaggio dall'elenco per visualizzarlo qui", "no_email_description": "Seleziona un messaggio dall'elenco per visualizzarlo qui",
"no_conversation_selected": "Nessuna conversazione selezionata", "no_conversation_selected": "Nessuna conversazione selezionata",
"no_conversation_description": "Scegli una conversazione dall'elenco per leggerla qui", "no_conversation_description": "Scegli una conversazione dall'elenco per leggerla qui",
"compose": "Componi",
"compose_hint": "Componi nuovo messaggio",
"no_subject": "(Nessun oggetto)", "no_subject": "(Nessun oggetto)",
"loading_email": "Caricamento messaggio...", "loading_email": "Caricamento messaggio...",
"loading": "Caricamento...", "loading": "Caricamento...",
@@ -477,7 +483,7 @@
"smime_sign_off": "Firma S/MIME disattivata", "smime_sign_off": "Firma S/MIME disattivata",
"smime_encrypt_on": "Cifratura S/MIME attivata", "smime_encrypt_on": "Cifratura S/MIME attivata",
"smime_encrypt_off": "Cifratura S/MIME disattivata", "smime_encrypt_off": "Cifratura S/MIME disattivata",
"smime_encrypt_unavailable": "La cifratura S/MIME non è disponibile: {reason}", "smime_encrypt_unavailable": "Cifratura S/MIME non disponibile certificati destinatario mancanti",
"smime_unlock_title": "Sblocca chiave S/MIME", "smime_unlock_title": "Sblocca chiave S/MIME",
"smime_unlock_message": "Inserisci la passphrase per sbloccare la tua chiave S/MIME.", "smime_unlock_message": "Inserisci la passphrase per sbloccare la tua chiave S/MIME.",
"smime_unlock_button": "Sblocca", "smime_unlock_button": "Sblocca",
@@ -683,6 +689,14 @@
"toolbar_labels": { "toolbar_labels": {
"label": "Show Toolbar Labels", "label": "Show Toolbar Labels",
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons." "description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
},
"hide_account_switcher": {
"label": "Nascondi selettore account dalla barra laterale",
"description": "Nascondi il selettore account nella parte superiore della barra laterale delle cartelle. Puoi comunque cambiare account dalla barra di navigazione inferiore."
},
"show_rail_account_list": {
"label": "Mostra avatar account nella barra di navigazione",
"description": "Visualizza i cerchi degli account individuali nella parte inferiore della barra di navigazione per un cambio rapido, con un pulsante di disconnessione sotto."
} }
}, },
"keywords": { "keywords": {
@@ -799,6 +813,14 @@
"label": "Elimina spam definitivamente", "label": "Elimina spam definitivamente",
"description": "Elimina definitivamente i messaggi dalla cartella Spam invece di spostarli nel cestino" "description": "Elimina definitivamente i messaggi dalla cartella Spam invece di spostarli nel cestino"
}, },
"mail_layout": {
"label": "Layout della posta",
"description": "Scegli tra il classico pannello di lettura diviso e un flusso di lettura focalizzato in stile Gmail.",
"split": "Pannello diviso",
"split_description": "Mantieni la lista dei messaggi e il pannello di lettura visibili affiancati.",
"focus": "Lista focalizzata",
"focus_description": "Mostra una riga per messaggio e apri la posta a larghezza piena mantenendo visibile la barra laterale delle cartelle."
},
"disable_threading": { "disable_threading": {
"label": "Disabilita raggruppamento conversazioni", "label": "Disabilita raggruppamento conversazioni",
"description": "Mostra le email come messaggi singoli anziché raggruppati per conversazione" "description": "Mostra le email come messaggi singoli anziché raggruppati per conversazione"
@@ -807,9 +829,14 @@
"label": "Solo testo normale", "label": "Solo testo normale",
"description": "Disabilita l'editor di testo formattato e invia tutte le email solo come testo normale, incluse risposte e inoltri" "description": "Disabilita l'editor di testo formattato e invia tutte le email solo come testo normale, incluse risposte e inoltri"
}, },
"auto_select_reply_identity": {
"label": "Seleziona automaticamente l'indirizzo di risposta",
"description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale"
},
"show_preview": { "show_preview": {
"label": "Mostra anteprima testo", "label": "Mostra anteprima testo",
"description": "Visualizza l'anteprima del messaggio nell'elenco" "description": "Visualizza l'anteprima del messaggio nell'elenco",
"focus_description": "Visualizza il testo di anteprima in linea nella lista di messaggi focalizzata a riga singola"
}, },
"attachment_click_action": { "attachment_click_action": {
"label": "Azione al clic sugli allegati", "label": "Azione al clic sugli allegati",
@@ -1023,6 +1050,14 @@
"enabled": "Crittografia a riposo abilitata", "enabled": "Crittografia a riposo abilitata",
"disabled_success": "Crittografia a riposo disabilitata", "disabled_success": "Crittografia a riposo disabilitata",
"error": "Impossibile aggiornare le impostazioni di crittografia" "error": "Impossibile aggiornare le impostazioni di crittografia"
},
"email_client": {
"title": "Configurazione client e-mail",
"description": "Usa queste credenziali per configurare il tuo client e-mail desktop o mobile (Thunderbird, Apple Mail, Outlook, ecc.)",
"jmap_username_label": "Nome utente JMAP",
"copy": "Copia",
"copied": "Copiato",
"password_instructions": "Usa il tuo nome utente JMAP sopra indicato insieme a una password dell'app per accedere al tuo client e-mail. Crea una password dell'app nella sezione sopra se non l'hai ancora fatto."
} }
}, },
"identities": { "identities": {
@@ -1859,6 +1894,7 @@
"title": "Titolo", "title": "Titolo",
"description": "Descrizione", "description": "Descrizione",
"location": "Luogo", "location": "Luogo",
"meeting_link": "Link riunione",
"start_date": "Data di inizio", "start_date": "Data di inizio",
"end_date": "Data di fine", "end_date": "Data di fine",
"start_time": "Ora di inizio", "start_time": "Ora di inizio",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Abilita attività", "enable_tasks": "Abilita attività",
"enable_tasks_desc": "Mostra una vista attività nel calendario per gestire le cose da fare", "enable_tasks_desc": "Mostra una vista attività nel calendario per gestire le cose da fare",
"show_tasks_on_calendar": "Mostra attività nel calendario", "show_tasks_on_calendar": "Mostra attività nel calendario",
"show_tasks_on_calendar_desc": "Mostra gli indicatori delle attività nelle viste giornaliera e settimanale del calendario" "show_tasks_on_calendar_desc": "Mostra gli indicatori delle attività nelle viste giornaliera e settimanale del calendario",
"hover_preview": "Anteprima al passaggio del mouse",
"hover_preview_desc": "Mostra un popover dettagliato al passaggio del mouse sugli eventi del calendario",
"hover_preview_instant": "Istantaneo",
"hover_preview_delay_500ms": "Ritardo di 0,5 secondi",
"hover_preview_delay_1s": "Ritardo di 1 secondo",
"hover_preview_delay_2s": "Ritardo di 2 secondi",
"hover_preview_off": "Disabilitato"
}, },
"days": { "days": {
"monday": "Lunedì", "monday": "Lunedì",
@@ -2015,7 +2058,7 @@
"importing": "Importazione eventi...", "importing": "Importazione eventi...",
"success": "{count} eventi importati con successo", "success": "{count} eventi importati con successo",
"error": "Importazione del calendario fallita", "error": "Importazione del calendario fallita",
"file_too_large": "Il file supera il limite di 5 MB", "file_too_large": "Il file supera il limite di 10 MB",
"invalid_format": "Formato del file calendario non valido" "invalid_format": "Formato del file calendario non valido"
}, },
"management": { "management": {
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "user@example.com", "username_placeholder": "user@example.com",
"password_label": "パスワード", "password_label": "パスワード",
"password_placeholder": "パスワードを入力", "password_placeholder": "パスワードを入力",
"jmap_endpoint_label": "JMAPサーバー",
"jmap_endpoint_placeholder": "https://mail.example.com",
"jmap_endpoint_cors_hint": "サーバーはこのドメインからのCORSリクエストを許可する必要があります。",
"sign_in": "サインイン", "sign_in": "サインイン",
"signing_in": "サインイン中...", "signing_in": "サインイン中...",
"loading": "読み込み中...", "loading": "読み込み中...",
@@ -115,6 +118,7 @@
"demo_reset": "リセット", "demo_reset": "リセット",
"demo_tour": "ツアー", "demo_tour": "ツアー",
"tags": "タグ", "tags": "タグ",
"folders": "フォルダ",
"mail": "メール", "mail": "メール",
"nav_label": "ナビゲーション", "nav_label": "ナビゲーション",
"add_app": "アプリ" "add_app": "アプリ"
@@ -196,6 +200,8 @@
"no_email_description": "リストからメールを選択して表示してください", "no_email_description": "リストからメールを選択して表示してください",
"no_conversation_selected": "会話が選択されていません", "no_conversation_selected": "会話が選択されていません",
"no_conversation_description": "リストから会話を選択して読んでください", "no_conversation_description": "リストから会話を選択して読んでください",
"compose": "作成",
"compose_hint": "新しいメッセージを作成",
"no_subject": "(件名なし)", "no_subject": "(件名なし)",
"loading_email": "メールを読み込み中...", "loading_email": "メールを読み込み中...",
"loading": "読み込み中...", "loading": "読み込み中...",
@@ -477,7 +483,7 @@
"smime_sign_off": "S/MIME 署名を無効化しました", "smime_sign_off": "S/MIME 署名を無効化しました",
"smime_encrypt_on": "S/MIME 暗号化を有効化しました", "smime_encrypt_on": "S/MIME 暗号化を有効化しました",
"smime_encrypt_off": "S/MIME 暗号化を無効化しました", "smime_encrypt_off": "S/MIME 暗号化を無効化しました",
"smime_encrypt_unavailable": "S/MIME 暗号化は利用できません: {reason}", "smime_encrypt_unavailable": "S/MIME 暗号化は利用できません 受信者の証明書がありません",
"smime_unlock_title": "S/MIME 鍵のロックを解除", "smime_unlock_title": "S/MIME 鍵のロックを解除",
"smime_unlock_message": "S/MIME 鍵を解除するためのパスフレーズを入力してください。", "smime_unlock_message": "S/MIME 鍵を解除するためのパスフレーズを入力してください。",
"smime_unlock_button": "ロック解除", "smime_unlock_button": "ロック解除",
@@ -683,6 +689,14 @@
"toolbar_labels": { "toolbar_labels": {
"label": "Show Toolbar Labels", "label": "Show Toolbar Labels",
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons." "description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
},
"hide_account_switcher": {
"label": "サイドバーのアカウント切替を非表示",
"description": "フォルダサイドバー上部のアカウント選択を非表示にします。下部のナビゲーションレールからアカウントを切り替えることは引き続き可能です。"
},
"show_rail_account_list": {
"label": "ナビゲーションレールにアカウントアバターを表示",
"description": "ナビゲーションレールの下部に個々のアカウントの丸を表示して素早く切り替えできるようにし、その下にサインアウトボタンを配置します。"
} }
}, },
"keywords": { "keywords": {
@@ -799,6 +813,14 @@
"label": "迷惑メールを完全に削除", "label": "迷惑メールを完全に削除",
"description": "迷惑メールフォルダのメールをゴミ箱に移動せずに完全に削除する" "description": "迷惑メールフォルダのメールをゴミ箱に移動せずに完全に削除する"
}, },
"mail_layout": {
"label": "メールレイアウト",
"description": "クラシックな分割読み取りペインとGmailスタイルの集中読み取りフローから選択します。",
"split": "分割ペイン",
"split_description": "メッセージリストと読み取りペインを並べて表示します。",
"focus": "集中リスト",
"focus_description": "メッセージごとに1行表示し、フォルダサイドバーを表示したままメールを全幅で開きます。"
},
"disable_threading": { "disable_threading": {
"label": "会話グループ化を無効にする", "label": "会話グループ化を無効にする",
"description": "メールを会話ごとにグループ化せず、個別のメッセージとして表示します" "description": "メールを会話ごとにグループ化せず、個別のメッセージとして表示します"
@@ -807,9 +829,14 @@
"label": "プレーンテキストのみ", "label": "プレーンテキストのみ",
"description": "リッチテキストエディターを無効にし、返信や転送を含むすべてのメールをプレーンテキストのみで送信します" "description": "リッチテキストエディターを無効にし、返信や転送を含むすべてのメールをプレーンテキストのみで送信します"
}, },
"auto_select_reply_identity": {
"label": "返信元アドレスを自動選択",
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
},
"show_preview": { "show_preview": {
"label": "プレビューテキストを表示", "label": "プレビューテキストを表示",
"description": "リストにメールのプレビューを表示" "description": "リストにメールのプレビューを表示",
"focus_description": "集中型1行メッセージリスト内にインラインプレビューテキストを表示"
}, },
"attachment_click_action": { "attachment_click_action": {
"label": "添付ファイルクリック時の動作", "label": "添付ファイルクリック時の動作",
@@ -1023,6 +1050,14 @@
"enabled": "保存時の暗号化が有効になりました", "enabled": "保存時の暗号化が有効になりました",
"disabled_success": "保存時の暗号化が無効になりました", "disabled_success": "保存時の暗号化が無効になりました",
"error": "暗号化設定を更新できませんでした" "error": "暗号化設定を更新できませんでした"
},
"email_client": {
"title": "メールクライアントの設定",
"description": "これらの資格情報を使用して、デスクトップまたはモバイルのメールクライアント(Thunderbird、Apple Mail、Outlookなど)を設定します",
"jmap_username_label": "JMAPユーザー名",
"copy": "コピー",
"copied": "コピー済み",
"password_instructions": "上記のJMAPユーザー名とアプリパスワードを使用してメールクライアントにサインインします。まだ作成していない場合は、上のセクションでアプリパスワードを作成してください。"
} }
}, },
"identities": { "identities": {
@@ -1859,6 +1894,7 @@
"title": "タイトル", "title": "タイトル",
"description": "説明", "description": "説明",
"location": "場所", "location": "場所",
"meeting_link": "会議リンク",
"start_date": "開始日", "start_date": "開始日",
"end_date": "終了日", "end_date": "終了日",
"start_time": "開始時刻", "start_time": "開始時刻",
@@ -1946,7 +1982,14 @@
"enable_tasks": "タスクを有効化", "enable_tasks": "タスクを有効化",
"enable_tasks_desc": "カレンダーにタスク表示を追加して、ToDoを管理します", "enable_tasks_desc": "カレンダーにタスク表示を追加して、ToDoを管理します",
"show_tasks_on_calendar": "カレンダーにタスクを表示", "show_tasks_on_calendar": "カレンダーにタスクを表示",
"show_tasks_on_calendar_desc": "日表示と週表示にタスクチップを表示します" "show_tasks_on_calendar_desc": "日表示と週表示にタスクチップを表示します",
"hover_preview": "イベントホバープレビュー",
"hover_preview_desc": "カレンダーイベントにマウスを合わせたときに詳細ポップオーバーを表示",
"hover_preview_instant": "即時",
"hover_preview_delay_500ms": "0.5秒遅延",
"hover_preview_delay_1s": "1秒遅延",
"hover_preview_delay_2s": "2秒遅延",
"hover_preview_off": "無効"
}, },
"days": { "days": {
"monday": "月曜日", "monday": "月曜日",
@@ -2015,7 +2058,7 @@
"importing": "イベントをインポート中...", "importing": "イベントをインポート中...",
"success": "{count}件のイベントをインポートしました", "success": "{count}件のイベントをインポートしました",
"error": "カレンダーのインポートに失敗しました", "error": "カレンダーのインポートに失敗しました",
"file_too_large": "ファイルサイズが5MBを超えています", "file_too_large": "ファイルサイズが10MBを超えています",
"invalid_format": "無効なカレンダーファイル形式" "invalid_format": "無効なカレンダーファイル形式"
}, },
"management": { "management": {
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "gebruiker@voorbeeld.nl", "username_placeholder": "gebruiker@voorbeeld.nl",
"password_label": "Wachtwoord", "password_label": "Wachtwoord",
"password_placeholder": "Voer je wachtwoord in", "password_placeholder": "Voer je wachtwoord in",
"jmap_endpoint_label": "JMAP-server",
"jmap_endpoint_placeholder": "https://mail.example.com",
"jmap_endpoint_cors_hint": "De server moet CORS-verzoeken van dit domein toestaan.",
"sign_in": "Aanmelden", "sign_in": "Aanmelden",
"signing_in": "Aanmelden...", "signing_in": "Aanmelden...",
"loading": "Laden...", "loading": "Laden...",
@@ -115,6 +118,7 @@
"demo_reset": "Resetten", "demo_reset": "Resetten",
"demo_tour": "Rondleiding", "demo_tour": "Rondleiding",
"tags": "Labels", "tags": "Labels",
"folders": "Mappen",
"mail": "E-mail", "mail": "E-mail",
"nav_label": "Navigatie", "nav_label": "Navigatie",
"add_app": "Apps" "add_app": "Apps"
@@ -196,6 +200,8 @@
"no_email_description": "Selecteer een e-mail uit de lijst om deze hier te bekijken", "no_email_description": "Selecteer een e-mail uit de lijst om deze hier te bekijken",
"no_conversation_selected": "Geen gesprek geselecteerd", "no_conversation_selected": "Geen gesprek geselecteerd",
"no_conversation_description": "Kies een gesprek uit de lijst om het hier te lezen", "no_conversation_description": "Kies een gesprek uit de lijst om het hier te lezen",
"compose": "Opstellen",
"compose_hint": "Nieuw bericht opstellen",
"no_subject": "(Geen onderwerp)", "no_subject": "(Geen onderwerp)",
"loading_email": "E-mail laden...", "loading_email": "E-mail laden...",
"loading": "Laden...", "loading": "Laden...",
@@ -477,7 +483,7 @@
"smime_sign_off": "S/MIME-ondertekening uitgeschakeld", "smime_sign_off": "S/MIME-ondertekening uitgeschakeld",
"smime_encrypt_on": "S/MIME-versleuteling ingeschakeld", "smime_encrypt_on": "S/MIME-versleuteling ingeschakeld",
"smime_encrypt_off": "S/MIME-versleuteling uitgeschakeld", "smime_encrypt_off": "S/MIME-versleuteling uitgeschakeld",
"smime_encrypt_unavailable": "S/MIME-versleuteling is niet beschikbaar: {reason}", "smime_encrypt_unavailable": "S/MIME-versleuteling niet beschikbaar ontbrekende ontvangercertificaten",
"smime_unlock_title": "S/MIME-sleutel ontgrendelen", "smime_unlock_title": "S/MIME-sleutel ontgrendelen",
"smime_unlock_message": "Voer de wachtwoordzin in om uw S/MIME-sleutel te ontgrendelen.", "smime_unlock_message": "Voer de wachtwoordzin in om uw S/MIME-sleutel te ontgrendelen.",
"smime_unlock_button": "Ontgrendelen", "smime_unlock_button": "Ontgrendelen",
@@ -683,6 +689,14 @@
"toolbar_labels": { "toolbar_labels": {
"label": "Show Toolbar Labels", "label": "Show Toolbar Labels",
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons." "description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
},
"hide_account_switcher": {
"label": "Accountwisselaar in zijbalk verbergen",
"description": "Verberg de accountselector bovenaan de mappenzijbalk. U kunt nog steeds van account wisselen via de onderste navigatiebalk."
},
"show_rail_account_list": {
"label": "Accountavatars tonen op navigatiebalk",
"description": "Toon individuele accountcirkels onderaan de navigatiebalk voor snel wisselen, met een afmeldknop eronder."
} }
}, },
"keywords": { "keywords": {
@@ -799,6 +813,14 @@
"label": "Spam permanent verwijderen", "label": "Spam permanent verwijderen",
"description": "E-mails uit de map Spam permanent verwijderen in plaats van naar de prullenbak te verplaatsen" "description": "E-mails uit de map Spam permanent verwijderen in plaats van naar de prullenbak te verplaatsen"
}, },
"mail_layout": {
"label": "E-mailindeling",
"description": "Kies tussen het klassieke gesplitste leesvenster en een gefocuste leesstroom in Gmail-stijl.",
"split": "Gesplitst venster",
"split_description": "Houd de berichtenlijst en het leesvenster naast elkaar zichtbaar.",
"focus": "Gefocuste lijst",
"focus_description": "Toon één regel per bericht en open e-mail op volledige breedte terwijl de mappenzijbalk zichtbaar blijft."
},
"disable_threading": { "disable_threading": {
"label": "Conversatiegroepering uitschakelen", "label": "Conversatiegroepering uitschakelen",
"description": "Toon e-mails als afzonderlijke berichten in plaats van gegroepeerd op gesprek" "description": "Toon e-mails als afzonderlijke berichten in plaats van gegroepeerd op gesprek"
@@ -807,9 +829,14 @@
"label": "Alleen platte tekst", "label": "Alleen platte tekst",
"description": "Schakel de rich text-editor uit en verzend alle e-mails alleen als platte tekst, inclusief antwoorden en doorgestuurde berichten" "description": "Schakel de rich text-editor uit en verzend alle e-mails alleen als platte tekst, inclusief antwoorden en doorgestuurde berichten"
}, },
"auto_select_reply_identity": {
"label": "Antwoordadres automatisch selecteren",
"description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving"
},
"show_preview": { "show_preview": {
"label": "Voorbeeldtekst tonen", "label": "Voorbeeldtekst tonen",
"description": "E-mailvoorbeeld weergeven in de lijst" "description": "E-mailvoorbeeld weergeven in de lijst",
"focus_description": "Inline voorbeeldtekst weergeven in de gefocuste berichtenlijst met één regel"
}, },
"attachment_click_action": { "attachment_click_action": {
"label": "Actie bij klikken op bijlagen", "label": "Actie bij klikken op bijlagen",
@@ -1023,6 +1050,14 @@
"enabled": "Versleuteling in rust ingeschakeld", "enabled": "Versleuteling in rust ingeschakeld",
"disabled_success": "Versleuteling in rust uitgeschakeld", "disabled_success": "Versleuteling in rust uitgeschakeld",
"error": "Kan versleutelingsinstellingen niet bijwerken" "error": "Kan versleutelingsinstellingen niet bijwerken"
},
"email_client": {
"title": "E-mailclient instellen",
"description": "Gebruik deze inloggegevens om uw desktop- of mobiele e-mailclient te configureren (Thunderbird, Apple Mail, Outlook, enz.)",
"jmap_username_label": "JMAP-gebruikersnaam",
"copy": "Kopiëren",
"copied": "Gekopieerd",
"password_instructions": "Gebruik uw hierboven vermelde JMAP-gebruikersnaam samen met een app-wachtwoord om in te loggen bij uw e-mailclient. Maak een app-wachtwoord aan in het bovenstaande gedeelte als u dat nog niet heeft gedaan."
} }
}, },
"identities": { "identities": {
@@ -1859,6 +1894,7 @@
"title": "Titel", "title": "Titel",
"description": "Beschrijving", "description": "Beschrijving",
"location": "Locatie", "location": "Locatie",
"meeting_link": "Vergaderlink",
"start_date": "Startdatum", "start_date": "Startdatum",
"end_date": "Einddatum", "end_date": "Einddatum",
"start_time": "Starttijd", "start_time": "Starttijd",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Taken inschakelen", "enable_tasks": "Taken inschakelen",
"enable_tasks_desc": "Een takenweergave in de agenda tonen om to-do's te beheren", "enable_tasks_desc": "Een takenweergave in de agenda tonen om to-do's te beheren",
"show_tasks_on_calendar": "Taken in agenda tonen", "show_tasks_on_calendar": "Taken in agenda tonen",
"show_tasks_on_calendar_desc": "Taaklabels tonen in dag- en weekweergave van de agenda" "show_tasks_on_calendar_desc": "Taaklabels tonen in dag- en weekweergave van de agenda",
"hover_preview": "Voorvertoning bij hover",
"hover_preview_desc": "Toon een detailpopover bij het hoveren over agenda-evenementen",
"hover_preview_instant": "Direct",
"hover_preview_delay_500ms": "0,5 seconde vertraging",
"hover_preview_delay_1s": "1 seconde vertraging",
"hover_preview_delay_2s": "2 seconden vertraging",
"hover_preview_off": "Uitgeschakeld"
}, },
"days": { "days": {
"monday": "Maandag", "monday": "Maandag",
@@ -2015,7 +2058,7 @@
"importing": "Evenementen importeren...", "importing": "Evenementen importeren...",
"success": "{count} evenementen succesvol geïmporteerd", "success": "{count} evenementen succesvol geïmporteerd",
"error": "Agenda importeren mislukt", "error": "Agenda importeren mislukt",
"file_too_large": "Bestand overschrijdt de limiet van 5 MB", "file_too_large": "Bestand overschrijdt de limiet van 10 MB",
"invalid_format": "Ongeldig agendabestandsformaat" "invalid_format": "Ongeldig agendabestandsformaat"
}, },
"management": { "management": {
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "usuario@exemplo.com", "username_placeholder": "usuario@exemplo.com",
"password_label": "Senha", "password_label": "Senha",
"password_placeholder": "Digite sua senha", "password_placeholder": "Digite sua senha",
"jmap_endpoint_label": "Servidor JMAP",
"jmap_endpoint_placeholder": "https://mail.example.com",
"jmap_endpoint_cors_hint": "O servidor deve permitir solicitações CORS deste domínio.",
"sign_in": "Entrar", "sign_in": "Entrar",
"signing_in": "Entrando...", "signing_in": "Entrando...",
"loading": "Carregando...", "loading": "Carregando...",
@@ -115,6 +118,7 @@
"demo_reset": "Repor", "demo_reset": "Repor",
"demo_tour": "Tour", "demo_tour": "Tour",
"tags": "Etiquetas", "tags": "Etiquetas",
"folders": "Pastas",
"mail": "E-mail", "mail": "E-mail",
"nav_label": "Navegação", "nav_label": "Navegação",
"add_app": "Apps" "add_app": "Apps"
@@ -196,6 +200,8 @@
"no_email_description": "Selecione um e-mail da lista para visualizá-lo aqui", "no_email_description": "Selecione um e-mail da lista para visualizá-lo aqui",
"no_conversation_selected": "Nenhuma conversa selecionada", "no_conversation_selected": "Nenhuma conversa selecionada",
"no_conversation_description": "Escolha uma conversa da lista para lê-la aqui", "no_conversation_description": "Escolha uma conversa da lista para lê-la aqui",
"compose": "Redigir",
"compose_hint": "Redigir nova mensagem",
"no_subject": "(Sem Assunto)", "no_subject": "(Sem Assunto)",
"loading_email": "Carregando e-mail...", "loading_email": "Carregando e-mail...",
"loading": "Carregando...", "loading": "Carregando...",
@@ -477,7 +483,7 @@
"smime_sign_off": "Assinatura S/MIME desativada", "smime_sign_off": "Assinatura S/MIME desativada",
"smime_encrypt_on": "Criptografia S/MIME ativada", "smime_encrypt_on": "Criptografia S/MIME ativada",
"smime_encrypt_off": "Criptografia S/MIME desativada", "smime_encrypt_off": "Criptografia S/MIME desativada",
"smime_encrypt_unavailable": "A criptografia S/MIME não está disponível: {reason}", "smime_encrypt_unavailable": "Criptografia S/MIME indisponível certificados de destinatário ausentes",
"smime_unlock_title": "Desbloquear chave S/MIME", "smime_unlock_title": "Desbloquear chave S/MIME",
"smime_unlock_message": "Insira a frase secreta para desbloquear sua chave S/MIME.", "smime_unlock_message": "Insira a frase secreta para desbloquear sua chave S/MIME.",
"smime_unlock_button": "Desbloquear", "smime_unlock_button": "Desbloquear",
@@ -683,6 +689,14 @@
"toolbar_labels": { "toolbar_labels": {
"label": "Show Toolbar Labels", "label": "Show Toolbar Labels",
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons." "description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
},
"hide_account_switcher": {
"label": "Ocultar seletor de conta da barra lateral",
"description": "Ocultar o seletor de conta no topo da barra lateral de pastas. Você ainda pode trocar de conta pela barra de navegação inferior."
},
"show_rail_account_list": {
"label": "Mostrar avatares de conta na barra de navegação",
"description": "Exibir círculos de contas individuais na parte inferior da barra de navegação para troca rápida, com um botão de sair abaixo."
} }
}, },
"keywords": { "keywords": {
@@ -799,6 +813,14 @@
"label": "Excluir spam permanentemente", "label": "Excluir spam permanentemente",
"description": "Excluir permanentemente e-mails da pasta Spam em vez de movê-los para a Lixeira" "description": "Excluir permanentemente e-mails da pasta Spam em vez de movê-los para a Lixeira"
}, },
"mail_layout": {
"label": "Layout do e-mail",
"description": "Escolha entre o painel de leitura dividido clássico e um fluxo de leitura focado estilo Gmail.",
"split": "Painel dividido",
"split_description": "Manter a lista de mensagens e o painel de leitura visíveis lado a lado.",
"focus": "Lista focada",
"focus_description": "Mostrar uma linha por mensagem e abrir o e-mail em largura total mantendo a barra lateral de pastas visível."
},
"disable_threading": { "disable_threading": {
"label": "Desativar agrupamento de conversas", "label": "Desativar agrupamento de conversas",
"description": "Mostrar e-mails como mensagens individuais em vez de agrupados por conversa" "description": "Mostrar e-mails como mensagens individuais em vez de agrupados por conversa"
@@ -807,9 +829,14 @@
"label": "Apenas texto simples", "label": "Apenas texto simples",
"description": "Desativar o editor de texto formatado e enviar todos os e-mails apenas como texto simples, incluindo respostas e encaminhamentos" "description": "Desativar o editor de texto formatado e enviar todos os e-mails apenas como texto simples, incluindo respostas e encaminhamentos"
}, },
"auto_select_reply_identity": {
"label": "Selecionar automaticamente o endereço de resposta",
"description": "Ao responder, muda automaticamente o endereço do remetente para a identidade que recebeu a mensagem original"
},
"show_preview": { "show_preview": {
"label": "Mostrar Texto de Visualização", "label": "Mostrar Texto de Visualização",
"description": "Exibir visualização do e-mail na lista" "description": "Exibir visualização do e-mail na lista",
"focus_description": "Exibir texto de pré-visualização em linha dentro da lista de mensagens focada de uma linha"
}, },
"attachment_click_action": { "attachment_click_action": {
"label": "Ação ao clicar em anexos", "label": "Ação ao clicar em anexos",
@@ -1023,6 +1050,14 @@
"enabled": "Criptografia em repouso habilitada", "enabled": "Criptografia em repouso habilitada",
"disabled_success": "Criptografia em repouso desabilitada", "disabled_success": "Criptografia em repouso desabilitada",
"error": "Não foi possível atualizar as configurações de criptografia" "error": "Não foi possível atualizar as configurações de criptografia"
},
"email_client": {
"title": "Configuração do Cliente de E-mail",
"description": "Use estas credenciais para configurar seu cliente de e-mail desktop ou móvel (Thunderbird, Apple Mail, Outlook, etc.)",
"jmap_username_label": "Nome de Usuário JMAP",
"copy": "Copiar",
"copied": "Copiado",
"password_instructions": "Use seu nome de usuário JMAP acima junto com uma senha de aplicativo para entrar no seu cliente de e-mail. Crie uma senha de aplicativo na seção acima se ainda não tiver uma."
} }
}, },
"identities": { "identities": {
@@ -1859,6 +1894,7 @@
"title": "Título", "title": "Título",
"description": "Descrição", "description": "Descrição",
"location": "Local", "location": "Local",
"meeting_link": "Link da reunião",
"start_date": "Data de início", "start_date": "Data de início",
"end_date": "Data de término", "end_date": "Data de término",
"start_time": "Hora de início", "start_time": "Hora de início",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Ativar tarefas", "enable_tasks": "Ativar tarefas",
"enable_tasks_desc": "Mostrar uma visualização de tarefas no calendário para gerenciar pendências", "enable_tasks_desc": "Mostrar uma visualização de tarefas no calendário para gerenciar pendências",
"show_tasks_on_calendar": "Mostrar tarefas no calendário", "show_tasks_on_calendar": "Mostrar tarefas no calendário",
"show_tasks_on_calendar_desc": "Exibir marcadores de tarefas nas visualizações diária e semanal do calendário" "show_tasks_on_calendar_desc": "Exibir marcadores de tarefas nas visualizações diária e semanal do calendário",
"hover_preview": "Pré-visualização ao passar o rato",
"hover_preview_desc": "Mostrar um popover de detalhes ao passar o rato sobre eventos do calendário",
"hover_preview_instant": "Instantâneo",
"hover_preview_delay_500ms": "Atraso de 0,5 segundos",
"hover_preview_delay_1s": "Atraso de 1 segundo",
"hover_preview_delay_2s": "Atraso de 2 segundos",
"hover_preview_off": "Desativado"
}, },
"days": { "days": {
"monday": "Segunda-feira", "monday": "Segunda-feira",
@@ -2015,7 +2058,7 @@
"importing": "Importando eventos...", "importing": "Importando eventos...",
"success": "{count} eventos importados com sucesso", "success": "{count} eventos importados com sucesso",
"error": "Falha ao importar calendário", "error": "Falha ao importar calendário",
"file_too_large": "Arquivo excede o limite de 5 MB", "file_too_large": "Arquivo excede o limite de 10 MB",
"invalid_format": "Formato de arquivo de calendário inválido" "invalid_format": "Formato de arquivo de calendário inválido"
}, },
"management": { "management": {
+46 -3
View File
@@ -5,6 +5,9 @@
"username_placeholder": "user@пример.рф", "username_placeholder": "user@пример.рф",
"password_label": "Пароль", "password_label": "Пароль",
"password_placeholder": "Введите пароль", "password_placeholder": "Введите пароль",
"jmap_endpoint_label": "Сервер JMAP",
"jmap_endpoint_placeholder": "https://mail.example.com",
"jmap_endpoint_cors_hint": "Сервер должен разрешать CORS-запросы с этого домена.",
"sign_in": "Войти", "sign_in": "Войти",
"signing_in": "Вход...", "signing_in": "Вход...",
"loading": "Загрузка...", "loading": "Загрузка...",
@@ -115,6 +118,7 @@
"demo_reset": "Сбросить", "demo_reset": "Сбросить",
"demo_tour": "Тур", "demo_tour": "Тур",
"tags": "Теги", "tags": "Теги",
"folders": "Папки",
"mail": "Почта", "mail": "Почта",
"nav_label": "Навигация", "nav_label": "Навигация",
"add_app": "Приложения" "add_app": "Приложения"
@@ -196,6 +200,8 @@
"no_email_description": "Выберите письмо из списка, чтобы просмотреть его здесь", "no_email_description": "Выберите письмо из списка, чтобы просмотреть его здесь",
"no_conversation_selected": "Беседа не выбрана", "no_conversation_selected": "Беседа не выбрана",
"no_conversation_description": "Выберите беседу из списка, чтобы прочитать её здесь", "no_conversation_description": "Выберите беседу из списка, чтобы прочитать её здесь",
"compose": "Написать",
"compose_hint": "Написать новое сообщение",
"no_subject": "(Без темы)", "no_subject": "(Без темы)",
"loading_email": "Загрузка письма...", "loading_email": "Загрузка письма...",
"loading": "Загрузка...", "loading": "Загрузка...",
@@ -683,6 +689,14 @@
"toolbar_labels": { "toolbar_labels": {
"label": "Показывать подписи панели инструментов", "label": "Показывать подписи панели инструментов",
"description": "Отображать текстовые подписи рядом с иконками панели. Отключите для экономии места, когда освоитесь с иконками." "description": "Отображать текстовые подписи рядом с иконками панели. Отключите для экономии места, когда освоитесь с иконками."
},
"hide_account_switcher": {
"label": "Скрыть переключатель аккаунтов в боковой панели",
"description": "Скрыть выбор аккаунта в верхней части боковой панели папок. Вы по-прежнему можете переключать аккаунты через нижнюю панель навигации."
},
"show_rail_account_list": {
"label": "Показать аватары аккаунтов на панели навигации",
"description": "Отображать отдельные круги аккаунтов в нижней части панели навигации для быстрого переключения, с кнопкой выхода ниже."
} }
}, },
"keywords": { "keywords": {
@@ -799,6 +813,14 @@
"label": "Удалять спам навсегда", "label": "Удалять спам навсегда",
"description": "Удалять письма из папки «Спам» навсегда вместо перемещения в корзину" "description": "Удалять письма из папки «Спам» навсегда вместо перемещения в корзину"
}, },
"mail_layout": {
"label": "Макет почты",
"description": "Выберите между классической разделённой панелью чтения и сфокусированным потоком чтения в стиле Gmail.",
"split": "Разделённая панель",
"split_description": "Список сообщений и панель чтения отображаются рядом друг с другом.",
"focus": "Сфокусированный список",
"focus_description": "Показывать одну строку на сообщение и открывать письма на всю ширину, сохраняя видимой боковую панель папок."
},
"disable_threading": { "disable_threading": {
"label": "Отключить группировку по беседам", "label": "Отключить группировку по беседам",
"description": "Отображать письма как отдельные сообщения, а не сгруппированные по беседам" "description": "Отображать письма как отдельные сообщения, а не сгруппированные по беседам"
@@ -807,9 +829,14 @@
"label": "Только простой текст", "label": "Только простой текст",
"description": "Отключить редактор форматированного текста и отправлять все письма только в виде простого текста, включая ответы и пересылки" "description": "Отключить редактор форматированного текста и отправлять все письма только в виде простого текста, включая ответы и пересылки"
}, },
"auto_select_reply_identity": {
"label": "Автоматически выбирать адрес для ответа",
"description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
},
"show_preview": { "show_preview": {
"label": "Показывать текст предпросмотра", "label": "Показывать текст предпросмотра",
"description": "Отображать предпросмотр письма в списке" "description": "Отображать предпросмотр письма в списке",
"focus_description": "Отображать встроенный текст предпросмотра в сфокусированном однострочном списке сообщений"
}, },
"attachment_click_action": { "attachment_click_action": {
"label": "Действие при нажатии на вложение", "label": "Действие при нажатии на вложение",
@@ -1023,6 +1050,14 @@
"enabled": "Шифрование хранилища включено", "enabled": "Шифрование хранилища включено",
"disabled_success": "Шифрование хранилища отключено", "disabled_success": "Шифрование хранилища отключено",
"error": "Не удалось обновить настройки шифрования" "error": "Не удалось обновить настройки шифрования"
},
"email_client": {
"title": "Настройка почтового клиента",
"description": "Используйте эти учётные данные для настройки вашего почтового клиента (Thunderbird, Apple Mail, Outlook и др.)",
"jmap_username_label": "Имя пользователя JMAP",
"copy": "Копировать",
"copied": "Скопировано",
"password_instructions": "Используйте указанное выше имя пользователя JMAP вместе с паролем приложения для входа в почтовый клиент. Создайте пароль приложения в разделе выше, если вы ещё этого не сделали."
} }
}, },
"identities": { "identities": {
@@ -1859,6 +1894,7 @@
"title": "Название", "title": "Название",
"description": "Описание", "description": "Описание",
"location": "Место", "location": "Место",
"meeting_link": "Ссылка на встречу",
"start_date": "Дата начала", "start_date": "Дата начала",
"end_date": "Дата окончания", "end_date": "Дата окончания",
"start_time": "Время начала", "start_time": "Время начала",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Включить задачи", "enable_tasks": "Включить задачи",
"enable_tasks_desc": "Показывать представление задач в календаре для управления делами", "enable_tasks_desc": "Показывать представление задач в календаре для управления делами",
"show_tasks_on_calendar": "Показывать задачи в календаре", "show_tasks_on_calendar": "Показывать задачи в календаре",
"show_tasks_on_calendar_desc": "Отображать чипы задач в видах «день» и «неделя»" "show_tasks_on_calendar_desc": "Отображать чипы задач в видах «день» и «неделя»",
"hover_preview": "Предпросмотр при наведении",
"hover_preview_desc": "Показывать всплывающее окно с деталями при наведении на события календаря",
"hover_preview_instant": "Мгновенно",
"hover_preview_delay_500ms": "Задержка 0,5 секунды",
"hover_preview_delay_1s": "Задержка 1 секунда",
"hover_preview_delay_2s": "Задержка 2 секунды",
"hover_preview_off": "Отключено"
}, },
"days": { "days": {
"monday": "Понедельник", "monday": "Понедельник",
@@ -2015,7 +2058,7 @@
"importing": "Импортирование событий...", "importing": "Импортирование событий...",
"success": "{count} событий успешно импортировано", "success": "{count} событий успешно импортировано",
"error": "Не удалось импортировать календарь", "error": "Не удалось импортировать календарь",
"file_too_large": "Файл превышает лимит 5 МБ", "file_too_large": "Файл превышает лимит 10 МБ",
"invalid_format": "Неверный формат файла календаря" "invalid_format": "Неверный формат файла календаря"
}, },
"management": { "management": {
+66 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.4.9", "version": "1.4.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.4.9", "version": "1.4.10",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
@@ -3282,6 +3282,70 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.4.9", "version": "1.4.10",
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server", "description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>", "author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
+34
View File
@@ -339,5 +339,39 @@ describe('filter-store', () => {
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient); await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
expect(useFilterStore.getState().rawScript).toBe(script); expect(useFilterStore.getState().rawScript).toBe(script);
}); });
it('should skip the server-managed vacation script', async () => {
const { generateScript } = await import('@/lib/sieve/generator');
const rules = [makeRule({ name: 'MyFilter' })];
const script = generateScript(rules);
const mockClient = {
getSieveCapabilities: () => null,
getSieveScripts: async () => [
{ id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true },
{ id: 's1', name: 'filters', blobId: 'b1', isActive: false },
],
getSieveScriptContent: async (blobId: string) => {
if (blobId === 'b1') return script;
return 'require "vacation"; vacation "I am away";';
},
};
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
// Should pick the 'filters' script, not the 'vacation' one
expect(useFilterStore.getState().activeScriptId).toBe('s1');
expect(useFilterStore.getState().rules[0].name).toBe('MyFilter');
});
it('should handle only vacation script present (no filter scripts)', async () => {
const mockClient = {
getSieveCapabilities: () => null,
getSieveScripts: async () => [
{ id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true },
],
};
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
expect(useFilterStore.getState().activeScriptId).toBeNull();
expect(useFilterStore.getState().rules).toEqual([]);
});
}); });
}); });
@@ -29,4 +29,14 @@ describe('settings-store attachment action', () => {
expect(exported.calendarInvitationParsingEnabled).toBe(false); expect(exported.calendarInvitationParsingEnabled).toBe(false);
}); });
it('includes reply identity auto-selection in exported settings', () => {
useSettingsStore.getState().updateSetting('autoSelectReplyIdentity', true);
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
autoSelectReplyIdentity?: boolean;
};
expect(exported.autoSelectReplyIdentity).toBe(true);
});
}); });
+57 -8
View File
@@ -52,6 +52,7 @@ interface AuthState {
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [ const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
{ key: 'cors_blocked', matches: ['CORS_ERROR'] }, { key: 'cors_blocked', matches: ['CORS_ERROR'] },
{ key: 'totp_required', matches: ['TOTP_REQUIRED'] },
{ key: 'invalid_credentials', matches: ['Invalid username or password', '401', 'Unauthorized'] }, { key: 'invalid_credentials', matches: ['Invalid username or password', '401', 'Unauthorized'] },
{ key: 'connection_failed', matches: ['network', 'Failed to fetch', 'NetworkError', 'ECONNREFUSED', 'Load failed', 'cancelled'] }, { key: 'connection_failed', matches: ['network', 'Failed to fetch', 'NetworkError', 'ECONNREFUSED', 'Load failed', 'cancelled'] },
{ key: 'server_error', matches: ['500', '502', '503', '504', 'Internal Server Error', 'Service Unavailable'] }, { key: 'server_error', matches: ['500', '502', '503', '504', 'Internal Server Error', 'Service Unavailable'] },
@@ -371,6 +372,47 @@ export const useAuthStore = create<AuthState>()(
clearAllStores(); clearAllStores();
} }
// When TOTP was used, try to upgrade to token-based auth so the
// session survives TOTP rotation (basic auth embeds the TOTP in
// every request, which expires after ~30 seconds).
let upgradedToOAuth = false;
let oauthAccessToken: string | null = null;
let oauthExpiresIn = 0;
if (totp) {
try {
const tokenRes = await fetch('/api/auth/totp-token-exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
});
if (tokenRes.ok) {
const { access_token, expires_in, has_refresh_token } = await tokenRes.json();
// Upgrade client to Bearer auth
client.upgradeToBearer(access_token, () => get().refreshAccessToken());
oauthAccessToken = access_token;
oauthExpiresIn = expires_in;
upgradedToOAuth = true;
debug.log('TOTP login upgraded to token-based auth (has_refresh_token=' + has_refresh_token + ')');
} else {
const errorBody = await tokenRes.json().catch(() => ({ error: 'unknown' }));
debug.warn('TOTP token exchange failed:', tokenRes.status, errorBody);
}
} catch (err) {
debug.warn('TOTP token exchange error:', err);
}
// If token exchange failed, enable TOTP re-auth prompt so the
// client can ask for a fresh code on 401 instead of disconnecting.
if (!upgradedToOAuth) {
const { useTotpReauthStore } = await import('@/stores/totp-reauth-store');
client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp());
debug.log('TOTP re-auth enabled — user will be prompted for fresh codes on session expiry');
}
}
const effectiveAuthMode = upgradedToOAuth ? 'oauth' : 'basic';
// Store client in multi-account map // Store client in multi-account map
clients.set(accountId, client); clients.set(accountId, client);
bindClientStatusHandlers(client, set, get, accountId); bindClientStatusHandlers(client, set, get, accountId);
@@ -379,7 +421,7 @@ export const useAuthStore = create<AuthState>()(
label: primaryIdentity?.name || username, label: primaryIdentity?.name || username,
serverUrl, serverUrl,
username, username,
authMode: 'basic', authMode: effectiveAuthMode,
rememberMe: !!rememberMe, rememberMe: !!rememberMe,
displayName: primaryIdentity?.name || username, displayName: primaryIdentity?.name || username,
email: primaryIdentity?.email || username, email: primaryIdentity?.email || username,
@@ -392,6 +434,7 @@ export const useAuthStore = create<AuthState>()(
// Update account entry in case it already existed (addAccount is a no-op for existing accounts) // Update account entry in case it already existed (addAccount is a no-op for existing accounts)
accountStore.updateAccount(accountId, { accountStore.updateAccount(accountId, {
authMode: effectiveAuthMode,
rememberMe: !!rememberMe, rememberMe: !!rememberMe,
isConnected: true, isConnected: true,
hasError: false, hasError: false,
@@ -402,7 +445,8 @@ export const useAuthStore = create<AuthState>()(
// Store session cookie BEFORE setting isAuthenticated to avoid a race // Store session cookie BEFORE setting isAuthenticated to avoid a race
// condition: setting isAuthenticated triggers navigation to the main page, // condition: setting isAuthenticated triggers navigation to the main page,
// whose checkAuth() would try to read the cookie before it was stored. // whose checkAuth() would try to read the cookie before it was stored.
if (rememberMe) { if (rememberMe && !upgradedToOAuth) {
// For basic auth (no TOTP or TOTP upgrade failed), store encrypted credentials
try { try {
const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, { const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, {
method: 'POST', method: 'POST',
@@ -426,15 +470,20 @@ export const useAuthStore = create<AuthState>()(
...getClientRateLimitState(client), ...getClientRateLimitState(client),
identities, identities,
primaryIdentity, primaryIdentity,
authMode: 'basic', authMode: effectiveAuthMode,
rememberMe: !!rememberMe, rememberMe: !!rememberMe,
accessToken: null, accessToken: oauthAccessToken,
tokenExpiresAt: null, tokenExpiresAt: oauthAccessToken ? Date.now() + oauthExpiresIn * 1000 : null,
connectionLost: false, connectionLost: false,
error: null, error: null,
activeAccountId: accountId, activeAccountId: accountId,
}); });
// Schedule token refresh for TOTP-upgraded sessions
if (upgradedToOAuth && oauthExpiresIn > 0) {
scheduleRefresh(oauthExpiresIn, get().refreshAccessToken, accountId);
}
// Sync settings from server (only if enabled) // Sync settings from server (only if enabled)
fetchConfig().then(config => { fetchConfig().then(config => {
if (!config.settingsSyncEnabled) return; if (!config.settingsSyncEnabled) return;
@@ -965,7 +1014,7 @@ export const useAuthStore = create<AuthState>()(
scheduleRefresh(expires_in, get().refreshAccessToken, accountId); scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
} }
} else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) { } else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) {
const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`); const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
if (res.ok) { if (res.ok) {
const { serverUrl, username, password } = await res.json(); const { serverUrl, username, password } = await res.json();
targetClient = new JMAPClient(serverUrl, username, password); targetClient = new JMAPClient(serverUrl, username, password);
@@ -1130,7 +1179,7 @@ export const useAuthStore = create<AuthState>()(
throw new Error(`Token refresh failed: ${res.status}`); throw new Error(`Token refresh failed: ${res.status}`);
} }
} else if (account.authMode === 'basic' && account.rememberMe) { } else if (account.authMode === 'basic' && account.rememberMe) {
const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`); const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'PUT' });
if (res.ok) { if (res.ok) {
const { serverUrl, username, password } = await res.json(); const { serverUrl, username, password } = await res.json();
const client = new JMAPClient(serverUrl, username, password); const client = new JMAPClient(serverUrl, username, password);
@@ -1321,7 +1370,7 @@ export const useAuthStore = create<AuthState>()(
if (state.authMode === 'basic') { if (state.authMode === 'basic') {
set({ isLoading: true, isRateLimited: false, rateLimitUntil: null }); set({ isLoading: true, isRateLimited: false, rateLimitUntil: null });
try { try {
const res = await fetch('/api/auth/session'); const res = await fetch('/api/auth/session', { method: 'PUT' });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (!data.serverUrl || !data.username || !data.password) { if (!data.serverUrl || !data.username || !data.password) {
+170 -178
View File
@@ -5,6 +5,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/ty
import { debug } from '@/lib/debug'; import { debug } from '@/lib/debug';
import { normalizeAllDayDuration } from '@/lib/calendar-utils'; import { normalizeAllDayDuration } from '@/lib/calendar-utils';
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks'; export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
@@ -132,6 +133,7 @@ interface CalendarStore {
// iCal subscriptions // iCal subscriptions
icalSubscriptions: ICalSubscription[]; icalSubscriptions: ICalSubscription[];
addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>; addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
updateICalSubscription: (client: IJMAPClient, subscriptionId: string, updates: { url?: string; name?: string; color?: string; refreshInterval?: number }) => Promise<void>;
removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>; removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>; refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>; refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
@@ -190,13 +192,17 @@ export const useCalendarStore = create<CalendarStore>()(
before: end, before: end,
}); });
// Filter out malformed events missing required 'start' field // Filter out malformed events missing required 'start' field
const events = rawEvents.filter(e => typeof e.start === 'string' && e.start); const validEvents = rawEvents.filter(e => typeof e.start === 'string' && e.start);
const droppedEvents = rawEvents.length - events.length; const droppedEvents = rawEvents.length - validEvents.length;
// Expand recurring events client-side (Stalwart doesn't support
// mutations on synthetic IDs from server-side expandRecurrences)
const events = expandRecurringEvents(validEvents, start, end);
debug.log('Calendar fetchEvents completed', { debug.log('Calendar fetchEvents completed', {
start, start,
end, end,
rawCount: rawEvents.length, rawCount: rawEvents.length,
usableCount: events.length, validCount: validEvents.length,
expandedCount: events.length,
droppedEvents, droppedEvents,
}); });
if (droppedEvents > 0) { if (droppedEvents > 0) {
@@ -291,10 +297,18 @@ export const useCalendarStore = create<CalendarStore>()(
updateEvent: async (client, id, updates, sendSchedulingMessages) => { updateEvent: async (client, id, updates, sendSchedulingMessages) => {
set({ error: null }); set({ error: null });
try { try {
// Resolve shared event IDs // Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === id); const storeEvent = get().events.find(e => e.id === id);
const realId = storeEvent?.originalId || id; const realId = storeEvent?.originalId || id;
const targetAccountId = storeEvent?.accountId; const targetAccountId = storeEvent?.accountId;
debug.log('Calendar updateEvent', {
storeId: id,
realId,
uid: storeEvent?.uid,
recurrenceId: storeEvent?.recurrenceId,
targetAccountId,
updateKeys: Object.keys(updates),
});
// Remap namespaced calendarIds back to original IDs // Remap namespaced calendarIds back to original IDs
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates }); const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
if (cleanUpdates.calendarIds) { if (cleanUpdates.calendarIds) {
@@ -305,54 +319,26 @@ export const useCalendarStore = create<CalendarStore>()(
} }
cleanUpdates.calendarIds = remapped; cleanUpdates.calendarIds = remapped;
} }
try {
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId); await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
} catch (updateError) {
// Stalwart rejects updates to "synthetic" JMAP IDs (CalDAV-created events
// or expanded recurring-event instances returned by expandRecurrences).
// Resolve the real event via a UID query and retry.
const message = updateError instanceof Error ? updateError.message : '';
if (message.toLowerCase().includes('synthetic') && storeEvent) {
debug.log('Event has synthetic ID, resolving real ID via UID query');
const queryResults = await client.queryCalendarEvents(
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
);
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
if (realEvent) {
const resolvedId = realEvent.originalId || realEvent.id;
if (storeEvent.recurrenceId) {
// Recurring instance: patch the master event's recurrenceOverrides.
// Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1
const escapedRecurrenceId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1');
// Build override object with all changed properties
const overrideObj: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cleanUpdates as Record<string, unknown>)) {
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
overrideObj[key] = value;
}
const patchUpdates: Record<string, unknown> = {
[`recurrenceOverrides/${escapedRecurrenceId}`]: overrideObj,
};
await client.updateCalendarEvent(
resolvedId,
patchUpdates as unknown as Partial<CalendarEvent>,
sendSchedulingMessages,
targetAccountId
);
} else {
// Non-recurring event with synthetic ID: retry with the real ID
await client.updateCalendarEvent(resolvedId, cleanUpdates, sendSchedulingMessages, targetAccountId);
}
set((state) => ({ set((state) => ({
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e), events: state.events.map(e => {
})); if (e.id !== id) return e;
return; const merged = { ...e, ...cleanUpdates };
// When start changes, shift utcStart/utcEnd by the same delta so the
// event renders at the new position immediately (optimistic update).
if (cleanUpdates.start && e.start && e.utcStart) {
const oldStart = new Date(e.start).getTime();
const newStart = new Date(cleanUpdates.start).getTime();
const delta = newStart - oldStart;
if (delta !== 0) {
merged.utcStart = new Date(new Date(e.utcStart).getTime() + delta).toISOString();
if (e.utcEnd) {
merged.utcEnd = new Date(new Date(e.utcEnd).getTime() + delta).toISOString();
} }
} }
throw updateError;
} }
set((state) => ({ return merged;
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e), }),
})); }));
} catch (error) { } catch (error) {
debug.error('Failed to update event:', error); debug.error('Failed to update event:', error);
@@ -370,7 +356,7 @@ export const useCalendarStore = create<CalendarStore>()(
throw new Error('Invalid participant ID'); throw new Error('Invalid participant ID');
} }
try { try {
// Resolve shared event IDs // Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === eventId); const storeEvent = get().events.find(e => e.id === eventId);
const realId = storeEvent?.originalId || eventId; const realId = storeEvent?.originalId || eventId;
const targetAccountId = storeEvent?.accountId; const targetAccountId = storeEvent?.accountId;
@@ -383,65 +369,12 @@ export const useCalendarStore = create<CalendarStore>()(
if (replyTo) { if (replyTo) {
patch.replyTo = replyTo; patch.replyTo = replyTo;
} }
try {
await client.updateCalendarEvent( await client.updateCalendarEvent(
realId, realId,
patch as unknown as Partial<CalendarEvent>, patch as unknown as Partial<CalendarEvent>,
true, true,
targetAccountId targetAccountId
); );
} catch (updateError) {
// Stalwart rejects updates to synthetic IDs. Resolve real ID via UID query.
const message = updateError instanceof Error ? updateError.message : '';
if (message.toLowerCase().includes('synthetic') && storeEvent) {
debug.log('RSVP: Event has synthetic ID, resolving real ID via UID query');
const queryResults = await client.queryCalendarEvents(
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
);
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
if (realEvent) {
const resolvedId = realEvent.originalId || realEvent.id;
if (storeEvent.recurrenceId) {
// Recurring instance: patch RSVP as recurrence override on master
// Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1
const escapedRecId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1');
const overrideObj: Record<string, unknown> = {
[patchKey]: status,
};
if (replyTo) {
overrideObj['replyTo'] = replyTo;
}
const overridePatch: Record<string, unknown> = {
[`recurrenceOverrides/${escapedRecId}`]: overrideObj,
};
await client.updateCalendarEvent(
resolvedId,
overridePatch as unknown as Partial<CalendarEvent>,
true,
targetAccountId
);
} else {
// Non-recurring event: retry RSVP with real ID
await client.updateCalendarEvent(
resolvedId,
patch as unknown as Partial<CalendarEvent>,
true,
targetAccountId
);
}
set((state) => ({
events: state.events.map(e => e.id === eventId ? { ...e, participants: {
...e.participants,
...(e.participants?.[participantId] ? {
[participantId]: { ...e.participants[participantId], participationStatus: status as CalendarParticipant['participationStatus'] },
} : {}),
}} : e),
}));
return;
}
}
throw updateError;
}
set((state) => ({ set((state) => ({
events: state.events.map(e => { events: state.events.map(e => {
if (e.id !== eventId || !e.participants?.[participantId]) return e; if (e.id !== eventId || !e.participants?.[participantId]) return e;
@@ -462,14 +395,76 @@ export const useCalendarStore = create<CalendarStore>()(
}, },
importEvents: async (client, events, calendarId) => { importEvents: async (client, events, calendarId) => {
let imported = 0;
// Resolve shared calendar IDs // Resolve shared calendar IDs
const cal = get().calendars.find(c => c.id === calendarId); const cal = get().calendars.find(c => c.id === calendarId);
const realCalendarId = cal?.originalId || calendarId; const realCalendarId = cal?.originalId || calendarId;
const targetAccountId = cal?.accountId; const targetAccountId = cal?.accountId;
for (const event of events) {
const src = sanitizeOutgoingCalendarEventData(event as Partial<CalendarEvent>); // Deduplicate UIDs: Stalwart enforces UID uniqueness across all calendars.
// - Events already in the target calendar → skip (true duplicates)
// - Events in other calendars → link to target calendar via calendarIds update
// - New events → create as normal
let eventsToProcess = events;
let linked = 0;
try { try {
const allServerEvents = await client.getCalendarEvents(undefined, targetAccountId);
const uidToEvent = new Map<string, { id: string; calendarIds: Record<string, boolean> }>();
for (const e of allServerEvents) {
if (e.uid) {
uidToEvent.set(e.uid, {
id: (e as CalendarEvent).originalId || e.id,
calendarIds: (e as CalendarEvent).originalCalendarIds || e.calendarIds || {},
});
}
}
const newEvents: Partial<CalendarEvent>[] = [];
const eventsToLink: { eventId: string; calendarIds: Record<string, boolean> }[] = [];
for (const e of eventsToProcess) {
if (!e.uid || !uidToEvent.has(e.uid)) {
// UID doesn't exist on server — create it
newEvents.push(e);
} else {
const existing = uidToEvent.get(e.uid)!;
if (existing.calendarIds[realCalendarId]) {
// Already in target calendar — skip
continue;
}
// Exists in another calendar — link to target calendar
eventsToLink.push({
eventId: existing.id,
calendarIds: { ...existing.calendarIds, [realCalendarId]: true },
});
}
}
// Batch-link existing events to the target calendar
for (const { eventId, calendarIds } of eventsToLink) {
try {
await client.updateCalendarEvent(eventId, { calendarIds } as Partial<CalendarEvent>, undefined, targetAccountId);
linked++;
} catch (err) {
debug.warn(`Import: failed to link event ${eventId} to target calendar:`, err);
}
}
if (linked > 0) {
debug.log(`Import: linked ${linked} existing events to target calendar`);
}
const skipped = eventsToProcess.length - newEvents.length - eventsToLink.length;
if (skipped > 0) {
debug.log(`Import: skipped ${skipped} events already in target calendar`);
}
eventsToProcess = newEvents;
} catch (error) {
debug.warn('Could not fetch existing events for deduplication, proceeding without:', error);
}
// Prepare all events for batch creation
const prepared: Partial<CalendarEvent>[] = [];
for (const event of eventsToProcess) {
const src = sanitizeOutgoingCalendarEventData(event as Partial<CalendarEvent>);
let cleanParticipants: Record<string, CalendarParticipant> | null = null; let cleanParticipants: Record<string, CalendarParticipant> | null = null;
if (src.participants) { if (src.participants) {
cleanParticipants = {}; cleanParticipants = {};
@@ -534,45 +529,41 @@ export const useCalendarStore = create<CalendarStore>()(
const v = (data as Record<string, unknown>)[k]; const v = (data as Record<string, unknown>)[k];
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k]; if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
}); });
const created = await client.createCalendarEvent(data, undefined, targetAccountId); prepared.push(data);
const mappedCreated = mapServerEventToStoreEvent(created, get().calendars, targetAccountId);
set((state) => ({ events: [...state.events, mappedCreated] }));
imported++;
} catch (error) {
const msg = error instanceof Error ? error.message : '';
if ((msg.includes('already exists') || msg.includes('duplicate') || msg.includes('conflict')) && src.uid) {
const { events: storeEvents } = get();
const alreadyInStore = storeEvents.some((e) => e.uid === src.uid);
if (alreadyInStore) {
imported++;
continue;
} }
if (prepared.length === 0) return linked;
// Batch create in chunks of 50 to avoid oversized requests
const BATCH_SIZE = 50;
let imported = 0;
for (let i = 0; i < prepared.length; i += BATCH_SIZE) {
const batch = prepared.slice(i, i + BATCH_SIZE);
try { try {
const all = await client.queryCalendarEvents({}, undefined, undefined, targetAccountId); const { created, failed } = await client.batchCreateCalendarEvents(batch, targetAccountId);
const matching = all.filter((e) => e.uid === src.uid); imported += created.length;
if (matching.length > 0) { if (failed.length > 0) {
const existingIds = new Set(storeEvents.map((e) => e.id)); debug.warn(`Import batch ${i / BATCH_SIZE + 1}: ${failed.length} events failed`);
const newEvents = matching.filter((e) => !existingIds.has(e.id));
if (newEvents.length > 0) {
set((state) => ({ events: [...state.events, ...newEvents] }));
} }
imported++; } catch (error) {
continue; debug.error(`Import batch ${i / BATCH_SIZE + 1} failed:`, error);
}
} catch {
// fall through to error
} }
} }
debug.error('Failed to import event:', event.title, error);
// Re-fetch all events properly so the store state is consistent
// (with recurrence expansion, multi-account mapping, etc.)
const { dateRange } = get();
if (dateRange) {
await get().fetchEvents(client, dateRange.start, dateRange.end);
} }
}
return imported; return imported + linked;
}, },
deleteEvent: async (client, id, sendSchedulingMessages) => { deleteEvent: async (client, id, sendSchedulingMessages) => {
set({ error: null }); set({ error: null });
try { try {
// Resolve shared event IDs // Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === id); const storeEvent = get().events.find(e => e.id === id);
const realId = storeEvent?.originalId || id; const realId = storeEvent?.originalId || id;
const targetAccountId = storeEvent?.accountId; const targetAccountId = storeEvent?.accountId;
@@ -586,39 +577,14 @@ export const useCalendarStore = create<CalendarStore>()(
debug.error('Failed to send cancellation emails:', e); debug.error('Failed to send cancellation emails:', e);
} }
} }
try { debug.log('Calendar deleteEvent', {
storeId: id,
realId,
uid: storeEvent?.uid,
recurrenceId: storeEvent?.recurrenceId,
targetAccountId,
});
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId); await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
} catch (deleteError) {
// Stalwart rejects deletes on synthetic IDs (CalDAV-created events or
// expanded recurring instances). Resolve the real ID via UID query.
const message = deleteError instanceof Error ? deleteError.message : '';
if (message.toLowerCase().includes('synthetic') && storeEvent) {
debug.log('Event has synthetic ID, resolving real ID via UID query for delete');
const queryResults = await client.queryCalendarEvents(
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
);
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
if (realEvent) {
const resolvedId = realEvent.originalId || realEvent.id;
if (storeEvent.recurrenceId) {
// Recurring instance: exclude via recurrenceOverrides on master
await client.updateCalendarEvent(
resolvedId,
{ [`recurrenceOverrides/${storeEvent.recurrenceId}`]: { excluded: true } } as unknown as Partial<CalendarEvent>,
false,
targetAccountId
);
} else {
// Non-recurring event: delete using the real ID
await client.deleteCalendarEvent(resolvedId, sendSchedulingMessages, targetAccountId);
}
} else {
throw deleteError;
}
} else {
throw deleteError;
}
}
set((state) => ({ set((state) => ({
events: state.events.filter(e => e.id !== id), events: state.events.filter(e => e.id !== id),
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId, selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
@@ -788,6 +754,38 @@ export const useCalendarStore = create<CalendarStore>()(
} }
}, },
updateICalSubscription: async (client, subscriptionId, updates) => {
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
if (!sub) return;
// Update the calendar on the server if name or color changed
if (updates.name || updates.color) {
const calUpdates: Record<string, unknown> = {};
if (updates.name) calUpdates.name = updates.name;
if (updates.color) calUpdates.color = updates.color;
await client.updateCalendar(sub.calendarId, calUpdates);
}
// Update local subscription record
const updated = { ...sub, ...updates };
set((state) => ({
icalSubscriptions: state.icalSubscriptions.map(s => s.id === subscriptionId ? updated : s),
calendars: state.calendars.map(c => {
if (c.id !== sub.calendarId) return c;
return {
...c,
...(updates.name ? { name: updates.name } : {}),
...(updates.color ? { color: updates.color } : {}),
};
}),
}));
// If URL changed, refresh to fetch events from new source
if (updates.url && updates.url !== sub.url) {
await get().refreshICalSubscription(client, subscriptionId);
}
},
removeICalSubscription: async (client, subscriptionId) => { removeICalSubscription: async (client, subscriptionId) => {
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId); const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
if (!sub) return; if (!sub) return;
@@ -858,23 +856,17 @@ export const useCalendarStore = create<CalendarStore>()(
// Import only events that don't already exist on server // Import only events that don't already exist on server
const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid)); const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid));
// Remove stale local events for this calendar // Import new events (importEvents will re-fetch all events at the end)
set((state) => ({
events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]),
}));
// Import new events
if (eventsToImport.length > 0) { if (eventsToImport.length > 0) {
await get().importEvents(client, eventsToImport, sub.calendarId); await get().importEvents(client, eventsToImport, sub.calendarId);
} else {
// No new events to import, but stale ones may have been deleted
// Re-fetch to reflect deletions
const { dateRange } = get();
if (dateRange) {
await get().fetchEvents(client, dateRange.start, dateRange.end);
}
} }
// Re-fetch ALL events from server and filter for this calendar
const allUpdatedEvents = await client.getCalendarEvents();
const updatedEvents = allUpdatedEvents.filter(e => e.calendarIds?.[sub.calendarId]);
set((state) => {
const otherEvents = state.events.filter(e => !e.calendarIds?.[sub.calendarId]);
return { events: [...otherEvents, ...updatedEvents] };
});
// Update last refreshed timestamp // Update last refreshed timestamp
set((state) => ({ set((state) => ({
+43 -10
View File
@@ -342,6 +342,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Get emails per page from settings // Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage; const emailsPerPage = useSettingsStore.getState().emailsPerPage;
// Capture position from current email count before the async call
const position = emails.length;
let result; let result;
const { searchFilters } = get(); const { searchFilters } = get();
@@ -355,9 +358,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (hasFilters) { if (hasFilters) {
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, emails.length); result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, position);
} else { } else {
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length); result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position);
} }
} else { } else {
// Load more from mailbox // Load more from mailbox
@@ -369,11 +372,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store) // Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || selectedMailbox; const jmapMailboxId = mailbox?.originalId || selectedMailbox;
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length, selectedKeyword ? `$label:${selectedKeyword}` : undefined); result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
} }
// Use fresh state when merging to avoid overwriting concurrent updates
// (e.g. refreshCurrentMailbox running during the load)
const currentEmails = get().emails;
// Deduplicate: the server may return overlapping results if new emails
// arrived between paginated requests and shifted positions.
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter((e: Email) => !existingIds.has(e.id));
set({ set({
emails: [...emails, ...result.emails], emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore, hasMoreEmails: result.hasMore,
totalEmails: result.total, totalEmails: result.total,
isLoadingMore: false isLoadingMore: false
@@ -1242,12 +1254,30 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
get().handleNewEmailNotification(result.emails[0]); get().handleNewEmailNotification(result.emails[0]);
} }
// Skip state update if emails haven't actually changed to avoid // Merge the refreshed first page with the existing loaded emails.
// unnecessary re-renders that cause a visible list flicker // This avoids discarding already-loaded pages which would cause the
// virtual list to shrink and then rapidly re-load (scroll bounce).
const freshMap = new Map(result.emails.map((e: Email) => [e.id, e]));
// Build the merged list: start with the fresh first page, then append
// existing emails beyond that page (if any), skipping duplicates and
// emails removed from the first page (e.g. deleted or moved).
const merged: Email[] = [...result.emails];
const mergedIds = new Set(result.emails.map((e: Email) => e.id));
for (const email of currentEmails) {
if (!mergedIds.has(email.id)) {
merged.push(email);
mergedIds.add(email.id);
}
}
// Check if anything actually changed to avoid unnecessary re-renders
const hasChanged = const hasChanged =
currentEmails.length !== result.emails.length || currentEmails.length !== merged.length ||
result.emails.some((email, i) => { merged.some((email, i) => {
const curr = currentEmails[i]; const curr = currentEmails[i];
if (!curr) return true;
return ( return (
curr.id !== email.id || curr.id !== email.id ||
curr.threadId !== email.threadId || curr.threadId !== email.threadId ||
@@ -1256,9 +1286,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}); });
if (hasChanged) { if (hasChanged) {
// hasMore should reflect whether there are still more emails beyond
// what we have loaded, using the fresh total from the server.
const hasMore = merged.length < (result.total || 0);
set({ set({
emails: result.emails, emails: merged,
hasMoreEmails: result.hasMore, hasMoreEmails: hasMore,
totalEmails: result.total, totalEmails: result.total,
}); });
} }
+16 -5
View File
@@ -52,8 +52,12 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
const capabilities = client.getSieveCapabilities(); const capabilities = client.getSieveCapabilities();
set({ sieveCapabilities: capabilities }); set({ sieveCapabilities: capabilities });
const scripts = await client.getSieveScripts(); const allScripts = await client.getSieveScripts();
debug.log('Sieve scripts fetched:', scripts.length); debug.log('Sieve scripts fetched:', allScripts.length);
// Skip the server-managed 'vacation' script (RFC 9661 §4) — it can only
// be modified via VacationResponse/set, not SieveScript/set.
const scripts = allScripts.filter(s => s.name !== 'vacation');
const activeScript = scripts.find(s => s.isActive) || scripts[0]; const activeScript = scripts.find(s => s.isActive) || scripts[0];
if (!activeScript) { if (!activeScript) {
@@ -163,7 +167,9 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
// Always re-fetch scripts from the server to get the current state // Always re-fetch scripts from the server to get the current state
// after Stalwart may have rewritten the active script. // after Stalwart may have rewritten the active script.
const scripts = await client.getSieveScripts(); const allScripts = await client.getSieveScripts();
// Skip the server-managed 'vacation' script (RFC 9661 §4)
const scripts = allScripts.filter(s => s.name !== 'vacation');
const activeScript = scripts.find(s => s.isActive) || scripts[0]; const activeScript = scripts.find(s => s.isActive) || scripts[0];
let rules = previousRules; let rules = previousRules;
@@ -182,7 +188,10 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
const content = generateScript(rules, vacation.isEnabled ? vacation : undefined); const content = generateScript(rules, vacation.isEnabled ? vacation : undefined);
if (activeScript) { if (activeScript) {
await client.updateSieveScript(activeScript.id, content, true); // Preserve the script's current activation state — don't pass activate: true
// unconditionally, as that would deactivate the server-managed 'vacation'
// script and cause VacationResponse/get to return isEnabled: false.
await client.updateSieveScript(activeScript.id, content, activeScript.isActive);
set({ set({
activeScriptId: activeScript.id, activeScriptId: activeScript.id,
rawScript: content, rawScript: content,
@@ -191,7 +200,9 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
isOpaque: false, isOpaque: false,
}); });
} else { } else {
const script = await client.createSieveScript('filters', content, true); // Don't activate; there may be a server-managed 'vacation' script active.
// The filters script will be activated when the user saves filters normally.
const script = await client.createSieveScript('filters', content, false);
set({ set({
activeScriptId: script.id, activeScriptId: script.id,
rawScript: content, rawScript: content,
+1
View File
@@ -21,6 +21,7 @@ import { usePolicyStore } from '@/stores/policy-store';
const SLOT_NAMES: SlotName[] = [ const SLOT_NAMES: SlotName[] = [
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom', 'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
'calendar-event-actions', 'admin-plugin-page',
]; ];
function emptySlots(): Record<SlotName, SlotRegistration[]> { function emptySlots(): Record<SlotName, SlotRegistration[]> {
+17
View File
@@ -33,6 +33,8 @@ export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header'; export type AttachmentPosition = 'beside-sender' | 'below-header';
export type ToolbarPosition = 'top' | 'below-subject'; export type ToolbarPosition = 'top' | 'below-subject';
export type ArchiveMode = 'single' | 'year' | 'month'; export type ArchiveMode = 'single' | 'year' | 'month';
export type MailLayout = 'split' | 'focus';
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam'; export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
export type HoverActionsMode = 'inline' | 'floating'; export type HoverActionsMode = 'inline' | 'floating';
@@ -105,6 +107,7 @@ interface SettingsState {
deleteAction: DeleteAction; deleteAction: DeleteAction;
permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash
showPreview: boolean; showPreview: boolean;
mailLayout: MailLayout;
emailsPerPage: number; emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy; externalContentPolicy: ExternalContentPolicy;
mailAttachmentAction: MailAttachmentAction; mailAttachmentAction: MailAttachmentAction;
@@ -119,6 +122,7 @@ interface SettingsState {
autoSaveDraftInterval: number; // milliseconds autoSaveDraftInterval: number; // milliseconds
sendConfirmation: boolean; sendConfirmation: boolean;
defaultReplyMode: ReplyMode; defaultReplyMode: ReplyMode;
autoSelectReplyIdentity: boolean;
plainTextMode: boolean; // Send plain text only (no rich text editor) plainTextMode: boolean; // Send plain text only (no rich text editor)
// Privacy & Security // Privacy & Security
@@ -131,6 +135,7 @@ interface SettingsState {
// Calendar // Calendar
showTimeInMonthView: boolean; showTimeInMonthView: boolean;
showWeekNumbers: boolean; showWeekNumbers: boolean;
calendarHoverPreview: CalendarHoverPreview;
// Calendar Tasks // Calendar Tasks
enableCalendarTasks: boolean; enableCalendarTasks: boolean;
@@ -149,6 +154,8 @@ interface SettingsState {
// Layout // Layout
toolbarPosition: ToolbarPosition; toolbarPosition: ToolbarPosition;
showToolbarLabels: boolean; showToolbarLabels: boolean;
hideAccountSwitcher: boolean;
showRailAccountList: boolean;
// Email Display // Email Display
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
@@ -224,6 +231,7 @@ const DEFAULT_SETTINGS = {
deleteAction: 'trash' as DeleteAction, deleteAction: 'trash' as DeleteAction,
permanentlyDeleteJunk: false, permanentlyDeleteJunk: false,
showPreview: true, showPreview: true,
mailLayout: 'split' as MailLayout,
emailsPerPage: 50, emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy, externalContentPolicy: 'ask' as ExternalContentPolicy,
mailAttachmentAction: 'preview' as MailAttachmentAction, mailAttachmentAction: 'preview' as MailAttachmentAction,
@@ -238,6 +246,7 @@ const DEFAULT_SETTINGS = {
autoSaveDraftInterval: 60000, // 1 minute autoSaveDraftInterval: 60000, // 1 minute
sendConfirmation: false, sendConfirmation: false,
defaultReplyMode: 'reply' as ReplyMode, defaultReplyMode: 'reply' as ReplyMode,
autoSelectReplyIdentity: false,
plainTextMode: false, plainTextMode: false,
// Privacy & Security // Privacy & Security
@@ -250,6 +259,7 @@ const DEFAULT_SETTINGS = {
// Calendar // Calendar
showTimeInMonthView: false, showTimeInMonthView: false,
showWeekNumbers: false, showWeekNumbers: false,
calendarHoverPreview: 'delay-500ms' as CalendarHoverPreview,
// Calendar Tasks // Calendar Tasks
enableCalendarTasks: false, enableCalendarTasks: false,
@@ -268,6 +278,8 @@ const DEFAULT_SETTINGS = {
// Layout // Layout
toolbarPosition: 'top' as ToolbarPosition, toolbarPosition: 'top' as ToolbarPosition,
showToolbarLabels: true, showToolbarLabels: true,
hideAccountSwitcher: false,
showRailAccountList: false,
// Email Display // Email Display
disableThreading: false, disableThreading: false,
@@ -333,6 +345,7 @@ export const useSettingsStore = create<SettingsState>()(
markAsReadDelay: state.markAsReadDelay, markAsReadDelay: state.markAsReadDelay,
deleteAction: state.deleteAction, deleteAction: state.deleteAction,
showPreview: state.showPreview, showPreview: state.showPreview,
mailLayout: state.mailLayout,
emailsPerPage: state.emailsPerPage, emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy, externalContentPolicy: state.externalContentPolicy,
mailAttachmentAction: state.mailAttachmentAction, mailAttachmentAction: state.mailAttachmentAction,
@@ -346,6 +359,7 @@ export const useSettingsStore = create<SettingsState>()(
autoSaveDraftInterval: state.autoSaveDraftInterval, autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation, sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode, defaultReplyMode: state.defaultReplyMode,
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode, plainTextMode: state.plainTextMode,
sessionTimeout: state.sessionTimeout, sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled, emailNotificationsEnabled: state.emailNotificationsEnabled,
@@ -359,7 +373,10 @@ export const useSettingsStore = create<SettingsState>()(
expandedFilterView: state.expandedFilterView, expandedFilterView: state.expandedFilterView,
showTimeInMonthView: state.showTimeInMonthView, showTimeInMonthView: state.showTimeInMonthView,
showWeekNumbers: state.showWeekNumbers, showWeekNumbers: state.showWeekNumbers,
calendarHoverPreview: state.calendarHoverPreview,
toolbarPosition: state.toolbarPosition, toolbarPosition: state.toolbarPosition,
hideAccountSwitcher: state.hideAccountSwitcher,
showRailAccountList: state.showRailAccountList,
senderFavicons: state.senderFavicons, senderFavicons: state.senderFavicons,
folderIcons: state.folderIcons, folderIcons: state.folderIcons,
emailKeywords: state.emailKeywords, emailKeywords: state.emailKeywords,
+136 -25
View File
@@ -91,10 +91,12 @@ function clearRememberedUnlocks(): void {
async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
unlockedKeys: Map<string, CryptoKey>; unlockedKeys: Map<string, CryptoKey>;
unlockedDecryptionKeys: Map<string, CryptoKey>; unlockedDecryptionKeys: Map<string, CryptoKey>;
unlockedLegacyDecryptionKeys: Map<string, CryptoKey>;
}> { }> {
const rememberedUnlocks = readRememberedUnlocks(); const rememberedUnlocks = readRememberedUnlocks();
const unlockedKeys = new Map<string, CryptoKey>(); const unlockedKeys = new Map<string, CryptoKey>();
const unlockedDecryptionKeys = new Map<string, CryptoKey>(); const unlockedDecryptionKeys = new Map<string, CryptoKey>();
const unlockedLegacyDecryptionKeys = new Map<string, CryptoKey>();
let removedStaleEntries = false; let removedStaleEntries = false;
for (const record of keyRecords) { for (const record of keyRecords) {
@@ -104,11 +106,14 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
} }
try { try {
const { signingKey, decryptionKey } = await unlockPrivateKey(record, passphrase); const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase);
unlockedKeys.set(record.id, signingKey); unlockedKeys.set(record.id, signingKey);
if (decryptionKey) { if (decryptionKey) {
unlockedDecryptionKeys.set(record.id, decryptionKey); unlockedDecryptionKeys.set(record.id, decryptionKey);
} }
if (legacyDecryptionKey) {
unlockedLegacyDecryptionKeys.set(record.id, legacyDecryptionKey);
}
} catch { } catch {
delete rememberedUnlocks[record.id]; delete rememberedUnlocks[record.id];
removedStaleEntries = true; removedStaleEntries = true;
@@ -119,29 +124,40 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
writeRememberedUnlocks(rememberedUnlocks); writeRememberedUnlocks(rememberedUnlocks);
} }
return { unlockedKeys, unlockedDecryptionKeys }; return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys };
} }
interface SmimePersistedState { interface SmimePersistedState {
identityKeyBindings: Record<string, string>; // identityId → keyRecordId /** Account-scoped preferences: accountId → { identityKeyBindings, defaultSignIdentity, defaultEncrypt } */
defaultSignIdentity: Record<string, boolean>; // identityId → sign by default accountPreferences: Record<string, {
identityKeyBindings: Record<string, string>;
defaultSignIdentity: Record<string, boolean>;
defaultEncrypt: boolean; defaultEncrypt: boolean;
}>;
rememberUnlockedKeys: boolean; rememberUnlockedKeys: boolean;
autoImportSignerCerts: boolean; autoImportSignerCerts: boolean;
} }
interface SmimeStore extends SmimePersistedState { interface SmimeStore extends SmimePersistedState {
// Current account scope
currentAccountId: string | null;
// Account-scoped convenience accessors (derived from accountPreferences + currentAccountId)
identityKeyBindings: Record<string, string>;
defaultSignIdentity: Record<string, boolean>;
defaultEncrypt: boolean;
// Loaded from IndexedDB // Loaded from IndexedDB
keyRecords: SmimeKeyRecord[]; keyRecords: SmimeKeyRecord[];
publicCerts: SmimePublicCert[]; publicCerts: SmimePublicCert[];
// Runtime only — never persisted // Runtime only — never persisted
unlockedKeys: Map<string, CryptoKey>; unlockedKeys: Map<string, CryptoKey>;
unlockedDecryptionKeys: Map<string, CryptoKey>; unlockedDecryptionKeys: Map<string, CryptoKey>;
unlockedLegacyDecryptionKeys: Map<string, CryptoKey>;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
// Actions // Actions
load: () => Promise<void>; load: (accountId?: string) => Promise<void>;
clearState: () => void;
importPKCS12: (file: ArrayBuffer, p12Passphrase: string, storagePassphrase: string) => Promise<SmimeKeyRecord>; importPKCS12: (file: ArrayBuffer, p12Passphrase: string, storagePassphrase: string) => Promise<SmimeKeyRecord>;
importPublicCert: (data: ArrayBuffer | string, source: SmimePublicCert['source'], contactId?: string) => Promise<SmimePublicCert>; importPublicCert: (data: ArrayBuffer | string, source: SmimePublicCert['source'], contactId?: string) => Promise<SmimePublicCert>;
bindIdentityToKey: (identityId: string, keyRecordId: string | null) => void; bindIdentityToKey: (identityId: string, keyRecordId: string | null) => void;
@@ -166,26 +182,47 @@ export const useSmimeStore = create<SmimeStore>()(
persist( persist(
(set, get) => ({ (set, get) => ({
// Persisted preferences // Persisted preferences
accountPreferences: {},
rememberUnlockedKeys: false,
autoImportSignerCerts: true,
// Runtime state
currentAccountId: null,
identityKeyBindings: {}, identityKeyBindings: {},
defaultSignIdentity: {}, defaultSignIdentity: {},
defaultEncrypt: false, defaultEncrypt: false,
rememberUnlockedKeys: false,
autoImportSignerCerts: false,
// Runtime state
keyRecords: [], keyRecords: [],
publicCerts: [], publicCerts: [],
unlockedKeys: new Map(), unlockedKeys: new Map(),
unlockedDecryptionKeys: new Map(), unlockedDecryptionKeys: new Map(),
unlockedLegacyDecryptionKeys: new Map(),
isLoading: false, isLoading: false,
error: null, error: null,
load: async () => { load: async (accountId) => {
set({ isLoading: true, error: null }); const acctId = accountId ?? get().currentAccountId;
set({ isLoading: true, error: null, currentAccountId: acctId });
// Restore account-scoped preferences
const prefs = acctId ? get().accountPreferences[acctId] : undefined;
if (prefs) {
set({
identityKeyBindings: prefs.identityKeyBindings,
defaultSignIdentity: prefs.defaultSignIdentity,
defaultEncrypt: prefs.defaultEncrypt,
});
} else {
set({
identityKeyBindings: {},
defaultSignIdentity: {},
defaultEncrypt: false,
});
}
try { try {
const [keyRecords, publicCerts] = await Promise.all([ const [keyRecords, publicCerts] = await Promise.all([
listKeyRecords(), listKeyRecords(acctId ?? undefined),
listPublicCerts(), listPublicCerts(acctId ?? undefined),
]); ]);
if (get().rememberUnlockedKeys) { if (get().rememberUnlockedKeys) {
@@ -201,6 +238,10 @@ export const useSmimeStore = create<SmimeStore>()(
...state.unlockedDecryptionKeys, ...state.unlockedDecryptionKeys,
...restoredKeys.unlockedDecryptionKeys, ...restoredKeys.unlockedDecryptionKeys,
]), ]),
unlockedLegacyDecryptionKeys: new Map([
...state.unlockedLegacyDecryptionKeys,
...restoredKeys.unlockedLegacyDecryptionKeys,
]),
isLoading: false, isLoading: false,
})); }));
return; return;
@@ -219,6 +260,8 @@ export const useSmimeStore = create<SmimeStore>()(
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const { keyRecord } = await importPkcs12(file, p12Passphrase, storagePassphrase); const { keyRecord } = await importPkcs12(file, p12Passphrase, storagePassphrase);
const acctId = get().currentAccountId;
if (acctId) keyRecord.accountId = acctId;
await saveKeyRecord(keyRecord); await saveKeyRecord(keyRecord);
set((state) => ({ set((state) => ({
keyRecords: [...state.keyRecords, keyRecord], keyRecords: [...state.keyRecords, keyRecord],
@@ -245,6 +288,7 @@ export const useSmimeStore = create<SmimeStore>()(
const publicCert: SmimePublicCert = { const publicCert: SmimePublicCert = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
accountId: get().currentAccountId ?? undefined,
email: email.toLowerCase(), email: email.toLowerCase(),
certificate: der, certificate: der,
issuer: info.issuer, issuer: info.issuer,
@@ -279,7 +323,15 @@ export const useSmimeStore = create<SmimeStore>()(
} else { } else {
bindings[identityId] = keyRecordId; bindings[identityId] = keyRecordId;
} }
return { identityKeyBindings: bindings }; const accountPreferences = { ...state.accountPreferences };
const acctId = state.currentAccountId;
if (acctId) {
accountPreferences[acctId] = {
...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }),
identityKeyBindings: bindings,
};
}
return { identityKeyBindings: bindings, accountPreferences };
}); });
}, },
@@ -291,16 +343,25 @@ export const useSmimeStore = create<SmimeStore>()(
unlockedKeys.delete(id); unlockedKeys.delete(id);
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys); const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
unlockedDecryptionKeys.delete(id); unlockedDecryptionKeys.delete(id);
const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys);
unlockedLegacyDecryptionKeys.delete(id);
// Remove any identity bindings pointing to this key // Remove any identity bindings pointing to this key
const bindings = { ...state.identityKeyBindings }; const bindings = { ...state.identityKeyBindings };
for (const [identityId, keyId] of Object.entries(bindings)) { for (const [identityId, keyId] of Object.entries(bindings)) {
if (keyId === id) delete bindings[identityId]; if (keyId === id) delete bindings[identityId];
} }
const accountPreferences = { ...state.accountPreferences };
const acctId = state.currentAccountId;
if (acctId && accountPreferences[acctId]) {
accountPreferences[acctId] = { ...accountPreferences[acctId], identityKeyBindings: bindings };
}
return { return {
keyRecords: state.keyRecords.filter((k) => k.id !== id), keyRecords: state.keyRecords.filter((k) => k.id !== id),
unlockedKeys, unlockedKeys,
unlockedDecryptionKeys, unlockedDecryptionKeys,
unlockedLegacyDecryptionKeys,
identityKeyBindings: bindings, identityKeyBindings: bindings,
accountPreferences,
}; };
}); });
}, },
@@ -316,7 +377,7 @@ export const useSmimeStore = create<SmimeStore>()(
const record = get().keyRecords.find((k) => k.id === id); const record = get().keyRecords.find((k) => k.id === id);
if (!record) throw new Error('Key record not found'); if (!record) throw new Error('Key record not found');
const { signingKey, decryptionKey } = await unlockPrivateKey(record, passphrase); const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase);
if (get().rememberUnlockedKeys) { if (get().rememberUnlockedKeys) {
rememberUnlockedKey(id, passphrase); rememberUnlockedKey(id, passphrase);
} }
@@ -327,7 +388,11 @@ export const useSmimeStore = create<SmimeStore>()(
if (decryptionKey) { if (decryptionKey) {
unlockedDecryptionKeys.set(id, decryptionKey); unlockedDecryptionKeys.set(id, decryptionKey);
} }
return { unlockedKeys, unlockedDecryptionKeys }; const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys);
if (legacyDecryptionKey) {
unlockedLegacyDecryptionKeys.set(id, legacyDecryptionKey);
}
return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys };
}); });
}, },
@@ -338,13 +403,15 @@ export const useSmimeStore = create<SmimeStore>()(
unlockedKeys.delete(id); unlockedKeys.delete(id);
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys); const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
unlockedDecryptionKeys.delete(id); unlockedDecryptionKeys.delete(id);
return { unlockedKeys, unlockedDecryptionKeys }; const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys);
unlockedLegacyDecryptionKeys.delete(id);
return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys };
}); });
}, },
lockAllKeys: () => { lockAllKeys: () => {
clearRememberedUnlocks(); clearRememberedUnlocks();
set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map() }); set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), unlockedLegacyDecryptionKeys: new Map() });
}, },
getKeyRecordForIdentity: (identityId) => { getKeyRecordForIdentity: (identityId) => {
@@ -378,13 +445,32 @@ export const useSmimeStore = create<SmimeStore>()(
}, },
setSignDefault: (identityId, value) => { setSignDefault: (identityId, value) => {
set((state) => ({ set((state) => {
defaultSignIdentity: { ...state.defaultSignIdentity, [identityId]: value }, const defaultSignIdentity = { ...state.defaultSignIdentity, [identityId]: value };
})); const accountPreferences = { ...state.accountPreferences };
const acctId = state.currentAccountId;
if (acctId) {
accountPreferences[acctId] = {
...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }),
defaultSignIdentity,
};
}
return { defaultSignIdentity, accountPreferences };
});
}, },
setEncryptDefault: (value) => { setEncryptDefault: (value) => {
set({ defaultEncrypt: value }); set((state) => {
const accountPreferences = { ...state.accountPreferences };
const acctId = state.currentAccountId;
if (acctId) {
accountPreferences[acctId] = {
...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }),
defaultEncrypt: value,
};
}
return { defaultEncrypt: value, accountPreferences };
});
}, },
setRememberUnlockedKeys: (value) => { setRememberUnlockedKeys: (value) => {
@@ -403,17 +489,42 @@ export const useSmimeStore = create<SmimeStore>()(
getUnlockedKey: (id) => get().unlockedKeys.get(id), getUnlockedKey: (id) => get().unlockedKeys.get(id),
clearState: () => {
clearRememberedUnlocks();
set({
keyRecords: [],
publicCerts: [],
unlockedKeys: new Map(),
unlockedDecryptionKeys: new Map(),
unlockedLegacyDecryptionKeys: new Map(),
identityKeyBindings: {},
defaultSignIdentity: {},
defaultEncrypt: false,
currentAccountId: null,
isLoading: false,
error: null,
});
},
setError: (error) => set({ error }), setError: (error) => set({ error }),
}), }),
{ {
name: 'smime-preferences', name: 'smime-preferences',
partialize: (state): SmimePersistedState => ({ partialize: (state): SmimePersistedState => ({
identityKeyBindings: state.identityKeyBindings, accountPreferences: state.accountPreferences,
defaultSignIdentity: state.defaultSignIdentity,
defaultEncrypt: state.defaultEncrypt,
rememberUnlockedKeys: state.rememberUnlockedKeys, rememberUnlockedKeys: state.rememberUnlockedKeys,
autoImportSignerCerts: state.autoImportSignerCerts, autoImportSignerCerts: state.autoImportSignerCerts,
}), }),
merge: (persisted, current) => {
const p = persisted as Partial<SmimePersistedState & { identityKeyBindings?: Record<string, string>; defaultSignIdentity?: Record<string, boolean>; defaultEncrypt?: boolean }>;
return {
...current,
// Migrate legacy flat preferences into accountPreferences
accountPreferences: p?.accountPreferences ?? {},
rememberUnlockedKeys: p?.rememberUnlockedKeys ?? false,
autoImportSignerCerts: p?.autoImportSignerCerts ?? true,
};
},
}, },
), ),
); );
+13 -1
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { CalendarTask } from '@/lib/jmap/types'; import type { CalendarTask } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { debug } from '@/lib/debug';
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue'; export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
@@ -36,18 +37,29 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
setShowCompleted: (show) => set({ showCompleted: show }), setShowCompleted: (show) => set({ showCompleted: show }),
fetchTasks: async (client, calendarIds) => { fetchTasks: async (client, calendarIds) => {
debug.log('TaskStore/fetchTasks start', { calendarIds: calendarIds || 'all' });
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const tasks = await client.getCalendarTasks(calendarIds); const tasks = await client.getCalendarTasks(calendarIds);
debug.log('TaskStore/fetchTasks received', tasks.length, 'tasks');
tasks.forEach((t, i) => {
debug.log(`TaskStore/fetchTasks [${i}]`, {
id: t.id, uid: t.uid, '@type': t['@type'],
title: t.title, due: t.due, progress: t.progress,
showWithoutTime: t.showWithoutTime, calendarIds: t.calendarIds,
});
});
set({ tasks, isLoading: false }); set({ tasks, isLoading: false });
} catch (error) { } catch (error) {
console.error('Failed to fetch tasks:', error); debug.error('TaskStore/fetchTasks failed', error);
set({ isLoading: false, error: 'Failed to fetch tasks' }); set({ isLoading: false, error: 'Failed to fetch tasks' });
} }
}, },
createTask: async (client, task) => { createTask: async (client, task) => {
debug.log('TaskStore/createTask', task);
const created = await client.createCalendarTask(task); const created = await client.createCalendarTask(task);
debug.log('TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title });
set({ tasks: [...get().tasks, created] }); set({ tasks: [...get().tasks, created] });
return created; return created;
}, },
+48
View File
@@ -0,0 +1,48 @@
import { create } from 'zustand';
/**
* Global store for TOTP re-authentication prompts.
*
* When a JMAP client detects a 401 and TOTP was used for login,
* it calls the registered callback which triggers this store to show
* a dialog. The dialog collects a fresh TOTP code and resolves the
* pending promise so the client can retry with updated credentials.
*/
interface TotpReauthState {
isOpen: boolean;
resolve: ((totp: string | null) => void) | null;
/** Request a fresh TOTP code from the user. Returns the code or null if cancelled. */
requestTotp: () => Promise<string | null>;
/** Submit the TOTP code from the dialog. */
submit: (totp: string) => void;
/** Cancel/dismiss the dialog. */
cancel: () => void;
}
export const useTotpReauthStore = create<TotpReauthState>()((set, get) => ({
isOpen: false,
resolve: null,
requestTotp: () => {
// If already open, cancel the previous request
const prev = get().resolve;
if (prev) prev(null);
return new Promise<string | null>((resolve) => {
set({ isOpen: true, resolve });
});
},
submit: (totp: string) => {
const { resolve } = get();
if (resolve) resolve(totp);
set({ isOpen: false, resolve: null });
},
cancel: () => {
const { resolve } = get();
if (resolve) resolve(null);
set({ isOpen: false, resolve: null });
},
}));

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