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=Bulwark Webmail
# URL of your JMAP-compatible mail server (required)
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
JMAP_SERVER_URL=https://your-jmap-server.com
# Allow users to specify a custom JMAP server URL on the login form.
# When enabled, a "JMAP Server" field appears on the login page.
# Users can connect to any JMAP-compatible server.
# NOTE: External JMAP servers must include this domain in their CORS
# Access-Control-Allow-Origin header, or browser requests will be blocked.
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
# =============================================================================
# Stalwart Mail Server Integration
# =============================================================================
-7
View File
@@ -1,7 +0,0 @@
# Check for AI attribution in commit message
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
echo " This violates project policy in CLAUDE.md"
echo " Remove 'Co-Authored-By: Claude' and similar references"
exit 1
fi
+44
View File
@@ -1,5 +1,49 @@
# 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)
### Features
+1 -1
View File
@@ -195,7 +195,7 @@ webmail/
- **Never commit sensitive data** (API keys, passwords, etc.)
- **Sanitize user input** and email content
- **Block external content** by default for privacy
- Report security vulnerabilities privately
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
## Questions?
+1 -1
View File
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.4.9-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.4.10-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
+1 -1
View File
@@ -1 +1 @@
1.4.9
1.4.10
+38 -9
View File
@@ -39,6 +39,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils";
import { useTaskStore } from "@/stores/task-store";
import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
@@ -66,9 +67,9 @@ export default function CalendarPage() {
isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions,
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore();
@@ -82,6 +83,7 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -337,18 +339,28 @@ export default function CalendarPage() {
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (calendarHoverPreview === 'off') return;
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event
if (showEventModal && editEvent?.id === event.id) return;
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, [isMobile, showEventModal, editEvent]);
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
hoverTimerRef.current = setTimeout(() => {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, ms);
} else {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
const handleHoverLeave = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
hoverTimerRef.current = setTimeout(() => {
setDetailEvent(null);
setDetailAnchorRect(null);
}, 200);
}, 300);
}, []);
const handleEditFromDetail = useCallback(() => {
@@ -385,12 +397,16 @@ export default function CalendarPage() {
}
}, [client, fetchEvents]);
const focusCalendarOnEvent = useCallback((event: Pick<CalendarEvent, "start">) => {
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
if (!event.start) {
return;
}
const eventDate = parseISO(event.start);
const eventDate = getEventStartDate({
start: event.start,
utcStart: event.utcStart ?? null,
showWithoutTime: event.showWithoutTime ?? false,
});
if (Number.isNaN(eventDate.getTime())) {
return;
}
@@ -873,7 +889,7 @@ export default function CalendarPage() {
})();
return (
<div className="relative flex-1 flex flex-col overflow-hidden">
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{viewContent}
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
@@ -933,6 +949,7 @@ export default function CalendarPage() {
updateCalendar(client, calendarId, { color });
} : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client}
/>
</div>
@@ -1099,6 +1116,18 @@ export default function CalendarPage() {
/>
)}
{editingSubscription && client && (() => {
const sub = icalSubscriptions.find(s => s.id === editingSubscription);
if (!sub) return null;
return (
<ICalSubscriptionModal
client={client}
editSubscription={sub}
onClose={() => setEditingSubscription(null)}
/>
);
})()}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
+46 -6
View File
@@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.9";
const APP_VERSION = "1.4.10";
const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" },
@@ -32,13 +32,14 @@ export default function LoginPage() {
const isAddAccountMode = searchParams.get("mode") === "add-account";
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = useConfig();
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [jmapEndpoint, setJmapEndpoint] = useState("");
const [totpCode, setTotpCode] = useState("");
const [showTotpField, setShowTotpField] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
@@ -73,6 +74,12 @@ export default function LoginPage() {
}
}, [appName, serverUrl]);
useEffect(() => {
if (serverUrl && !jmapEndpoint) {
setJmapEndpoint(serverUrl);
}
}, [serverUrl, jmapEndpoint]);
useEffect(() => {
try {
if (sessionStorage.getItem('session_expired') === 'true') {
@@ -91,6 +98,14 @@ export default function LoginPage() {
prevError.current = error;
}, [error]);
// Auto-show and focus TOTP field when server requires it
useEffect(() => {
if (error === 'totp_required') {
setShowTotpField(true);
setTimeout(() => totpInputRef.current?.focus(), 100);
}
}, [error]);
useEffect(() => {
if (!serverUrl) return;
const saved = localStorage.getItem("webmail_usernames");
@@ -264,7 +279,7 @@ export default function LoginPage() {
);
}
if (!serverUrl && !demoMode) {
if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-md mx-auto px-4 text-center">
@@ -362,7 +377,7 @@ export default function LoginPage() {
sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", serverUrl!);
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
if (isAddAccountMode) {
sessionStorage.setItem("oauth_add_account_mode", "true");
}
@@ -382,8 +397,9 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
const success = await login(
serverUrl,
effectiveServerUrl,
formData.username,
formData.password,
totpCode || undefined,
@@ -749,6 +765,27 @@ export default function LoginPage() {
/* Login Form */
<form onSubmit={handleSubmit} className="space-y-5">
<fieldset disabled={isLoading} className="space-y-4">
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
{allowCustomJmapEndpoint && (
<div className="space-y-1.5">
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
{t("jmap_endpoint_label")}
</label>
<Input
id="jmap-endpoint"
type="url"
value={jmapEndpoint}
onChange={(e) => setJmapEndpoint(e.target.value)}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("jmap_endpoint_placeholder")}
required
/>
<p className="text-[11px] text-muted-foreground leading-snug">
{t("jmap_endpoint_cors_hint")}
</p>
</div>
)}
{/* Username field */}
<div className="space-y-1.5">
<label htmlFor="username" className="block text-sm font-medium text-foreground">
@@ -861,7 +898,10 @@ export default function LoginPage() {
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
className={cn(
"h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest",
error === 'totp_required' && "border-primary ring-2 ring-primary/30"
)}
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
aria-label={t("totp_label")}
+43 -24
View File
@@ -30,6 +30,7 @@ import {
ComposerErrorFallback,
} from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
@@ -53,6 +54,7 @@ export default function Home() {
const t = useTranslations();
const tCommon = useTranslations('common');
const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout);
const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState("");
@@ -519,7 +521,10 @@ export default function Home() {
const handleEditDraft = async (email?: Email) => {
if (!client) return;
let draft = email || selectedEmail;
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
? email
: selectedEmail;
let draft = draftCandidate;
if (!draft) return;
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
@@ -967,6 +972,10 @@ export default function Home() {
// Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const isFocusedMailLayout = mailLayout === 'focus';
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
// Handle email selection with mobile view switching
const handleEmailSelect = async (email: { id: string }) => {
@@ -1017,6 +1026,9 @@ export default function Home() {
setConversationEmails([]);
}
selectEmail(null);
if (isTablet) {
setTabletListVisible(true);
}
setActiveView("list");
};
@@ -1199,10 +1211,9 @@ export default function Home() {
// Tablet/Desktop: fixed width with collapse animation
"md:flex-shrink-0 md:shadow-sm",
!isResizing && "transition-all duration-200 ease-out",
// Tablet: collapse when email selected
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
)}
style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined}
style={!isMobile && !shouldCollapseListPane ? { width: emailListWidth } : undefined}
>
{/* Mobile Header for List View */}
<MobileHeader
@@ -1487,24 +1498,27 @@ export default function Home() {
</ErrorBoundary>
</div>
{/* Floating Compose Button (mobile) */}
{isMobile && (
<Button
onClick={() => {
setComposerMode('compose');
setShowComposer(true);
setActiveView('viewer');
}}
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
aria-label={t('sidebar.compose')}
>
<PenSquare className="h-6 w-6" />
</Button>
)}
{/* Floating Compose Button */}
<Button
onClick={() => {
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
className={cn(
"absolute z-40 rounded-full shadow-lg",
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
)}
aria-label={t('sidebar.compose')}
title={t('sidebar.compose_hint')}
data-tour="compose-button"
>
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
</Button>
</div>
{/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && (
{!isMobile && !isTablet && !isFocusedMailLayout && (
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
@@ -1521,7 +1535,8 @@ export default function Home() {
"max-md:fixed max-md:inset-0 max-md:z-30",
isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: relative
"md:relative"
"md:relative",
shouldHideViewerPane && "md:hidden"
)}
>
{/* Inline Composer - shown in viewer pane */}
@@ -1537,8 +1552,10 @@ export default function Home() {
mode={pendingDraft?.mode ?? composerMode}
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to,
cc: selectedEmail.cc,
bcc: selectedEmail.bcc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
@@ -1648,14 +1665,15 @@ export default function Home() {
}}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
onBack={() => {
setTabletListVisible(true);
selectEmail(null);
}}
onBack={handleMobileBack}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
}}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
@@ -1709,6 +1727,7 @@ export default function Home() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
<TotpReauthDialog />
</div>
</DragDropProvider>
);
+285
View File
@@ -0,0 +1,285 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
import Link from 'next/link';
interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
label: string;
description?: string;
required?: boolean;
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
}
interface PluginConfig {
[key: string]: unknown;
}
interface PluginInfo {
id: string;
name: string;
description: string;
version: string;
author: string;
type: string;
permissions: string[];
enabled: boolean;
configSchema?: Record<string, ConfigField>;
}
export default function PluginConfigPage() {
const params = useParams();
const pluginId = params.id as string;
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
const [config, setConfig] = useState<PluginConfig>({});
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginId]);
// Initialize form values from config + schema defaults when data loads
useEffect(() => {
if (!plugin?.configSchema) return;
const initial: Record<string, string> = {};
for (const [key, field] of Object.entries(plugin.configSchema)) {
const stored = config[key];
if (stored !== undefined && stored !== null) {
initial[key] = String(stored);
} else if (field.default !== undefined) {
initial[key] = String(field.default);
} else {
initial[key] = '';
}
}
setFormValues(initial);
}, [plugin, config]);
async function fetchData() {
setLoading(true);
try {
const [pluginsRes, configRes] = await Promise.all([
fetch('/api/admin/plugins'),
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
]);
if (pluginsRes.ok) {
const plugins: PluginInfo[] = await pluginsRes.json();
setPlugin(plugins.find(p => p.id === pluginId) || null);
}
if (configRes.ok) {
setConfig(await configRes.json());
}
} finally {
setLoading(false);
}
}
async function handleSaveAll() {
if (!plugin?.configSchema) return;
setSaving(true);
setMessage(null);
// Validate required fields
for (const [key, field] of Object.entries(plugin.configSchema)) {
if (field.required && !formValues[key]?.trim()) {
setMessage({ type: 'error', text: `"${field.label}" is required` });
setSaving(false);
return;
}
}
try {
// Save each changed field
let hasError = false;
for (const [key, field] of Object.entries(plugin.configSchema)) {
const newVal = formValues[key] ?? '';
const oldVal = config[key] !== undefined ? String(config[key]) : '';
// Skip unchanged fields (and skip secret fields that show as empty when they have a stored value)
if (newVal === oldVal) continue;
if (field.type === 'secret' && !newVal && config[key]) continue;
// Convert types
let value: unknown = newVal;
if (field.type === 'boolean') value = newVal === 'true';
else if (field.type === 'number') value = Number(newVal);
// Delete if clearing a non-required field
if (!newVal && !field.required) {
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
} else {
hasError = true;
}
continue;
}
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
if (res.ok) {
setConfig(prev => ({ ...prev, [key]: value }));
} else {
hasError = true;
}
}
setMessage(hasError
? { type: 'error', text: 'Some settings failed to save' }
: { type: 'success', text: 'Configuration saved' }
);
} catch {
setMessage({ type: 'error', text: 'Failed to save configuration' });
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Loading...
</div>
);
}
if (!plugin) {
return (
<div className="space-y-4">
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4" /> Back to Plugins
</Link>
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
</div>
);
}
const schema = plugin.configSchema;
const hasSchema = schema && Object.keys(schema).length > 0;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<Puzzle className="w-5 h-5" />
{plugin.name} Configuration
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
v{plugin.version} by {plugin.author}
</p>
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{hasSchema ? (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Settings</h2>
</div>
<div className="p-4 space-y-5">
{Object.entries(schema).map(([key, field]) => (
<div key={key}>
<label className="text-sm font-medium text-foreground block mb-1">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
</label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
)}
{field.type === 'boolean' ? (
<select
value={formValues[key] ?? String(field.default ?? 'false')}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
) : field.type === 'select' && field.options ? (
<select
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value=""> Select </option>
{field.options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : field.type === 'secret' ? (
<div className="relative">
<input
type={revealSecrets[key] ? 'text' : 'password'}
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
/>
<button
type="button"
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
>
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
) : (
<input
type={field.type === 'number' ? 'number' : 'text'}
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={field.placeholder || ''}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
)}
</div>
))}
<button
onClick={handleSaveAll}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
Save Configuration
</button>
</div>
</div>
) : (
<div className="border border-border rounded-lg p-8 text-center">
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
</div>
)}
</div>
);
}
+9 -1
View File
@@ -1,7 +1,8 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen } from 'lucide-react';
import Link from 'next/link';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
@@ -396,6 +397,13 @@ export default function AdminPluginsPage() {
</div>
<div className="flex items-center gap-2">
<Link
href={`/admin/plugins/${plugin.id}`}
title="Configure"
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Settings className="w-4 h-4" />
</Link>
<button
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
+2
View File
@@ -28,10 +28,12 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
+8
View File
@@ -112,6 +112,14 @@ export default function AdminSettingsPage() {
<SettingsSection title="General">
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
{!!currentValue('allowCustomJmapEndpoint') && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
</p>
</div>
)}
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
+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[]) || [],
entrypoint: manifest.entrypoint as string,
enabled: true,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
installedAt: now,
updatedAt: now,
};
+39
View File
@@ -59,6 +59,45 @@ export async function GET(request: NextRequest) {
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, {
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', ''),
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
+2 -7
View File
@@ -53,16 +53,14 @@ function isEnabled(): boolean {
/**
* Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4).
* Returns true if any slot matches OR if no session cookies exist at all.
* Returns true only if a matching session cookie is found.
*/
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies();
let hasAnyCookie = false;
for (let slot = 0; slot <= 4; slot++) {
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (!token) continue;
hasAnyCookie = true;
const session = decryptSession(token);
if (session && session.username === username && session.serverUrl === serverUrl) {
@@ -70,10 +68,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
}
}
// No cookies at all → can't verify, allow (same-origin protection applies)
if (!hasAnyCookie) return true;
// Cookies exist but none matched → identity mismatch
// No matching session found (or no cookies at all)reject
return false;
}
+5 -5
View File
@@ -2,11 +2,11 @@
import { useMemo, useRef, useEffect, useCallback } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { format, parseISO, isToday, isTomorrow, startOfDay } from "date-fns";
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
import { MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card";
import { getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -49,7 +49,7 @@ export function CalendarAgendaView({
const grouped = useMemo(() => {
const sorted = [...events].sort((a, b) =>
parseISO(a.start).getTime() - parseISO(b.start).getTime()
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
);
const groups: DayGroup[] = [];
@@ -144,9 +144,9 @@ export function CalendarAgendaView({
const calId = getPrimaryCalendarId(ev);
const calendar = calId ? calendarMap.get(calId) : undefined;
const color = getEventColor(ev, calendar);
const start = parseISO(ev.start);
const start = getEventStartDate(ev);
const durMin = parseDuration(ev.duration);
const end = new Date(start.getTime() + durMin * 60000);
const end = getEventEndDate(ev);
const locationName = ev.locations
? Object.values(ev.locations)[0]?.name
: null;
+9 -11
View File
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
import { format, isSameDay, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
@@ -66,7 +66,7 @@ export function CalendarDayView({
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
if (!spansThisDay) return;
if (ev.showWithoutTime) allDay.push(ev);
if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
else timed.push(ev);
} catch { /* skip invalid dates */ }
});
@@ -127,10 +127,10 @@ export function CalendarDayView({
return format(new Date(2000, 0, 1, h), "HH:mm");
};
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile
@@ -200,7 +200,7 @@ export function CalendarDayView({
</div>
)}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
{HOURS.map((h) => (
@@ -241,11 +241,9 @@ export function CalendarDayView({
/>
))}
{layouted.map(({ event: ev, column, totalColumns }) => {
const start = parseISO(ev.start);
const startMin = start.getHours() * 60 + start.getMinutes();
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
+13 -1
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Globe, ListTodo, RefreshCw, Share2, Trash2 } from "lucide-react";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2 } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -18,6 +18,7 @@ interface CalendarSidebarPanelProps {
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null;
}
@@ -27,6 +28,7 @@ export function CalendarSidebarPanel({
onToggleVisibility,
onColorChange,
onSubscribe,
onEditSubscription,
client,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
@@ -173,6 +175,16 @@ export function CalendarSidebarPanel({
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => {
setContextMenuCalId(null);
onEditSubscription?.(sub.id);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
{tSub('edit')}
</button>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
+22 -16
View File
@@ -7,9 +7,9 @@ import {
} from "date-fns";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
@@ -77,7 +77,7 @@ export function CalendarWeekView({
const cursor = new Date(startDay);
while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd");
if (!ev.showWithoutTime) {
if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
const arr = timed.get(key) || [];
arr.push(ev);
timed.set(key, arr);
@@ -89,10 +89,18 @@ export function CalendarWeekView({
return timed;
}, [events]);
const allDaySegments = useMemo(() => buildWeekSegments(
events.filter((event) => event.showWithoutTime),
weekDays,
), [events, weekDays]);
const allDaySegments = useMemo(() => {
const explicitAllDay = buildWeekSegmentsRaw(
events.filter((event) => event.showWithoutTime),
weekDays,
);
const timedFullDay = buildTimedFullDayWeekSegments(
events.filter((event) => !event.showWithoutTime),
weekDays,
);
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
}, [events, weekDays]);
const allDayRowCount = useMemo(() => {
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
@@ -189,13 +197,13 @@ export function CalendarWeekView({
<div
ref={rootRef}
className={cn(
"flex flex-col flex-1",
"flex min-h-0 min-w-0 flex-col flex-1",
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
)}
role="grid"
aria-label={t("views.week")}
>
<div className={cn("flex flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className="flex border-b border-border">
<div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
@@ -321,7 +329,7 @@ export function CalendarWeekView({
</div>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
{HOURS.map((h) => (
@@ -344,7 +352,7 @@ export function CalendarWeekView({
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
const todayCol = isToday(day);
const layouted = layoutOverlappingEvents(dayEvents);
const layouted = layoutOverlappingEvents(dayEvents, day);
return (
<div
@@ -371,11 +379,9 @@ export function CalendarWeekView({
/>
))}
{layouted.map(({ event: ev, column, totalColumns }) => {
const start = parseISO(ev.start);
const startMin = start.getHours() * 60 + start.getMinutes();
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
+4 -3
View File
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format, parseISO } from "date-fns";
import { format } from "date-fns";
import { Users } from "lucide-react";
import { getParticipantCount } from "@/lib/calendar-participants";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store";
interface EventCardProps {
@@ -68,14 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
const startDate = parseISO(event.start);
const startDate = getEventStartDate(event);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const calendarName = calendar?.name || "";
const durationMinutes = parseDuration(event.duration);
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
const endTime = getEventEndDate(event);
const timeString = `${format(startDate, timeFmt)} ${format(endTime, timeFmt)}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
+3 -2
View File
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import {
isOrganizer,
getUserParticipantId,
@@ -138,9 +139,9 @@ export function EventDetailPopover({
const [isSavingNote, setIsSavingNote] = useState(false);
const color = getEventColor(event, calendar);
const startDate = parseISO(event.start);
const startDate = getEventStartDate(event);
const durationMinutes = parseDuration(event.duration);
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
const endDate = getEventEndDate(event);
const locationName = useMemo(() => {
if (!event.locations) return null;
+61 -11
View File
@@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
import { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput } from "./participant-input";
import {
isOrganizer,
@@ -18,6 +18,7 @@ import {
getStatusCounts,
buildParticipantMap,
} from "@/lib/calendar-participants";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
export interface PendingEventPreview {
@@ -159,7 +160,7 @@ export function EventModal({
}, [event, existingParticipants]);
const getInitialStart = (): Date => {
if (event?.start) return parseISO(event.start);
if (event?.start) return getEventStartDate(event);
if (defaultDate) {
const d = new Date(defaultDate);
if (defaultEndDate) return d;
@@ -177,9 +178,7 @@ export function EventModal({
if (event.showWithoutTime) {
return getEventDisplayEndDate(event);
}
const s = parseISO(event.start);
const dur = parseDuration(event.duration);
return new Date(s.getTime() + dur * 60000);
return getEventEndDate(event);
}
if (defaultEndDate) return new Date(defaultEndDate);
return addHours(getInitialStart(), 1);
@@ -190,6 +189,9 @@ export function EventModal({
const [location, setLocation] = useState(
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
);
const [virtualLocation, setVirtualLocation] = useState(
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
);
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
@@ -315,6 +317,20 @@ export function EventModal({
data.locations = null;
}
if (virtualLocation.trim()) {
data.virtualLocations = {
vl1: {
"@type": "VirtualLocation",
name: null,
description: null,
uri: virtualLocation.trim(),
features: null,
},
};
} else if (event && event.virtualLocations && Object.keys(event.virtualLocations).length > 0) {
data.virtualLocations = null;
}
if (recurrence !== "none") {
data.recurrenceRules = [{
"@type": "RecurrenceRule",
@@ -374,7 +390,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -384,7 +400,7 @@ export function EventModal({
const handleDuplicate = useCallback(() => {
if (!event || !onDuplicate) return;
const start = parseISO(event.start);
const start = getEventStartDate(event);
const newStart = addDays(start, 1);
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
@@ -403,6 +419,7 @@ export function EventModal({
privacy: event.privacy,
};
if (event.locations) data.locations = structuredClone(event.locations);
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants);
@@ -456,9 +473,9 @@ export function EventModal({
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
if (isAttendeeMode && event) {
const startD = parseISO(event.start);
const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration);
const endD = new Date(startD.getTime() + durMin * 60000);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event);
@@ -567,9 +584,9 @@ export function EventModal({
// View mode: read-only display of event details with Edit button
if (mode === "view" && event) {
const startD = parseISO(event.start);
const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration);
const endD = new Date(startD.getTime() + durMin * 60000);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event);
@@ -780,6 +797,39 @@ export function EventModal({
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5">
<Video className="w-4 h-4" />
{t("form.meeting_link")}
</span>
</label>
<Input
type="url"
value={virtualLocation}
onChange={(e) => setVirtualLocation(e.target.value)}
placeholder="https://meet.example.com/..."
maxLength={2000}
/>
<PluginSlot
name="calendar-event-actions"
className="mt-2 flex flex-wrap gap-2"
extraProps={{
eventData: {
title,
description,
start: startDate + 'T' + startTime,
end: endDate + 'T' + endTime,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
},
setVirtualLocation,
}}
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5">
+3 -2
View File
@@ -7,6 +7,7 @@ import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
@@ -17,7 +18,7 @@ interface ICalImportModalProps {
onClose: () => void;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing";
@@ -195,7 +196,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const formatEventDate = (event: Partial<CalendarEvent>): string => {
if (!event.start) return "";
try {
const date = parseISO(event.start);
const date = getEventStartDate(event as CalendarEvent);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
return event.showWithoutTime
? format(date, "MMM d, yyyy")
+29 -14
View File
@@ -5,24 +5,28 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useCalendarStore } from "@/stores/calendar-store";
import { useCalendarStore, type ICalSubscription } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps {
client: IJMAPClient;
onClose: () => void;
editSubscription?: ICalSubscription;
}
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(60);
const isEdit = !!editSubscription;
const [url, setUrl] = useState(editSubscription?.url || "");
const [name, setName] = useState(editSubscription?.name || "");
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
setIsSubmitting(true);
try {
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
if (isEdit && editSubscription) {
const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
if (trimmedUrl !== editSubscription.url) updates.url = trimmedUrl;
if (name.trim() !== editSubscription.name) updates.name = name.trim();
if (color !== editSubscription.color) updates.color = color;
if (refreshInterval !== editSubscription.refreshInterval) updates.refreshInterval = refreshInterval;
await updateICalSubscription(client, editSubscription.id, updates);
toast.success(t("updated", { name: name.trim() }));
onClose();
} else {
setError(t("error"));
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
onClose();
} else {
setError(t("error"));
}
}
} catch {
setError(t("error"));
setError(isEdit ? t("update_error") : t("error"));
} finally {
setIsSubmitting(false);
}
@@ -108,7 +123,7 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title")}</h2>
<h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</h2>
</div>
<button
onClick={onClose}
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("subscribing")}
{isEdit ? t("saving") : t("subscribing")}
</>
) : (
t("subscribe")
isEdit ? t("save") : t("subscribe")
)}
</Button>
</div>
+10 -2
View File
@@ -7,9 +7,10 @@ import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear,
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
isSameDay, isSameMonth, isToday, format, parseISO,
isSameDay, isSameMonth, isToday, format,
} from "date-fns";
import { cn } from "@/lib/utils";
import { getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types";
type PickerView = "days" | "months" | "years";
@@ -54,7 +55,14 @@ export function MiniCalendar({
const eventDates = useMemo(() => {
const set = new Set<string>();
events.forEach(e => {
try { set.add(format(parseISO(e.start), "yyyy-MM-dd")); } catch { /* skip */ }
try {
const { startDay, endDay } = getEventDayBounds(e);
const cursor = new Date(startDay);
while (cursor <= endDay) {
set.add(format(cursor, "yyyy-MM-dd"));
cursor.setDate(cursor.getDate() + 1);
}
} catch { /* skip */ }
});
return set;
}, [events]);
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
render(<EmailListItem email={email} />);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('renders inline preview text in focused mail layout', () => {
useSettingsStore.setState({
showPreview: true,
mailLayout: 'focus',
});
const email = makeEmail({ preview: 'Inline preview content' });
const { container } = render(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
expect(container.querySelector('p')).toBeNull();
});
});
+35 -4
View File
@@ -29,6 +29,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { findReplyIdentityId } from "@/lib/reply-identity";
import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */
@@ -76,8 +77,10 @@ interface EmailComposerProps {
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: {
from?: { email?: string; name?: string }[];
replyToAddresses?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[];
bcc?: { email?: string; name?: string }[];
subject?: string;
body?: string;
htmlBody?: string;
@@ -100,17 +103,20 @@ export function EmailComposer({
const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
// Initialize with reply/forward data if provided
const getInitialTo = () => {
if (!replyTo) return "";
// RFC 5322: use Reply-To header if present, otherwise fall back to From
const replyTarget = replyTo.replyToAddresses?.length
? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ")
: replyTo.from?.[0]?.email || "";
if (mode === 'reply') {
const email = replyTo.from?.[0]?.email || "";
return email ? email + ', ' : "";
return replyTarget ? replyTarget + ', ' : "";
} else if (mode === 'replyAll') {
const from = replyTo.from?.[0]?.email || "";
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
const combined = [from, originalTo].filter(Boolean).join(", ");
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
return combined ? combined + ', ' : "";
}
return "";
@@ -226,6 +232,31 @@ export function EmailComposer({
const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity;
useEffect(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
if (mode !== 'reply' && mode !== 'replyAll') return;
const matchedIdentityId = findReplyIdentityId(identities, {
to: replyTo?.to,
cc: replyTo?.cc,
bcc: replyTo?.bcc,
});
if (matchedIdentityId) {
setSelectedIdentityId(matchedIdentityId);
}
}, [
autoSelectReplyIdentity,
identities,
initialData?.selectedIdentityId,
mode,
replyTo?.bcc,
replyTo?.cc,
replyTo?.to,
selectedIdentityId,
]);
const composerSignatureHtml = currentIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
: currentIdentity?.textSignature
+15 -3
View File
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
interface EmailHoverActionsProps {
email: Email;
backgroundClassName?: string;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
@@ -63,6 +64,7 @@ const CORNER_CLASSES = {
export function EmailHoverActions({
email,
backgroundClassName = "bg-muted",
onToggleStar,
onMarkAsRead,
onDelete,
@@ -77,6 +79,7 @@ export function EmailHoverActions({
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const hoverBackgroundClassName = backgroundClassName;
if (hoverActions.length === 0) return null;
@@ -144,7 +147,10 @@ export function EmailHoverActions({
CORNER_CLASSES[hoverActionsCorner],
)}
>
<div className="flex items-center gap-0.5 bg-muted rounded-lg px-1.5 py-0.5 shadow-md border border-border">
<div className={cn(
"flex items-center gap-0.5 rounded-lg px-1.5 py-0.5 shadow-md border border-border",
hoverBackgroundClassName,
)}>
{actionButtons}
</div>
</div>
@@ -155,8 +161,14 @@ export function EmailHoverActions({
<div
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
>
<div className="w-8 h-full bg-gradient-to-r from-transparent to-muted" />
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
<div
className={cn("w-8 h-full", hoverBackgroundClassName)}
style={{
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
maskImage: "linear-gradient(to right, transparent, black)",
}}
/>
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
{actionButtons}
</div>
</div>
+125 -71
View File
@@ -35,6 +35,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
@@ -44,6 +45,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tag using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords);
@@ -114,15 +117,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
}
}}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
<div
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
@@ -145,7 +152,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
)}
{/* Avatar */}
{density !== 'extra-compact' && (
{!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
@@ -156,85 +163,131 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
Important
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{sender?.name || sender?.email || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'shrink-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{email.subject || t('no_subject')}
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{inlinePreview && (
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
) : (
<>
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
Important
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{email.subject || t('no_subject')}
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || t('no_subject')}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)}
</>
)}
</div>
</div>
@@ -242,6 +295,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
+20 -4
View File
@@ -103,11 +103,16 @@ export function EmailList({
const parentRef = useRef<HTMLDivElement>(null);
const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isFocusedMailLayout = mailLayout === 'focus';
const estimateSize = useCallback(() => {
if (isFocusedMailLayout) {
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
}
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
}, [density, showPreview]);
}, [density, isFocusedMailLayout, showPreview]);
const virtualizer = useVirtualizer({
count: threadGroups.length,
@@ -216,15 +221,26 @@ export function EmailList({
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
// Range-based load more: trigger when last visible item is near the end
// Range-based load more: trigger when last visible item is near the end.
// Debounce to prevent rapid cascade when thread grouping reduces item
// count below the viewport size (e.g. 2400 emails → fewer thread groups).
const virtualItems = virtualizer.getVirtualItems();
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (lastVirtualItemIndex === undefined) return;
if (lastVirtualItemIndex >= threadGroups.length - 5) {
handleLoadMore();
// Clear any pending timer so we don't stack calls
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
loadMoreTimerRef.current = setTimeout(() => {
handleLoadMore();
loadMoreTimerRef.current = null;
}, 150);
}
return () => {
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
};
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
// Scroll to the thread group containing the selected email
@@ -244,7 +260,7 @@ export function EmailList({
useEffect(() => {
virtualizer.measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [density, showPreview]);
}, [density, isFocusedMailLayout, showPreview]);
return (
<div className={cn("flex flex-col min-h-0", className)}>
+96 -19
View File
@@ -68,6 +68,7 @@ import {
HelpCircle,
EditIcon,
PlayCircle,
PenSquare,
} from "lucide-react";
import { useTranslations } from "next-intl";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -121,6 +122,7 @@ interface EmailViewerProps {
onNavigatePrev?: () => void;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
onCompose?: () => void;
currentUserEmail?: string;
currentUserName?: string;
currentMailboxRole?: string;
@@ -865,6 +867,7 @@ export function EmailViewer({
onNavigatePrev,
onShowShortcuts,
onEditDraft,
onCompose,
currentUserEmail,
currentUserName,
currentMailboxRole,
@@ -887,8 +890,10 @@ export function EmailViewer({
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
// Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk';
@@ -906,7 +911,7 @@ export function EmailViewer({
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode } = useAuthStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const [showFullHeaders, setShowFullHeaders] = useState(false);
@@ -960,9 +965,9 @@ export function EmailViewer({
// Ensure S/MIME key records are loaded from IndexedDB
useLayoutEffect(() => {
smimeStore.load();
smimeStore.load(activeAccountId ?? undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [activeAccountId]);
// Build mailbox tree for move-to dropdown
const moveTargetIds = useMemo(() => new Set(
@@ -1540,8 +1545,25 @@ export function EmailViewer({
if (detection.type === 'enveloped-data') {
// Encrypted message
const { keyRecords, unlockedDecryptionKeys } = smimeStore;
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
smimeDebug('[S/MIME] decrypt attempt:', {
keyRecordCount: keyRecords.length,
unlockedKeyCount: unlockedDecryptionKeys.size,
legacyKeyCount: unlockedLegacyDecryptionKeys.size,
keyRecordIds: keyRecords.map(k => k.id),
});
// Short-circuit: no keys imported at all
if (keyRecords.length === 0) {
smimeDebug('[S/MIME] no key records available, skipping decrypt');
setSmimeStatus({
isSigned: false,
isEncrypted: true,
decryptionError: 'no-key',
});
return;
}
try {
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
let lastError: unknown = null;
@@ -1552,6 +1574,7 @@ export function EmailViewer({
cmsBytes: candidate.normalized,
keyRecords,
unlockedKeys: unlockedDecryptionKeys,
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
});
smimeDebug('[S/MIME] decrypt success with candidate:', {
source: candidate.source,
@@ -1633,8 +1656,15 @@ export function EmailViewer({
if (!existing) {
try {
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
} catch { /* ignore import errors */ }
smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint });
} catch (importErr) {
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
}
} else {
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
}
} else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) {
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email });
}
} catch (error) {
smimeError('[S/MIME] nested signature verify failed:', {
@@ -1672,10 +1702,12 @@ export function EmailViewer({
decryptionError: 'locked',
});
} else {
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
setSmimeStatus({
isSigned: false,
isEncrypted: true,
decryptionError: err instanceof Error ? err.message : 'Decryption failed',
decryptionError: isNoKeyError ? 'no-key' : errMsg,
});
}
}
@@ -1737,8 +1769,15 @@ export function EmailViewer({
if (!existing) {
try {
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
} catch { /* ignore import errors */ }
smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint });
} catch (importErr) {
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
}
} else {
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
}
} else if (result.status.signatureValid && result.status.signerCert) {
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email });
}
} catch (err) {
if (cancelled) return;
@@ -1771,6 +1810,7 @@ export function EmailViewer({
smimeStore.autoImportSignerCerts,
smimeStore.keyRecords,
smimeStore.unlockedDecryptionKeys,
smimeStore.unlockedLegacyDecryptionKeys,
smimeStore,
]);
@@ -2526,14 +2566,21 @@ export function EmailViewer({
// If email has native dark mode, let it handle its own theming
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
// Re-invert leaf media elements so they appear normal.
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
// double re-inverting images nested inside those containers.
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #1a1a1a; }
body { filter: invert(1) hue-rotate(180deg); }
img, video, picture, svg, canvas, object, embed,
[style*="background-image"], [style*="background:"],
[background], [bgcolor],
td[background], table[background],
img[src], input[type="image"] {
img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
[background]:not(:has(img, video, svg, canvas, object, embed)),
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
td[background]:not(:has(img, video, svg, canvas, object, embed)),
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
filter: invert(1) hue-rotate(180deg);
}
` : '';
@@ -2572,11 +2619,35 @@ export function EmailViewer({
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
// Dark mode: re-invert elements with stylesheet-defined background images
// (CSS attribute selectors only catch inline styles, not <style> block rules)
if (isDark && !emailHasNativeDarkMode) {
const win = doc.defaultView;
if (win) {
doc.body.querySelectorAll('*').forEach(el => {
const htmlEl = el as HTMLElement;
// Skip elements already handled by CSS attribute selectors
if (htmlEl.style.backgroundImage || htmlEl.style.background ||
htmlEl.hasAttribute('background') || htmlEl.hasAttribute('bgcolor')) return;
// Skip leaf media elements (already re-inverted by CSS)
const tag = htmlEl.tagName;
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
const computed = win.getComputedStyle(htmlEl);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Only re-invert if this container doesn't have media children
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) {
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
}
}
});
}
}
}
} catch {
// Cross-origin restrictions - iframe will still display content
}
}, []);
}, [isDark, emailHasNativeDarkMode]);
// Export email as .eml file
const handleExportEmail = async () => {
@@ -2775,6 +2846,12 @@ export function EmailViewer({
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
<PenSquare className="w-4 h-4 mr-2" />
{t('compose')}
</Button>
)}
</div>
</div>
);
@@ -2790,7 +2867,7 @@ export function EmailViewer({
<>
{/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
{showBackButton && isTablet && !tabletListVisible && onBack && (
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -2805,7 +2882,7 @@ export function EmailViewer({
<Button
variant="default"
size="sm"
onClick={onEditDraft}
onClick={() => onEditDraft()}
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.edit_draft')}
>
@@ -3524,7 +3601,7 @@ export function EmailViewer({
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && (
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -4403,7 +4480,7 @@ export function EmailViewer({
<Button
size="sm"
variant="outline"
onClick={onEditDraft}
onClick={() => onEditDraft()}
className="gap-1.5"
>
<EditIcon className="w-3.5 h-3.5" />
@@ -4672,7 +4749,7 @@ export function EmailViewer({
</button>
{isDraft && onEditDraft ? (
<button
onClick={onEditDraft}
onClick={() => onEditDraft()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
aria-label={t('tooltips.edit_draft')}
>
+6
View File
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
text: t('unlock_key_desc'),
variant: 'warning',
});
} else if (status.decryptionError === 'no-key') {
items.push({
icon: <Lock className="w-4 h-4" />,
text: t('status_encrypted_no_key'),
variant: 'warning',
});
} else {
items.push({
icon: <ShieldX className="w-4 h-4" />,
+256 -140
View File
@@ -59,7 +59,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isChecked = selectedEmailIds.has(email.id);
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color and keyword definition from keyword definitions if not passed directly
const tagId = getEmailColorTag(email.keywords);
@@ -131,15 +134,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
onClick={handleClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
<div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
@@ -160,7 +167,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
)}
{density !== 'extra-compact' && (
{!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
@@ -170,76 +177,121 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{sender?.name || sender?.email || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'shrink-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{email.subject || '(no subject)'}
</span>
{inlinePreview && (
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
) : (
<>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{email.subject || "(no subject)"}
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
</div>
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)}
</>
)}
</div>
</div>
@@ -247,6 +299,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
@@ -280,8 +333,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
@@ -405,15 +461,19 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
onClick={handleHeaderClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
<div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox for thread selection - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleThreadCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
@@ -428,7 +488,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</button>
)}
{!isMobile && (
{!isMobile && !isFocusedMailLayout && (
<button
data-expand-toggle
onClick={(e) => {
@@ -460,7 +520,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
)}
{density !== 'extra-compact' && (
{!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar
name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email}
@@ -470,88 +530,143 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
hasUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{participantNames.join(", ")}
</span>
<span
className={cn(
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{hasStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{hasAnswered && !hasForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasForwarded && !hasAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-44',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{participantNames.join(', ')}
</span>
<span
className={cn(
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'shrink-0 truncate',
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{latestEmail.subject || '(no subject)'}
</span>
{inlinePreview && (
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{hasAnswered && hasForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
<span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
) : (
<>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
hasUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{participantNames.join(", ")}
</span>
<span
className={cn(
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{hasStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{hasAnswered && !hasForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasForwarded && !hasAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasAnswered && hasForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
</div>
{latestEmail.subject || "(no subject)"}
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
</div>
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
hasUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{latestEmail.preview || "No preview available"}
</p>
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
hasUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{latestEmail.preview || "No preview available"}
</p>
)}
</>
)}
</div>
</div>
@@ -559,6 +674,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{/* Hover Quick Actions for thread header */}
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
@@ -568,7 +684,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
/>
</div>
{isExpanded && !isMobile && (
{isExpanded && !isMobile && !isFocusedMailLayout && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
+141 -14
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield } from "lucide-react";
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check } from "lucide-react";
import { AccountSwitcher } from "./account-switcher";
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
@@ -16,8 +16,11 @@ import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { getInitials } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
interface NavItem {
id: string;
@@ -164,11 +167,55 @@ export function NavigationRail({
const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore();
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
// Account list for rail
const accounts = useAccountStore((s) => s.accounts);
const activeAccountId = useAccountStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
const logoutAll = useAuthStore((s) => s.logoutAll);
const [logoutMenuOpen, setLogoutMenuOpen] = useState(false);
const logoutBtnRef = useRef<HTMLButtonElement>(null);
const logoutPopoverRef = useRef<HTMLDivElement>(null);
const [logoutPopoverStyle, setLogoutPopoverStyle] = useState<React.CSSProperties>({});
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const updateLogoutPosition = useCallback(() => {
if (!logoutBtnRef.current) return;
const rect = logoutBtnRef.current.getBoundingClientRect();
setLogoutPopoverStyle({
position: "fixed",
left: rect.right + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
});
}, []);
useEffect(() => {
if (!logoutMenuOpen) return;
updateLogoutPosition();
const handleClickOutside = (e: MouseEvent) => {
if (
logoutBtnRef.current?.contains(e.target as Node) ||
logoutPopoverRef.current?.contains(e.target as Node)
) return;
setLogoutMenuOpen(false);
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setLogoutMenuOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [logoutMenuOpen, updateLogoutPosition]);
useEffect(() => {
let cancelled = false;
const { client } = useAuthStore.getState();
@@ -504,6 +551,22 @@ export function NavigationRail({
<Keyboard className="w-[18px] h-[18px]" />
</button>
)}
{!onShowShortcuts && (
<>
<button
onClick={() => setShowShortcutsModal(true)}
data-tour="nav-shortcuts"
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("keyboard_shortcuts")}
>
<Keyboard className="w-[18px] h-[18px]" />
</button>
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
</>
)}
{quota && quota.total > 0 && (
<div data-tour="storage-quota">
@@ -511,21 +574,85 @@ export function NavigationRail({
</div>
)}
{isPushConnected != null && (
<span
className="relative group"
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
>
<span
className={cn(
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
)}
/>
</span>
{onLogout && showRailAccountList && accounts.length > 0 && (
<>
<div className="w-8 border-t my-1" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
{/* Account circles */}
<div className="flex flex-col items-center gap-3">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
const initials = getInitials(account.displayName || account.label, account.email || account.username);
return (
<button
key={account.id}
onClick={() => {
if (!isActive) switchAccount(account.id);
}}
className={cn(
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
isActive
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "opacity-70 hover:opacity-100"
)}
style={{ backgroundColor: account.avatarColor }}
title={`${account.displayName || account.label} (${account.email || account.username})`}
>
{initials}
{isActive && (
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2 h-2 text-primary-foreground" />
</span>
)}
</button>
);
})}
</div>
{/* Logout button with popover */}
<button
ref={logoutBtnRef}
onClick={() => setLogoutMenuOpen(!logoutMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("sign_out")}
aria-expanded={logoutMenuOpen}
aria-haspopup="true"
>
<LogOut className="w-4 h-4" />
</button>
{logoutMenuOpen && createPortal(
<div
ref={logoutPopoverRef}
style={logoutPopoverStyle}
className="w-56 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
role="menu"
>
<button
onClick={() => { setLogoutMenuOpen(false); logout(); }}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
<LogOut className="w-4 h-4" />
{t("sign_out")}
</button>
{accounts.length > 1 && (
<button
onClick={() => { setLogoutMenuOpen(false); logoutAll(); }}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-destructive hover:bg-muted transition-colors"
role="menuitem"
>
<LogOut className="w-4 h-4" />
{t("sign_out_all")}
</button>
)}
</div>,
document.body
)}
</>
)}
{onLogout && (
{onLogout && !showRailAccountList && (
<AccountSwitcher variant="rail" />
)}
</div>
+132 -37
View File
@@ -12,7 +12,6 @@ import {
Star,
Trash2,
Archive,
PenSquare,
ChevronsLeft,
ChevronsRight,
ChevronRight,
@@ -309,7 +308,7 @@ function TagItem({
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
{!isCollapsed && (
<>
<span className="truncate">{kw.label}</span>
<span className="flex-1 truncate">{kw.label}</span>
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
{unreadCount > 0 && (
<span className={cn(
@@ -428,9 +427,16 @@ export function Sidebar({
onUnreadFilterClick,
className,
}: SidebarProps) {
const router = useRouter();
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity: _primaryIdentity } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [foldersExpanded, setFoldersExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarFoldersExpanded');
return stored !== null ? JSON.parse(stored) : true;
} catch { return true; }
});
const [tagsExpanded, setTagsExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarTagsExpanded');
@@ -438,6 +444,7 @@ export function Sidebar({
} catch { return true; }
});
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher);
const tagCounts = useEmailStore(s => s.tagCounts);
const t = useTranslations('sidebar');
@@ -452,10 +459,17 @@ export function Sidebar({
}
} else {
const tree = buildMailboxTree(mailboxes);
const defaultExpanded = tree
.filter(node => node.children.length > 0)
.map(node => node.id);
setExpandedFolders(new Set(defaultExpanded));
const collectExpandable = (nodes: MailboxNode[]): string[] => {
const ids: string[] = [];
for (const node of nodes) {
if (node.children.length > 0) {
ids.push(node.id);
ids.push(...collectExpandable(node.children));
}
}
return ids;
};
setExpandedFolders(new Set(collectExpandable(tree)));
}
}, [mailboxes]);
@@ -539,7 +553,7 @@ export function Sidebar({
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
</Button>
{!isCollapsed && (
{!isCollapsed && !hideAccountSwitcher && (
<AccountSwitcher variant="expanded" className="flex-1" />
)}
</div>
@@ -553,24 +567,103 @@ export function Sidebar({
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
<div className="py-1">
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
{/* Folders Section Header */}
<div
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 font-medium",
isCollapsed ? "justify-center px-1" : "px-2",
"text-foreground hover:bg-muted"
)}
>
{!isCollapsed && (
<button
onClick={() => {
setFoldersExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
}}
className={cn(
"p-0.5 rounded mr-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
title={foldersExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
>
{foldersExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</button>
)}
<button
onClick={() => {
if (isCollapsed) return;
setFoldersExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
}}
className={cn(
"flex items-center px-1 rounded",
"transition-colors duration-150",
isCollapsed ? "justify-center" : "flex-1 text-left"
)}
style={{ paddingBlock: 'var(--density-sidebar-py)', ...(isCollapsed ? {} : { paddingLeft: '4px' }) }}
title={isCollapsed ? t("folders") : undefined}
>
<Folder className={cn(
"w-4 h-4 flex-shrink-0 transition-colors",
!isCollapsed && "mr-2",
foldersExpanded && "text-primary"
)} />
{!isCollapsed && (
<span className="flex-1 truncate">{t("folders")}</span>
)}
</button>
{!isCollapsed && (
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
title={t('settings')}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
{/* Folder Items */}
{((foldersExpanded && !isCollapsed) || isCollapsed) && (
<>
{mailboxTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
/>
))}
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
<>
{mailboxTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
/>
))}
</>
)}
</>
)}
</div>
@@ -635,6 +728,20 @@ export function Sidebar({
<span className="flex-1 truncate">{t("tags")}</span>
)}
</button>
{!isCollapsed && (
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
title={t('settings')}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
@@ -661,19 +768,7 @@ export function Sidebar({
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div>
{/* Compose Button */}
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
{isCollapsed ? (
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")} data-tour="compose-button">
<PenSquare className="w-5 h-5" />
</Button>
) : (
<Button onClick={onCompose} className="w-full" title={t("compose_hint")} data-tour="compose-button">
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
)}
</div>
</div>
);
}
+17 -1
View File
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
@@ -145,6 +145,22 @@ export function AppearanceSettings() {
/>
</SettingItem>
{/* Hide Account Switcher */}
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
<ToggleSwitch
checked={hideAccountSwitcher}
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
/>
</SettingItem>
{/* Show Rail Account List */}
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
<ToggleSwitch
checked={showRailAccountList}
onChange={(checked) => updateSetting('showRailAccountList', checked)}
/>
</SettingItem>
{/* Animations */}
{!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
@@ -164,6 +164,7 @@ export function CalendarManagementSettings() {
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const tImport = useTranslations('calendar.import');
@@ -629,6 +630,14 @@ export function CalendarManagementSettings() {
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingSubscription(sub)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={tSub('edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleRefreshSubscription(sub.id)}
@@ -667,6 +676,14 @@ export function CalendarManagementSettings() {
onClose={() => setShowSubscriptionModal(false)}
/>
)}
{editingSubscription && client && (
<ICalSubscriptionModal
client={client}
editSubscription={editingSubscription}
onClose={() => setEditingSubscription(null)}
/>
)}
</SettingsSection>
);
}
+18
View File
@@ -19,6 +19,7 @@ export function CalendarSettings() {
showWeekNumbers,
enableCalendarTasks,
showTasksOnCalendar,
calendarHoverPreview,
updateSetting,
} = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore();
@@ -80,6 +81,23 @@ export function CalendarSettings() {
/>
</SettingItem>
<SettingItem
label={t('hover_preview')}
description={t('hover_preview_desc')}
>
<Select
value={calendarHoverPreview}
onChange={(value) => updateSetting('calendarHoverPreview', value as 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s')}
options={[
{ value: 'instant', label: t('hover_preview_instant') },
{ value: 'delay-500ms', label: t('hover_preview_delay_500ms') },
{ value: 'delay-1s', label: t('hover_preview_delay_1s') },
{ value: 'delay-2s', label: t('hover_preview_delay_2s') },
{ value: 'off', label: t('hover_preview_off') },
]}
/>
</SettingItem>
{isFeatureEnabled('calendarTasksEnabled') && (
<>
<SettingItem
+110 -3
View File
@@ -4,16 +4,92 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction, MailLayout } from '@/stores/settings-store';
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
];
function MailLayoutPreview({
value,
t,
}: {
value: MailLayout;
t: (key: string) => string;
}) {
const isSplit = value === 'split';
return (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate font-medium text-foreground">{row.sender}</div>
<div className="truncate text-muted-foreground">{row.subject}</div>
</div>
))}
</div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2.5 w-20 rounded bg-foreground/10" />
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'rounded-md px-2 py-1 text-[10px]',
row.selected ? 'bg-primary/10' : 'bg-muted/20'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
<span className="text-muted-foreground/80">{row.preview}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
export function EmailSettings() {
const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
@@ -39,7 +115,9 @@ export function EmailSettings() {
deleteAction,
permanentlyDeleteJunk,
showPreview,
mailLayout,
disableThreading,
autoSelectReplyIdentity,
plainTextMode,
emailsPerPage,
externalContentPolicy,
@@ -62,6 +140,8 @@ export function EmailSettings() {
return t('trusted_senders.count_other', { count });
};
const isFocusedLayout = mailLayout === 'focus';
const handleReorganizeArchive = async () => {
const { client } = useAuthStore.getState();
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
@@ -207,9 +287,29 @@ export function EmailSettings() {
/>
</SettingItem>
{!isSettingHidden('mailLayout') && (
<SettingItem label={t('mail_layout.label')} description={t('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
<div className="w-[22rem] max-w-full">
<RadioGroup
value={mailLayout}
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
options={[
{ value: 'split', label: t('mail_layout.split') },
{ value: 'focus', label: t('mail_layout.focus') },
]}
/>
<MailLayoutPreview value={mailLayout} t={t} />
</div>
</SettingItem>
)}
{/* Show Preview */}
{!isSettingHidden('showPreview') && (
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')} locked={isSettingLocked('showPreview')}>
<SettingItem
label={t('show_preview.label')}
description={isFocusedLayout ? t('show_preview.focus_description') : t('show_preview.description')}
locked={isSettingLocked('showPreview')}
>
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem>
)}
@@ -230,6 +330,13 @@ export function EmailSettings() {
/>
</SettingItem>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
/>
</SettingItem>
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
+4 -2
View File
@@ -19,6 +19,7 @@ import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-di
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
import { useSmimeStore } from "@/stores/smime-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAuthStore } from "@/stores/auth-store";
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
@@ -50,6 +51,7 @@ export function SmimeSettings() {
} = useSmimeStore();
const { identities } = useIdentityStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
// Local UI state
const [importDialogOpen, setImportDialogOpen] = useState(false);
@@ -75,8 +77,8 @@ export function SmimeSettings() {
const [exportError, setExportError] = useState<string | null>(null);
useEffect(() => {
load();
}, [load]);
load(activeAccountId ?? undefined);
}, [load, activeAccountId]);
// ── PKCS#12 import flow ────────────────────────────────────────
+50 -46
View File
@@ -174,64 +174,68 @@ interface ThemeCardProps {
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
return (
<button
onClick={onActivate}
disabled={disabled}
className={cn(
'relative flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
isActive
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-primary/40 bg-card',
disabled && !isActive && 'hover:border-border'
)}
>
{/* Preview / Placeholder */}
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
{preview ? (
<img src={preview} alt={name} className="w-full h-full object-cover" />
) : (
<Palette className="w-8 h-8 text-muted-foreground/40" />
<div className="relative">
<button
type="button"
onClick={onActivate}
disabled={disabled}
className={cn(
'flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
isActive
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-primary/40 bg-card',
disabled && !isActive && 'hover:border-border'
)}
</div>
{/* Info */}
<div className="w-full">
<div className="flex items-center justify-between gap-1">
<span className="text-sm font-medium text-foreground truncate">{name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{isForceEnabled && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium flex items-center gap-0.5" title="Admin enforced">
<Lock className="w-2.5 h-2.5" />
</span>
)}
{isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
)}
{isActive && <Check className="w-4 h-4 text-primary" />}
</div>
>
{/* Preview / Placeholder */}
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
{preview ? (
<img src={preview} alt={name} className="w-full h-full object-cover" />
) : (
<Palette className="w-8 h-8 text-muted-foreground/40" />
)}
</div>
<span className="text-xs text-muted-foreground truncate block">{author}</span>
{variants && (
<div className="flex gap-1 mt-1">
{variants.map(v => (
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{v}
</span>
))}
{/* Info */}
<div className="w-full">
<div className="flex items-center justify-between gap-1">
<span className="text-sm font-medium text-foreground truncate">{name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{isForceEnabled && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium flex items-center gap-0.5" title="Admin enforced">
<Lock className="w-2.5 h-2.5" />
</span>
)}
{isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
)}
{isActive && <Check className="w-4 h-4 text-primary" />}
</div>
</div>
)}
</div>
<span className="text-xs text-muted-foreground truncate block">{author}</span>
{variants && (
<div className="flex gap-1 mt-1">
{variants.map(v => (
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{v}
</span>
))}
</div>
)}
</div>
</button>
{/* Remove button */}
{onRemove && !isActive && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
type="button"
onClick={onRemove}
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Remove theme"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</button>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTotpReauthStore } from "@/stores/totp-reauth-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Shield } from "lucide-react";
/**
* Modal dialog that prompts the user for a fresh TOTP code when their
* 2FA session expires (TOTP rotates every ~30 seconds).
*
* Rendered once at the app root level. The JMAP client triggers it via
* the useTotpReauthStore when a 401 is received on a TOTP-authenticated session.
*/
export function TotpReauthDialog() {
const { isOpen, submit, cancel } = useTotpReauthStore();
const [code, setCode] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: cancel,
restoreFocus: true,
});
useEffect(() => {
if (isOpen) {
setCode("");
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (code.length >= 6) {
submit(code);
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={cancel} />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Two-factor authentication required"
className="relative z-10 w-full max-w-sm mx-4 bg-background rounded-2xl shadow-xl border border-border p-6"
>
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
<Shield className="w-5 h-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Session Expired</h2>
<p className="text-sm text-muted-foreground">Your 2FA code has rotated</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-4">
Enter a fresh authentication code from your authenticator app to continue.
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mb-4 leading-relaxed">
To avoid being prompted repeatedly, ask your administrator to enable OAuth authentication
(either Stalwart&apos;s built-in OAuth or an external identity provider).
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
ref={inputRef}
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
className="h-12 text-center font-mono tracking-widest text-lg bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder="000000"
autoComplete="one-time-code"
aria-label="Authentication code"
/>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={cancel}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={code.length < 6}
>
Verify
</Button>
</div>
</form>
</div>
</div>
);
}
+1
View File
@@ -71,6 +71,7 @@ export default [
".next/**",
"node_modules/**",
"repos/**",
"data/admin/plugins/**",
"*.config.js",
"*.config.mjs",
"e2e/**",
+4
View File
@@ -25,6 +25,7 @@ interface ConfigData {
loginWebsiteUrl: string;
demoMode: boolean;
autoSsoEnabled: boolean;
allowCustomJmapEndpoint: boolean;
embeddedMode: boolean;
parentOrigin: string;
}
@@ -100,6 +101,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
demoMode: configCache?.demoMode || false,
autoSsoEnabled: configCache?.autoSsoEnabled || false,
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
embeddedMode: configCache?.embeddedMode || false,
parentOrigin: configCache?.parentOrigin || '',
isLoading: !configCache,
@@ -131,6 +133,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: configCache.loginWebsiteUrl,
demoMode: configCache.demoMode,
autoSsoEnabled: configCache.autoSsoEnabled,
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
embeddedMode: configCache.embeddedMode,
parentOrigin: configCache.parentOrigin,
isLoading: false,
@@ -163,6 +166,7 @@ export function useConfig(): AppConfig {
loginWebsiteUrl: data.loginWebsiteUrl,
demoMode: data.demoMode,
autoSsoEnabled: data.autoSsoEnabled,
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
embeddedMode: data.embeddedMode,
parentOrigin: data.parentOrigin,
isLoading: false,
@@ -45,4 +45,43 @@ describe('calendar event normalization', () => {
showWithoutTime: true,
});
});
describe('recurrenceRule normalization (JSCalendar 2.0 singular→plural)', () => {
it('wraps a single recurrenceRule object in an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' },
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]);
expect((result as Record<string, unknown>).recurrenceRule).toBeUndefined();
});
it('passes through recurrenceRule when already an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: [{ '@type': 'RecurrenceRule', frequency: 'daily' }],
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
});
it('passes through null recurrenceRule as-is', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: null,
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toBeNull();
});
it('wraps a single excludedRecurrenceRule object in an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
excludedRecurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.excludedRecurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
});
});
});
+109 -1
View File
@@ -1,11 +1,16 @@
import { describe, expect, it } from 'vitest';
import type { CalendarEvent } from '@/lib/jmap/types';
import {
buildTimedFullDayWeekSegments,
buildWeekSegments,
buildAllDayDuration,
getEventDayBounds,
getEventDisplayEndDate,
getEventEndDate,
getEventStartDate,
getTimedEventBoundsForDay,
isTimedEventFullDayOnDate,
layoutOverlappingEvents,
normalizeAllDayDuration,
} from '../calendar-utils';
@@ -92,7 +97,110 @@ describe('calendar-utils all-day handling', () => {
utcEnd: '2026-03-14T11:00:00Z',
});
expectLocalDateParts(getEventDisplayEndDate(event), 2026, 3, 14, 11);
expect(getEventDisplayEndDate(event).toISOString()).toBe('2026-03-14T11:00:00.000Z');
});
it('prefers utc timestamps for timed events with an event timezone', () => {
const event = makeEvent({
start: '2026-03-15T09:00:00',
duration: 'PT1H',
timeZone: 'America/New_York',
showWithoutTime: false,
utcStart: '2026-03-15T13:00:00Z',
utcEnd: '2026-03-15T14:00:00Z',
});
expect(getEventStartDate(event).toISOString()).toBe('2026-03-15T13:00:00.000Z');
expect(getEventEndDate(event).toISOString()).toBe('2026-03-15T14:00:00.000Z');
});
it('clips timed multi-day events to the visible day bounds', () => {
const event = makeEvent({
start: '2026-03-14T22:00:00',
duration: 'PT4H',
showWithoutTime: false,
utcStart: '2026-03-14T22:00:00Z',
utcEnd: '2026-03-15T02:00:00Z',
});
expect(getTimedEventBoundsForDay(event, new Date('2026-03-14T00:00:00Z'))).toMatchObject({
startMinutes: 1380,
endMinutes: 1440,
continuesBefore: false,
continuesAfter: true,
});
expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({
startMinutes: 0,
endMinutes: 180,
continuesBefore: true,
continuesAfter: false,
});
});
it('lays out continued timed events using clipped bounds for the active day', () => {
const event = makeEvent({
start: '2026-03-14T22:00:00',
duration: 'PT4H',
showWithoutTime: false,
utcStart: '2026-03-14T22:00:00Z',
utcEnd: '2026-03-15T02:00:00Z',
});
const layout = layoutOverlappingEvents([event], new Date('2026-03-15T00:00:00Z'));
expect(layout).toHaveLength(1);
expect(layout[0]).toMatchObject({
startMinutes: 0,
endMinutes: 180,
column: 0,
totalColumns: 1,
continuesBefore: true,
continuesAfter: false,
});
});
it('detects when a timed multi-day event fully occupies an intermediate day', () => {
const event = makeEvent({
start: '2026-03-14T12:00:00',
duration: 'PT48H',
showWithoutTime: false,
utcStart: '2026-03-14T12:00:00Z',
utcEnd: '2026-03-16T12:00:00Z',
});
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-15T00:00:00Z'))).toBe(true);
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-14T00:00:00Z'))).toBe(false);
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-16T00:00:00Z'))).toBe(false);
});
it('creates week-bar segments for timed events that fully cover visible days', () => {
const week = [
new Date('2026-03-14T00:00:00Z'),
new Date('2026-03-15T00:00:00Z'),
new Date('2026-03-16T00:00:00Z'),
new Date('2026-03-17T00:00:00Z'),
new Date('2026-03-18T00:00:00Z'),
new Date('2026-03-19T00:00:00Z'),
new Date('2026-03-20T00:00:00Z'),
];
const event = makeEvent({
start: '2026-03-14T12:00:00',
duration: 'PT72H',
showWithoutTime: false,
utcStart: '2026-03-14T12:00:00Z',
utcEnd: '2026-03-17T12:00:00Z',
});
const segments = buildTimedFullDayWeekSegments([event], week);
expect(segments).toHaveLength(1);
expect(segments[0]).toMatchObject({
startIndex: 1,
span: 2,
continuesBefore: false,
continuesAfter: false,
});
});
it('normalizes imported all-day durations to day units', () => {
+238
View File
@@ -0,0 +1,238 @@
import { describe, it, expect } from 'vitest';
// ─── Inline copies of the plugin helpers (pure functions, no deps) ──────────
// These mirror the implementations in repos/plugins/jitsi-meet/src/index.js
// so we can unit-test them without esbuild bundling.
function generateRoomName(eventTitle: string): string {
const slug = eventTitle
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60);
const suffix = crypto.randomUUID().slice(0, 8);
return slug ? `${slug}-${suffix}` : suffix;
}
function buildMeetingUrl(jitsiUrl: string, roomName: string): string {
const base = jitsiUrl.replace(/\/+$/, '');
return `${base}/${encodeURIComponent(roomName)}`;
}
function base64url(input: string | ArrayBuffer): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function createJitsiJwt(options: {
secret: string;
roomName: string;
userEmail?: string;
userName?: string;
jitsiUrl: string;
}): Promise<string> {
const { secret, roomName, userEmail, userName, jitsiUrl } = options;
let domain: string;
try {
domain = new URL(jitsiUrl).hostname;
} catch {
domain = jitsiUrl;
}
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'HS256', typ: 'JWT' };
const payload: Record<string, unknown> = {
iss: 'bulwark-webmail',
sub: domain,
aud: 'jitsi',
room: roomName,
iat: now,
exp: now + 86400,
context: {
user: {
...(userName ? { name: userName } : {}),
...(userEmail ? { email: userEmail } : {}),
},
},
};
const enc = new TextEncoder();
const headerB64 = base64url(JSON.stringify(header));
const payloadB64 = base64url(JSON.stringify(payload));
const signingInput = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const signature = await crypto.subtle.sign('HMAC', key, enc.encode(signingInput));
const signatureB64 = base64url(signature);
return `${signingInput}.${signatureB64}`;
}
// ─── Tests ──────────────────────────────────────────────────────
describe('generateRoomName', () => {
it('should slugify the event title and append a random suffix', () => {
const room = generateRoomName('Team Standup');
expect(room).toMatch(/^team-standup-[a-f0-9]{8}$/);
});
it('should handle special characters', () => {
const room = generateRoomName('Q&A Session: "Ask Me Anything!"');
expect(room).toMatch(/^q-a-session-ask-me-anything-[a-f0-9]{8}$/);
});
it('should handle empty title', () => {
const room = generateRoomName('');
expect(room).toMatch(/^[a-f0-9]{8}$/);
});
it('should handle whitespace-only title', () => {
const room = generateRoomName(' ');
expect(room).toMatch(/^[a-f0-9]{8}$/);
});
it('should truncate long titles to 60 chars plus suffix', () => {
const longTitle = 'A'.repeat(100);
const room = generateRoomName(longTitle);
// 60 chars of slug + '-' + 8 char suffix = 69 max
expect(room.length).toBeLessThanOrEqual(69);
});
it('should generate unique room names for the same title', () => {
const room1 = generateRoomName('Standup');
const room2 = generateRoomName('Standup');
expect(room1).not.toBe(room2);
});
});
describe('buildMeetingUrl', () => {
it('should combine base URL and room name', () => {
const url = buildMeetingUrl('https://meet.example.com', 'my-room-abc12345');
expect(url).toBe('https://meet.example.com/my-room-abc12345');
});
it('should strip trailing slashes from base URL', () => {
const url = buildMeetingUrl('https://meet.example.com/', 'room');
expect(url).toBe('https://meet.example.com/room');
});
it('should strip multiple trailing slashes', () => {
const url = buildMeetingUrl('https://meet.example.com///', 'room');
expect(url).toBe('https://meet.example.com/room');
});
it('should URL-encode the room name', () => {
const url = buildMeetingUrl('https://meet.example.com', 'room with spaces');
expect(url).toBe('https://meet.example.com/room%20with%20spaces');
});
});
describe('createJitsiJwt', () => {
it('should create a valid HS256 JWT', async () => {
const token = await createJitsiJwt({
secret: 'test-secret-key',
roomName: 'test-room',
userEmail: 'user@example.com',
userName: 'Test User',
jitsiUrl: 'https://meet.example.com',
});
const parts = token.split('.');
expect(parts).toHaveLength(3);
const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/')));
expect(header.alg).toBe('HS256');
expect(header.typ).toBe('JWT');
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
expect(payload.iss).toBe('bulwark-webmail');
expect(payload.sub).toBe('meet.example.com');
expect(payload.aud).toBe('jitsi');
expect(payload.room).toBe('test-room');
expect(payload.context.user.name).toBe('Test User');
expect(payload.context.user.email).toBe('user@example.com');
expect(payload.exp).toBe(payload.iat + 86400);
});
it('should set the sub claim to the Jitsi hostname', async () => {
const token = await createJitsiJwt({
secret: 'secret',
roomName: 'room',
jitsiUrl: 'https://jitsi.corp.example.com/subfolder',
});
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
expect(payload.sub).toBe('jitsi.corp.example.com');
});
it('should omit undefined user fields', async () => {
const token = await createJitsiJwt({
secret: 'secret',
roomName: 'room',
jitsiUrl: 'https://meet.example.com',
});
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
expect(payload.context.user.name).toBeUndefined();
expect(payload.context.user.email).toBeUndefined();
});
it('should produce a different signature with different secrets', async () => {
const token1 = await createJitsiJwt({
secret: 'secret-one',
roomName: 'room',
jitsiUrl: 'https://meet.example.com',
});
const token2 = await createJitsiJwt({
secret: 'secret-two',
roomName: 'room',
jitsiUrl: 'https://meet.example.com',
});
expect(token1.split('.')[2]).not.toBe(token2.split('.')[2]);
});
it('should produce a verifiable HMAC-SHA256 signature', async () => {
const secret = 'my-test-secret';
const token = await createJitsiJwt({
secret,
roomName: 'verify-room',
jitsiUrl: 'https://meet.example.com',
});
const [headerB64, payloadB64, signatureB64] = token.split('.');
const signingInput = `${headerB64}.${payloadB64}`;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const sigPadded = signatureB64.replace(/-/g, '+').replace(/_/g, '/');
const sigBinary = atob(sigPadded);
const sigBytes = new Uint8Array(sigBinary.length);
for (let i = 0; i < sigBinary.length; i++) {
sigBytes[i] = sigBinary.charCodeAt(i);
}
const valid = await crypto.subtle.verify('HMAC', key, sigBytes, enc.encode(signingInput));
expect(valid).toBe(true);
});
});
+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': [],
'context-menu-email': [],
'navigation-rail-bottom': [],
'calendar-event-actions': [],
'admin-plugin-page': [],
},
initialized: false,
});
+379
View File
@@ -0,0 +1,379 @@
import { describe, it, expect } from 'vitest';
import { expandRecurringEvents } from '../recurrence-expansion';
import type { CalendarEvent } from '@/lib/jmap/types';
/** Helper: create a minimal CalendarEvent for testing recurrence */
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'evt1',
uid: 'uid1',
calendarIds: { cal1: true },
start: '2025-01-06T09:00:00', // Monday
duration: 'PT1H',
title: 'Test Event',
showWithoutTime: false,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
...overrides,
} as CalendarEvent;
}
function expand(event: CalendarEvent, rangeStart: string, rangeEnd: string) {
return expandRecurringEvents([event], rangeStart, rangeEnd);
}
function starts(events: CalendarEvent[]) {
return events.map(e => e.start);
}
describe('expandRecurringEvents', () => {
it('passes through non-recurring events unchanged', () => {
const event = makeEvent();
const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('evt1');
});
// -----------------------------------------------------------------------
// Daily
// -----------------------------------------------------------------------
describe('daily frequency', () => {
it('expands daily events within range', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-07T09:00:00',
'2025-01-08T09:00:00',
]);
});
it('respects interval', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', interval: 2 } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-12T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-08T09:00:00',
'2025-01-10T09:00:00',
]);
});
it('respects count', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 3 } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
expect(result).toHaveLength(3);
});
it('respects until', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', until: '2025-01-08T09:00:00' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
expect(result).toHaveLength(3);
});
});
// -----------------------------------------------------------------------
// Weekly
// -----------------------------------------------------------------------
describe('weekly frequency', () => {
it('expands weekly with implicit byDay (same weekday as start)', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
// Jan 6 is Monday, so every Monday
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-13T09:00:00',
'2025-01-20T09:00:00',
'2025-01-27T09:00:00',
]);
});
it('expands weekly with explicit byDay (MWF)', () => {
const event = makeEvent({
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'weekly',
byDay: [{ day: 'mo' }, { day: 'we' }, { day: 'fr' }],
} as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-08T09:00:00',
'2025-01-10T09:00:00',
]);
});
it('expands weekly with interval=2', () => {
const event = makeEvent({
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'weekly',
interval: 2,
byDay: [{ day: 'mo' }],
} as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-02-03T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-20T09:00:00',
]);
});
});
// -----------------------------------------------------------------------
// Monthly
// -----------------------------------------------------------------------
describe('monthly frequency', () => {
it('expands monthly with implicit byMonthDay', () => {
const event = makeEvent({
start: '2025-01-15T10:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'monthly' } as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
expect(starts(result)).toEqual([
'2025-01-15T10:00:00',
'2025-02-15T10:00:00',
'2025-03-15T10:00:00',
]);
});
it('expands monthly with byMonthDay', () => {
const event = makeEvent({
start: '2025-01-01T08:00:00',
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byMonthDay: [1, 15],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-02-28T00:00:00');
expect(starts(result)).toEqual([
'2025-01-01T08:00:00',
'2025-01-15T08:00:00',
'2025-02-01T08:00:00',
'2025-02-15T08:00:00',
]);
});
it('expands monthly with negative byMonthDay (-1 = last day)', () => {
const event = makeEvent({
start: '2025-01-31T08:00:00',
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byMonthDay: [-1],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-01-31', '2025-02-28', '2025-03-31']);
});
it('expands monthly with byDay + nthOfPeriod (2nd Tuesday)', () => {
const event = makeEvent({
start: '2025-01-14T09:00:00', // 2nd Tuesday
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'tu', nthOfPeriod: 2 }],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-01-14', '2025-02-11', '2025-03-11']);
});
it('expands monthly with byDay nthOfPeriod=-1 (last Friday)', () => {
const event = makeEvent({
start: '2025-01-31T09:00:00', // last Friday of Jan
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-01-31', '2025-02-28', '2025-03-28']);
});
});
// -----------------------------------------------------------------------
// Yearly
// -----------------------------------------------------------------------
describe('yearly frequency', () => {
it('expands yearly on the same date', () => {
const event = makeEvent({
start: '2023-03-15T12:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'yearly' } as any],
});
const result = expand(event, '2023-01-01T00:00:00', '2026-01-01T00:00:00');
expect(starts(result)).toEqual([
'2023-03-15T12:00:00',
'2024-03-15T12:00:00',
'2025-03-15T12:00:00',
]);
});
it('expands yearly with byMonth and byDay (last Friday of November = Thanksgiving-ish)', () => {
const event = makeEvent({
start: '2025-11-28T09:00:00', // last Friday of Nov 2025
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'yearly',
byMonth: ['11'],
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
// Last Friday of November: 2025-11-28, 2026-11-27, 2027-11-26
expect(days).toEqual(['2025-11-28', '2026-11-27', '2027-11-26']);
});
it('expands yearly with byMonth + byMonthDay', () => {
const event = makeEvent({
start: '2025-07-04T00:00:00',
showWithoutTime: true,
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'yearly',
byMonth: ['7'],
byMonthDay: [4],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
expect(result).toHaveLength(3);
});
});
// -----------------------------------------------------------------------
// bySetPosition
// -----------------------------------------------------------------------
describe('bySetPosition', () => {
it('selects first and last from monthly byDay expansion', () => {
const event = makeEvent({
start: '2025-01-06T10:00:00',
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'mo' }, { day: 'tu' }, { day: 'we' }, { day: 'th' }, { day: 'fr' }],
bySetPosition: [1, -1], // first and last weekday of month
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-03-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
// Jan: first weekday = Jan 1 (Wed), last weekday = Jan 31 (Fri)
// Feb: first weekday = Feb 3 (Mon), last weekday = Feb 28 (Fri)
// But event starts Jan 6, so Jan 1 is before start → filtered out
expect(days).toContain('2025-01-31');
expect(days).toContain('2025-02-03');
expect(days).toContain('2025-02-28');
});
});
// -----------------------------------------------------------------------
// Recurrence overrides
// -----------------------------------------------------------------------
describe('recurrenceOverrides', () => {
it('applies overrides to matching occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceOverrides: {
'2025-01-07T09:00:00': { title: 'Modified' },
},
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
const modified = result.find(e => e.recurrenceId === '2025-01-07T09:00:00');
expect(modified?.title).toBe('Modified');
});
it('excludes occurrences marked as excluded', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceOverrides: {
'2025-01-07T09:00:00': { excluded: true } as any,
},
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
expect(result).toHaveLength(2);
expect(starts(result)).toEqual(['2025-01-06T09:00:00', '2025-01-08T09:00:00']);
});
it('adds RDATE-style overrides not generated by rules', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
recurrenceOverrides: {
'2025-01-08T09:00:00': { title: 'Extra Wednesday' }, // Not a Monday
},
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-14T00:00:00');
expect(result.some(e => e.recurrenceId === '2025-01-08T09:00:00')).toBe(true);
});
});
// -----------------------------------------------------------------------
// All-day events
// -----------------------------------------------------------------------
describe('all-day events', () => {
it('expands all-day weekly events', () => {
const event = makeEvent({
start: '2025-01-06T00:00:00',
showWithoutTime: true,
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
expect(result).toHaveLength(4); // 4 Mondays: 6, 13, 20, 27
});
});
// -----------------------------------------------------------------------
// Edge cases
// -----------------------------------------------------------------------
describe('edge cases', () => {
it('does not exceed 500 occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2030-01-01T00:00:00');
expect(result.length).toBeLessThanOrEqual(500);
});
it('handles invalid start date gracefully', () => {
const event = makeEvent({
start: 'invalid',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00');
expect(result).toHaveLength(0);
});
it('generates synthetic IDs for occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].id).toBe('evt1:2025-01-06T09:00:00');
expect(result[1].id).toBe('evt1:2025-01-07T09:00:00');
});
it('preserves originalId pointing to master', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].originalId).toBe('evt1');
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { findReplyIdentityId } from '../reply-identity';
import type { Identity } from '../jmap/types';
const identities: Identity[] = [
{
id: 'primary',
name: 'Harry Primary',
email: 'harry@primary.com',
mayDelete: false,
},
{
id: 'secondary',
name: 'Harry Secondary',
email: 'harry@secondary.com',
mayDelete: false,
},
];
describe('findReplyIdentityId', () => {
it('matches the identity that received the original message', () => {
const selected = findReplyIdentityId(identities, {
to: [{ email: 'harry@secondary.com' }],
});
expect(selected).toBe('secondary');
});
it('matches case-insensitively across recipients', () => {
const selected = findReplyIdentityId(identities, {
cc: [{ email: 'HARRY@PRIMARY.COM' }],
});
expect(selected).toBe('primary');
});
it('falls back to sub-address matching when needed', () => {
const selected = findReplyIdentityId(identities, {
to: [{ email: 'harry+news@secondary.com' }],
});
expect(selected).toBe('secondary');
});
it('returns null when no reply recipient matches an identity', () => {
const selected = findReplyIdentityId(identities, {
to: [{ email: 'other@example.com' }],
});
expect(selected).toBeNull();
});
});
+2
View File
@@ -11,6 +11,7 @@ import { useFilterStore } from '@/stores/filter-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store';
import { useSmimeStore } from '@/stores/smime-store';
// Minimal snapshot shapes — we only capture what we need
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -117,6 +118,7 @@ export function clearAllStores(): void {
useVacationStore.getState().clearState();
useCalendarStore.getState().clearState();
useFilterStore.getState().clearState();
useSmimeStore.getState().clearState();
}
/** Evict cached state for one account */
+81
View File
@@ -0,0 +1,81 @@
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
function getPluginConfigDir(): string {
return path.join(getAdminDir(), 'plugin-config');
}
function configPath(pluginId: string): string {
return path.join(getPluginConfigDir(), `${pluginId}.json`);
}
async function ensureDir(dir: string): Promise<void> {
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
}
/**
* Get all config for a plugin.
*/
export async function getPluginConfig(pluginId: string): Promise<Record<string, unknown>> {
try {
const raw = await readFile(configPath(pluginId), 'utf-8');
return JSON.parse(raw);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
logger.warn(`Failed to read plugin config for ${pluginId}`, {
error: error instanceof Error ? error.message : 'Unknown error',
});
return {};
}
}
/**
* Set a single config key for a plugin.
*/
export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise<void> {
const dir = getPluginConfigDir();
await ensureDir(dir);
const config = await getPluginConfig(pluginId);
config[key] = value;
const filePath = configPath(pluginId);
const tmpPath = filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
await rename(tmpPath, filePath);
}
/**
* Delete a single config key for a plugin.
*/
export async function deletePluginConfigKey(pluginId: string, key: string): Promise<void> {
const config = await getPluginConfig(pluginId);
delete config[key];
if (Object.keys(config).length === 0) {
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
return;
}
const dir = getPluginConfigDir();
await ensureDir(dir);
const filePath = configPath(pluginId);
const tmpPath = filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
await rename(tmpPath, filePath);
}
/**
* Delete all config for a plugin (used when uninstalling).
*/
export async function deleteAllPluginConfig(pluginId: string): Promise<void> {
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
}
+11
View File
@@ -17,6 +17,16 @@ function getThemesDir(): string {
// ─── Types ───────────────────────────────────────────────────
export interface PluginConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
label: string;
description?: string;
required?: boolean;
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
}
export interface ServerPlugin {
id: string;
name: string;
@@ -28,6 +38,7 @@ export interface ServerPlugin {
entrypoint: string;
enabled: boolean;
forceEnabled?: boolean;
configSchema?: Record<string, PluginConfigField>;
installedAt: string;
updatedAt: string;
}
+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: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
+46 -4
View File
@@ -74,15 +74,57 @@ export function isAllDayEventLike(event: Pick<Partial<CalendarEvent>, 'start' |
&& end.getMilliseconds() === 0;
}
/**
* Stalwart returns "recurrenceRule" (singular) instead of RFC 8984 "recurrenceRules" (plural).
* Normalize server responses to match the client's internal type.
*/
function normalizeStalwartPropertyNames<T extends Partial<CalendarEvent>>(event: T): T {
const raw = event as Record<string, unknown>;
let patched = false;
const updates: Partial<CalendarEvent> = {};
if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) {
// JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object,
// but Stalwart may also return it as an array (for JMAP-created events).
// Normalize both forms to our internal array type.
const val = raw.recurrenceRule;
if (val != null && !Array.isArray(val) && typeof val === 'object') {
updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules'];
} else {
updates.recurrenceRules = val as CalendarEvent['recurrenceRules'];
}
patched = true;
}
if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) {
const val = raw.excludedRecurrenceRule;
if (val != null && !Array.isArray(val) && typeof val === 'object') {
updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules'];
} else {
updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules'];
}
patched = true;
}
if (!patched) return event;
const result = { ...event, ...updates } as T;
delete (result as Record<string, unknown>).recurrenceRule;
delete (result as Record<string, unknown>).excludedRecurrenceRule;
return result;
}
export function normalizeCalendarEventLike<T extends Partial<CalendarEvent>>(event: T): T {
if (!isAllDayEventLike(event)) {
return event;
// First normalize Stalwart's singular property names to RFC 8984 plural forms
const normalized = normalizeStalwartPropertyNames(event);
if (!isAllDayEventLike(normalized)) {
return normalized;
}
return {
...event,
...normalized,
showWithoutTime: true,
duration: normalizeAllDayDurationValue(event.duration),
duration: normalizeAllDayDurationValue(normalized.duration),
} as T;
}
+149 -38
View File
@@ -1,4 +1,4 @@
import { differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
import { parseDuration } from "@/components/calendar/event-card";
import type { CalendarEvent } from "@/lib/jmap/types";
@@ -11,15 +11,63 @@ export interface CalendarWeekSegment {
continuesAfter: boolean;
}
export interface TimedEventLayout {
event: CalendarEvent;
column: number;
totalColumns: number;
startMinutes: number;
endMinutes: number;
continuesBefore: boolean;
continuesAfter: boolean;
}
export function getEventStartDate(
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
): Date {
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
return parseISO(source);
}
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
rawSegments.sort((left, right) => {
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
if (left.span !== right.span) return right.span - left.span;
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
return left.event.showWithoutTime ? -1 : 1;
}
const timeDiff = getEventStartDate(left.event).getTime() - getEventStartDate(right.event).getTime();
if (timeDiff !== 0) return timeDiff;
return (left.event.title || "").localeCompare(right.event.title || "");
});
const rowEndIndices: number[] = [];
return rawSegments.map((segment) => {
const segmentEndIndex = segment.startIndex + segment.span - 1;
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
if (row === -1) {
row = rowEndIndices.length;
rowEndIndices.push(segmentEndIndex);
} else {
rowEndIndices[row] = segmentEndIndex;
}
return { ...segment, row };
});
}
export function getEventEndDate(event: CalendarEvent): Date {
const start = parseISO(event.start);
if (!event.showWithoutTime && event.utcEnd) {
return parseISO(event.utcEnd);
}
const start = getEventStartDate(event);
if (!event.duration) return start;
return new Date(start.getTime() + parseDuration(event.duration) * 60000);
}
export function getEventDisplayEndDate(event: CalendarEvent): Date {
const end = getEventEndDate(event);
if (!event.showWithoutTime || end.getTime() <= parseISO(event.start).getTime()) {
const start = getEventStartDate(event);
if (!event.showWithoutTime || end.getTime() <= start.getTime()) {
return end;
}
return subMilliseconds(end, 1);
@@ -27,11 +75,44 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } {
return {
startDay: startOfDay(parseISO(event.start)),
startDay: startOfDay(getEventStartDate(event)),
endDay: startOfDay(getEventDisplayEndDate(event)),
};
}
export function getTimedEventBoundsForDay(
event: CalendarEvent,
day: Date,
): { startMinutes: number; endMinutes: number; continuesBefore: boolean; continuesAfter: boolean } | null {
if (event.showWithoutTime) return null;
const eventStart = getEventStartDate(event);
const eventEnd = getEventEndDate(event);
const dayStart = startOfDay(day);
const nextDayStart = addDays(dayStart, 1);
if (eventEnd <= dayStart || eventStart >= nextDayStart) {
return null;
}
const clippedStart = eventStart > dayStart ? eventStart : dayStart;
const clippedEnd = eventEnd < nextDayStart ? eventEnd : nextDayStart;
const startMinutes = Math.max(0, Math.floor((clippedStart.getTime() - dayStart.getTime()) / 60000));
const endMinutes = Math.min(1440, Math.ceil((clippedEnd.getTime() - dayStart.getTime()) / 60000));
return {
startMinutes,
endMinutes,
continuesBefore: eventStart < dayStart,
continuesAfter: eventEnd > nextDayStart,
};
}
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
const bounds = getTimedEventBoundsForDay(event, day);
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
}
export function normalizeAllDayDuration(duration: string | undefined): string | undefined {
if (!duration) return undefined;
const totalMinutes = parseDuration(duration);
@@ -44,7 +125,7 @@ export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
return `P${dayCount}D`;
}
export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
export function buildWeekSegmentsRaw(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
if (weekDays.length === 0) return [];
const weekStart = startOfDay(weekDays[0]);
@@ -71,59 +152,89 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca
} satisfies CalendarWeekSegment];
});
rawSegments.sort((left, right) => {
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
if (left.span !== right.span) return right.span - left.span;
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
return left.event.showWithoutTime ? -1 : 1;
return rawSegments;
}
export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
return packWeekSegments(buildWeekSegmentsRaw(events, weekDays));
}
export function buildTimedFullDayWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
if (weekDays.length === 0) return [];
const rawSegments = events.flatMap((event) => {
const fullDayIndices = weekDays
.map((day, index) => (isTimedEventFullDayOnDate(event, day) ? index : -1))
.filter((index) => index >= 0);
if (fullDayIndices.length === 0) {
return [];
}
const timeDiff = parseISO(left.event.start).getTime() - parseISO(right.event.start).getTime();
if (timeDiff !== 0) return timeDiff;
return (left.event.title || "").localeCompare(right.event.title || "");
const segments: CalendarWeekSegment[] = [];
let rangeStart = fullDayIndices[0];
let previousIndex = fullDayIndices[0];
const pushSegment = (startIndex: number, endIndex: number) => {
const startDay = weekDays[startIndex];
const endDay = weekDays[endIndex];
segments.push({
event,
startIndex,
span: endIndex - startIndex + 1,
row: -1,
continuesBefore: isTimedEventFullDayOnDate(event, addDays(startDay, -1)),
continuesAfter: isTimedEventFullDayOnDate(event, addDays(endDay, 1)),
});
};
for (let index = 1; index < fullDayIndices.length; index++) {
const currentIndex = fullDayIndices[index];
if (currentIndex !== previousIndex + 1) {
pushSegment(rangeStart, previousIndex);
rangeStart = currentIndex;
}
previousIndex = currentIndex;
}
pushSegment(rangeStart, previousIndex);
return segments;
});
const rowEndIndices: number[] = [];
return rawSegments.map((segment) => {
const segmentEndIndex = segment.startIndex + segment.span - 1;
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
if (row === -1) {
row = rowEndIndices.length;
rowEndIndices.push(segmentEndIndex);
} else {
rowEndIndices[row] = segmentEndIndex;
}
return { ...segment, row };
});
return packWeekSegments(rawSegments);
}
export function layoutOverlappingEvents(
events: CalendarEvent[],
): { event: CalendarEvent; column: number; totalColumns: number }[] {
const sorted = [...events].sort((a, b) => {
const diff = parseISO(a.start).getTime() - parseISO(b.start).getTime();
day: Date,
): TimedEventLayout[] {
const layoutInputs = events.flatMap((event) => {
const bounds = getTimedEventBoundsForDay(event, day);
return bounds ? [{ event, ...bounds }] : [];
});
const sorted = layoutInputs.sort((a, b) => {
const diff = a.startMinutes - b.startMinutes;
if (diff !== 0) return diff;
return parseDuration(b.duration) - parseDuration(a.duration);
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
});
const columns: { event: CalendarEvent; end: number }[][] = [];
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
const result: TimedEventLayout[] = [];
for (const event of sorted) {
const start = parseISO(event.start);
const startMin = start.getHours() * 60 + start.getMinutes();
const endMin = Math.min(1440, startMin + Math.max(15, parseDuration(event.duration)));
let placed = false;
for (let col = 0; col < columns.length; col++) {
if (columns[col].every(e => e.end <= startMin)) {
columns[col].push({ event, end: endMin });
result.push({ event, column: col, totalColumns: 0 });
if (columns[col].every(e => e.end <= event.startMinutes)) {
columns[col].push({ event: event.event, end: event.endMinutes });
result.push({ ...event, column: col, totalColumns: 0 });
placed = true;
break;
}
}
if (!placed) {
columns.push([{ event, end: endMin }]);
result.push({ event, column: columns.length - 1, totalColumns: 0 });
columns.push([{ event: event.event, end: event.endMinutes }]);
result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
}
}
+12
View File
@@ -41,6 +41,9 @@ export class DemoJMAPClient implements IJMAPClient {
getServerUrl(): string { return 'https://demo.example.com'; }
getAuthHeader(): string { return 'Bearer demo-token'; }
updateAccessToken(): void { /* no-op */ }
upgradeToBearer(): void { /* no-op */ }
enableTotpReauth(): void { /* no-op */ }
updateBasicAuth(): void { /* no-op */ }
getAccountId(): string { return 'demo-account'; }
getUsername(): string { return 'demo@example.com'; }
@@ -597,6 +600,15 @@ export class DemoJMAPClient implements IJMAPClient {
return full;
}
async batchCreateCalendarEvents(events: Partial<CalendarEvent>[]): Promise<{ created: CalendarEvent[]; failed: string[] }> {
const created: CalendarEvent[] = [];
for (const event of events) {
const full = await this.createCalendarEvent(event);
created.push(full);
}
return { created, failed: [] };
}
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
const event = this.data.calendarEvents.find(e => e.id === eventId);
if (!event) throw new Error('Event not found');
+4
View File
@@ -19,6 +19,9 @@ export interface IJMAPClient {
getServerUrl(): string;
getAuthHeader(): string;
updateAccessToken(token: string): void;
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void;
enableTotpReauth(basePassword: string, callback: () => Promise<string | null>): void;
updateBasicAuth(newPassword: string): void;
getAccountId(): string;
getUsername(): string;
@@ -193,6 +196,7 @@ export interface IJMAPClient {
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
batchCreateCalendarEvents(events: Partial<CalendarEvent>[], targetAccountId?: string): Promise<{ created: CalendarEvent[]; failed: string[] }>;
updateCalendarEvent(
eventId: string,
updates: Partial<CalendarEvent>,
+617 -44
View File
@@ -95,6 +95,24 @@ const EMAIL_LIST_PROPERTIES = [
"hasAttachment",
] 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 = [
'id',
'@type',
@@ -127,9 +145,9 @@ const CALENDAR_EVENT_PROPERTIES = [
'hideAttendees',
'recurrenceId',
'recurrenceIdTimeZone',
'recurrenceRules',
'recurrenceRule',
'recurrenceOverrides',
'excludedRecurrenceRules',
'excludedRecurrenceRule',
'useDefaultAlerts',
'alerts',
'locations',
@@ -140,6 +158,74 @@ const CALENDAR_EVENT_PROPERTIES = [
'isOrigin',
] 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 {
if (!event) {
return null;
@@ -194,9 +280,11 @@ export class JMAPClient implements IJMAPClient {
private serverUrl: string;
private username: string;
private password: string;
private basePassword: string = '';
private authHeader: string;
private authMode: 'basic' | 'bearer' = 'basic';
private onTokenRefresh?: () => Promise<string | null>;
private onTotpRequired?: () => Promise<string | null>;
private apiUrl: string = "";
private accountId: string = "";
private downloadUrl: string = "";
@@ -239,6 +327,29 @@ export class JMAPClient implements IJMAPClient {
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 {
return this.authHeader;
}
@@ -291,7 +402,21 @@ export class JMAPClient implements IJMAPClient {
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
response = await fetch(url, { ...init, headers: retryHeaders });
} 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 {
this.reconnecting = false;
}
@@ -335,6 +460,16 @@ export class JMAPClient implements IJMAPClient {
? 'Authentication failed - token may be expired'
: '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}`);
}
@@ -535,6 +670,27 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
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) => ({
id: mb.id,
originalId: undefined,
@@ -599,6 +755,17 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
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) => ({
id: isPrimary ? mb.id : `${accountId}:${mb.id}`,
originalId: mb.id,
@@ -649,6 +816,7 @@ export class JMAPClient implements IJMAPClient {
sort: [{ property: "receivedAt", isAscending: false }],
limit,
position,
calculateTotal: true,
}, "0"],
["Email/get", {
accountId: targetAccountId,
@@ -661,7 +829,12 @@ export class JMAPClient implements IJMAPClient {
const getResponse = response.methodResponses?.[1]?.[1];
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 hasMore = computeHasMore(position, emails.length, total, limit);
@@ -1181,6 +1354,7 @@ export class JMAPClient implements IJMAPClient {
sort: [{ property: "receivedAt", isAscending: false }],
limit,
position,
calculateTotal: true,
}, "0"],
["Email/get", {
accountId: targetAccountId,
@@ -1190,7 +1364,10 @@ export class JMAPClient implements IJMAPClient {
]);
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 hasMore = computeHasMore(position, emails.length, total, limit);
@@ -1217,6 +1394,7 @@ export class JMAPClient implements IJMAPClient {
sort: [{ property: "receivedAt", isAscending: false }],
limit,
position,
calculateTotal: true,
}, "0"],
["Email/get", {
accountId: targetAccountId,
@@ -1226,7 +1404,10 @@ export class JMAPClient implements IJMAPClient {
]);
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 hasMore = computeHasMore(position, emails.length, total, limit);
@@ -1601,19 +1782,28 @@ export class JMAPClient implements IJMAPClient {
}
let finalIdentityId = identityId;
if (!finalIdentityId) {
let identityReplyTo: EmailAddress[] | undefined;
{
const identityResponse = await this.request([
["Identity/get", { accountId: this.accountId }, "0"]
]);
finalIdentityId = this.accountId;
if (!finalIdentityId) {
finalIdentityId = this.accountId;
}
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) {
const target = fromEmail || this.username;
const matchingIdentity = identities.find((id) => id.email === target)
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
finalIdentityId = matchingIdentity?.id || identities[0].id;
if (!identityId) {
const target = fromEmail || this.username;
const matchingIdentity = identities.find((id) => id.email === target)
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
finalIdentityId = matchingIdentity?.id || identities[0].id;
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
const emailCreate: Record<string, unknown> = {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
@@ -3052,6 +3243,7 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
.filter((event) => !isTaskObject(event))
.map((event) => normalizeCalendarEventLike(event));
}
return [];
@@ -3112,10 +3304,9 @@ export class JMAPClient implements IJMAPClient {
filter,
limit: limit || 1000,
};
// Expand recurring events into individual occurrences when a date range is provided
if (filter.after || filter.before) {
queryArgs.expandRecurrences = true;
}
// NOTE: We do NOT use expandRecurrences because Stalwart returns synthetic
// IDs that cannot be used for CalendarEvent/set (update/destroy).
// Recurrence expansion is done client-side instead.
if (sort) {
queryArgs.sort = sort;
}
@@ -3131,6 +3322,7 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
.filter((event) => !isTaskObject(event))
.map((event) => normalizeCalendarEventLike(event));
}
return [];
@@ -3167,12 +3359,13 @@ export class JMAPClient implements IJMAPClient {
// Strip client-only shared fields before sending to JMAP
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.log('CalendarEvent/create outgoing payload', {
accountId,
sendSchedulingMessages,
event: getCalendarEventDebugSnapshot(cleanEvent),
eventKeys: Object.keys(cleanEvent),
});
const setArgs: Record<string, unknown> = {
@@ -3197,6 +3390,8 @@ export class JMAPClient implements IJMAPClient {
if (result.notCreated?.["new-event"]) {
const error = result.notCreated["new-event"];
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();
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");
}
/**
* 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(
eventId: string,
updates: Partial<CalendarEvent>,
@@ -3251,6 +3515,7 @@ export class JMAPClient implements IJMAPClient {
// Strip client-only shared fields before sending to JMAP
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> = {
accountId,
@@ -3262,20 +3527,33 @@ export class JMAPClient implements IJMAPClient {
setArgs.sendSchedulingMessages = sendSchedulingMessages;
}
debug.log('CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages });
const response = await this.request([
["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
const methodName = response.methodResponses?.[0]?.[0];
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]) {
const error = result.notUpdated[eventId];
debug.error('CalendarEvent/set notUpdated', { eventId, error });
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;
}
debug.error('CalendarEvent/set update unexpected response', { methodName, result });
throw new Error("Failed to update calendar event");
}
@@ -3322,20 +3600,33 @@ export class JMAPClient implements IJMAPClient {
setArgs.sendSchedulingMessages = sendSchedulingMessages;
}
debug.log('CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages });
const response = await this.request([
["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
const methodName = response.methodResponses?.[0]?.[0];
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]) {
const error = result.notDestroyed[eventId];
debug.error('CalendarEvent/set notDestroyed', { eventId, error });
throw new Error(error.description || "Failed to delete calendar event");
}
debug.log('CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed });
return;
}
debug.error('CalendarEvent/set destroy unexpected response', { methodName, result });
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) ───
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 {
const events = await this.getCalendarEvents(calendarIds, targetAccountId);
return events.filter((e) => {
const obj = e as unknown as Record<string, unknown>;
const type = obj['@type'];
// 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';
// Strategy 1: query with types filter (JMAP spec compliant)
const filter: Record<string, unknown> = { types: ['Task'] };
if (calendarIds && calendarIds.length > 0) {
filter.inCalendars = calendarIds;
}
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;
}
return task;
});
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) {
console.error('Failed to get calendar tasks:', error);
debug.error('CalendarTask/fetch failed', error);
debug.groupEnd();
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> {
const event = { ...task, '@type': 'Task' } as unknown as Partial<CalendarEvent>;
const created = await this.createCalendarEvent(event, false, targetAccountId);
return created as unknown as CalendarTask;
const accountId = targetAccountId || this.getCalendarsAccountId();
const { '@type': _type, ...taskData } = task;
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> {
@@ -3682,6 +4182,10 @@ export class JMAPClient implements IJMAPClient {
private pollingStates: { [key: string]: string } = {};
private sseAbortController: AbortController | 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> = {
'Mailbox/get': 'Mailbox',
@@ -3693,6 +4197,7 @@ export class JMAPClient implements IJMAPClient {
private static readonly POLLING_INTERVAL = 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 {
const eventSourceUrl = this.getEventSourceUrl();
@@ -3701,6 +4206,7 @@ export class JMAPClient implements IJMAPClient {
} else {
this.startPollingFallback();
}
this.setupBrowserEventListeners();
return true;
}
@@ -3741,11 +4247,16 @@ export class JMAPClient implements IJMAPClient {
const decoder = new TextDecoder();
let buffer = '';
this.lastSSEActivity = Date.now();
this.startSSEPingMonitor();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
this.lastSSEActivity = Date.now();
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
@@ -3758,6 +4269,8 @@ export class JMAPClient implements IJMAPClient {
if (error instanceof DOMException && error.name === 'AbortError') return;
}
this.stopSSEPingMonitor();
// Stream ended — reconnect unless we were intentionally closed
if (this.sseAbortController && !this.sseAbortController.signal.aborted) {
this.scheduleSSEReconnect();
@@ -3930,10 +4443,70 @@ export class JMAPClient implements IJMAPClient {
this.eventSource.close();
this.eventSource = null;
}
this.stopSSEPingMonitor();
this.cleanupBrowserEventListeners();
this.stateChangeCallback = null;
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 {
this.connectionChangeCallback = callback;
}
+1
View File
@@ -660,6 +660,7 @@ export interface CalendarEventFilter {
attendee?: string;
participationStatus?: string;
uid?: string;
types?: string[];
}
// JMAP Push Notification Types (RFC 8620 Section 7)
+81 -1
View File
@@ -11,11 +11,13 @@ import type {
SidebarWidget,
ContextMenuItem,
KeyboardShortcut,
AdminPageSection,
CalendarEventAction,
SlotName,
} from './plugin-types';
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
import {
emailHooks, calendarHooks, contactHooks, fileHooks,
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
@@ -116,6 +118,8 @@ export interface PluginAPI {
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
registerCalendarEventAction: (action: CalendarEventAction) => Disposable;
registerAdminPage: (page: AdminPageSection) => Disposable;
};
hooks: PluginHooksAPI;
toast: {
@@ -126,6 +130,12 @@ export interface PluginAPI {
};
storage: ReturnType<typeof createPluginStorage>;
log: ReturnType<typeof createPluginLogger>;
admin: {
getConfig: (key: string) => Promise<unknown>;
getAllConfig: () => Promise<Record<string, unknown>>;
setConfig: (key: string, value: unknown) => Promise<void>;
deleteConfig: (key: string) => Promise<void>;
};
}
// Simplified hooks API type (all hooks return Disposable)
@@ -176,6 +186,9 @@ export interface PluginHooksAPI {
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
// Calendar Form
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
// Contacts
onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -321,6 +334,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write',
@@ -407,6 +421,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(emailHooks)),
// Calendar
...Object.fromEntries(Object.entries(calendarHooks)),
// Calendar Form
...Object.fromEntries(Object.entries(calendarFormHooks)),
// Contacts
...Object.fromEntries(Object.entries(contactHooks)),
// Files
@@ -581,6 +597,38 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
requirePermission(plugin, 'ui:navigation-rail');
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100);
},
registerCalendarEventAction: (action: CalendarEventAction) => {
requirePermission(plugin, 'ui:calendar-action');
const Component = (props: Record<string, unknown>) => {
const externals = getPluginExternals();
const React = externals?.React;
if (!React) return null;
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
const iconSpan = createElement('span', {
'aria-hidden': 'true',
style: { display: 'contents' },
dangerouslySetInnerHTML: {
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="8" width="14" height="12" rx="2"/></svg>',
},
});
return createElement('button', {
onClick: () => action.onClick(
props.eventData as import('./plugin-types').CalendarEventFormView,
{ setVirtualLocation: props.setVirtualLocation as (url: string) => void },
),
className: 'inline-flex items-center gap-1.5 h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background cursor-pointer',
title: action.label,
type: 'button',
}, iconSpan, action.label);
};
return registerSlot(plugin.id, 'calendar-event-actions', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
},
registerAdminPage: (page: AdminPageSection) => {
requirePermission(plugin, 'ui:admin-page');
return registerSlot(plugin.id, 'admin-plugin-page', page.render as React.ComponentType<Record<string, unknown>>, 100);
},
},
hooks,
@@ -594,5 +642,37 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
storage: createPluginStorage(plugin.id),
log: createPluginLogger(plugin.id),
admin: {
getConfig: async (key: string) => {
requirePermission(plugin, 'admin:config');
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
if (!res.ok) return null;
const data = await res.json();
return data[key] ?? null;
},
getAllConfig: async () => {
requirePermission(plugin, 'admin:config');
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
if (!res.ok) return {};
return res.json();
},
setConfig: async (key: string, value: unknown) => {
requirePermission(plugin, 'admin:config');
await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
},
deleteConfig: async (key: string) => {
requirePermission(plugin, 'admin:config');
await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
},
},
};
}
+7 -1
View File
@@ -218,6 +218,12 @@ export const calendarHooks = {
onCalendarAlertAcknowledge: new HookBus(),
};
// §7.2b Calendar Form Hooks (UI integration)
export const calendarFormHooks = {
onCalendarEventFormOpen: new HookBus(),
onCalendarEventFormSave: new HookBus(),
};
// §7.3 Contact Hooks
export const contactHooks = {
onContactOpen: new HookBus(),
@@ -396,7 +402,7 @@ export const sidebarAppHooks = {
// ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [
emailHooks, calendarHooks, contactHooks, fileHooks,
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
+31 -1
View File
@@ -94,7 +94,9 @@ export type SlotName =
| 'email-detail-sidebar'
| 'settings-section'
| 'context-menu-email'
| 'navigation-rail-bottom';
| 'navigation-rail-bottom'
| 'calendar-event-actions'
| 'admin-plugin-page';
export interface SlotRegistration {
pluginId: string;
@@ -147,6 +149,32 @@ export interface ContextMenuItem {
order?: number;
}
export interface AdminPageSection {
id: string;
label: string;
icon?: string;
render: React.ComponentType;
}
export interface CalendarEventAction {
id: string;
label: string;
icon?: string;
onClick: (eventData: CalendarEventFormView, helpers: { setVirtualLocation: (url: string) => void }) => void;
order?: number;
}
export interface CalendarEventFormView {
title: string;
description: string;
start: string;
end: string;
isAllDay: boolean;
location: string;
virtualLocation: string;
calendarId: string;
}
export interface KeyboardShortcut {
id: string;
keys: string;
@@ -377,6 +405,8 @@ export const ALL_PERMISSIONS = [
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
'ui:calendar-action', 'ui:admin-page',
'admin:config',
'app:lifecycle',
] as const;
+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();
});
});
describe('accountId filtering', () => {
it('listKeyRecords filters by accountId', async () => {
const id1 = uid();
const id2 = uid();
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
const acct1Records = await listKeyRecords('acct-1');
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
expect(acct1Records.find(r => r.id === id2)).toBeUndefined();
});
it('listKeyRecords includes records without accountId when filtering', async () => {
const id1 = uid();
const id2 = uid();
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com` }));
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
const acct1Records = await listKeyRecords('acct-1');
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
expect(acct1Records.find(r => r.id === id2)).toBeDefined();
});
it('listPublicCerts filters by accountId', async () => {
const id1 = uid();
const id2 = uid();
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
const acct1Certs = await listPublicCerts('acct-1');
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
expect(acct1Certs.find(c => c.id === id2)).toBeUndefined();
});
it('listPublicCerts includes certs without accountId when filtering', async () => {
const id1 = uid();
const id2 = uid();
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com` }));
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
const acct1Certs = await listPublicCerts('acct-1');
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
expect(acct1Certs.find(c => c.id === id2)).toBeDefined();
});
});
});
+3 -1
View File
@@ -57,7 +57,9 @@ beforeEach(() => {
defaultSignIdentity: {},
defaultEncrypt: false,
rememberUnlockedKeys: false,
autoImportSignerCerts: false,
autoImportSignerCerts: true,
accountPreferences: {},
currentAccountId: null,
isLoading: false,
error: null,
});
+6
View File
@@ -274,6 +274,12 @@ export function getLinerCryptoEngine(): pkijs.CryptoEngine {
return linerEngine!;
}
/** Get the webcrypto-liner Crypto instance (for importKey with legacy algorithms). */
export function getLinerCrypto(): Crypto {
ensureLiner();
return linerCryptoInstance!;
}
/**
* Run an async operation with the global PKI.js engine set to webcrypto-liner,
* then restore the previous engine afterwards.
+26 -9
View File
@@ -1,22 +1,35 @@
import type { SmimeKeyRecord, SmimePublicCert } from './types';
const DB_NAME = 'smime-store';
const DB_VERSION = 1;
const DB_VERSION = 2;
const KEY_RECORDS_STORE = 'key-records';
const PUBLIC_CERTS_STORE = 'public-certs';
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains(KEY_RECORDS_STORE)) {
const oldVersion = event.oldVersion;
if (oldVersion < 1) {
const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
keyStore.createIndex('email', 'email', { unique: false });
}
if (!db.objectStoreNames.contains(PUBLIC_CERTS_STORE)) {
keyStore.createIndex('accountId', 'accountId', { unique: false });
const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
certStore.createIndex('email', 'email', { unique: false });
certStore.createIndex('accountId', 'accountId', { unique: false });
}
if (oldVersion >= 1 && oldVersion < 2) {
// Add accountId index to existing stores
const tx = request.transaction!;
const keyStore = tx.objectStore(KEY_RECORDS_STORE);
if (!keyStore.indexNames.contains('accountId')) {
keyStore.createIndex('accountId', 'accountId', { unique: false });
}
const certStore = tx.objectStore(PUBLIC_CERTS_STORE);
if (!certStore.indexNames.contains('accountId')) {
certStore.createIndex('accountId', 'accountId', { unique: false });
}
}
};
request.onsuccess = () => resolve(request.result);
@@ -62,9 +75,11 @@ export async function getKeyRecordForEmail(email: string): Promise<SmimeKeyRecor
});
}
export async function listKeyRecords(): Promise<SmimeKeyRecord[]> {
export async function listKeyRecords(accountId?: string): Promise<SmimeKeyRecord[]> {
const db = await openDB();
return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
const all = await txPromise<SmimeKeyRecord[]>(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
if (!accountId) return all;
return all.filter((r) => r.accountId === accountId || !r.accountId);
}
export async function deleteKeyRecord(id: string): Promise<void> {
@@ -90,9 +105,11 @@ export async function getPublicCertForEmail(email: string): Promise<SmimePublicC
});
}
export async function listPublicCerts(): Promise<SmimePublicCert[]> {
export async function listPublicCerts(accountId?: string): Promise<SmimePublicCert[]> {
const db = await openDB();
return txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
const all = await txPromise<SmimePublicCert[]>(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
if (!accountId) return all;
return all.filter((c) => c.accountId === accountId || !c.accountId);
}
export async function deletePublicCert(id: string): Promise<void> {
+42 -3
View File
@@ -5,7 +5,7 @@ import {
classifyCapabilities,
} from './certificate-utils';
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
import { withLinerEngine } from './crypto-engine';
import { withLinerEngine, getLinerCrypto } from './crypto-engine';
const KDF_ITERATIONS = 600_000;
const AES_KEY_LENGTH = 256;
@@ -217,6 +217,8 @@ async function encryptPrivateKey(
export interface UnlockedKeyPair {
signingKey: CryptoKey;
decryptionKey?: CryptoKey;
/** Key imported via webcrypto-liner as RSAES-PKCS1-v1_5 for legacy S/MIME (3DES) messages */
legacyDecryptionKey?: CryptoKey;
}
/** Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys for signing and decryption. */
@@ -257,7 +259,22 @@ export async function unlockPrivateKey(
} catch {
// Key may only support decryption (key-encipherment-only cert)
const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
return { signingKey: decryptionKey, decryptionKey };
let legacyDecryptionKey: CryptoKey | undefined;
if (!isEcdsa) {
try {
const linerCrypto = getLinerCrypto();
legacyDecryptionKey = await linerCrypto.subtle.importKey(
'pkcs8',
pkcs8Bytes,
{ name: 'RSAES-PKCS1-v1_5' },
false,
['decrypt'],
);
} catch {
// webcrypto-liner may not be available
}
}
return { signingKey: decryptionKey, decryptionKey, legacyDecryptionKey };
}
// Also import for decryption (separate CryptoKey handle required by Web Crypto)
@@ -268,7 +285,29 @@ export async function unlockPrivateKey(
// Key may only support signing (digitalSignature-only cert)
}
return { signingKey, decryptionKey };
// Import a legacy decryption key via webcrypto-liner for RSAES-PKCS1-v1_5 key transport
// (used by older S/MIME messages encrypted with 3DES, RC2, etc.)
let legacyDecryptionKey: CryptoKey | undefined;
if (!isEcdsa) {
try {
const linerCrypto = getLinerCrypto();
legacyDecryptionKey = await linerCrypto.subtle.importKey(
'pkcs8',
pkcs8Bytes,
{ name: 'RSAES-PKCS1-v1_5' },
false,
['decrypt'],
);
console.debug('[S/MIME] legacy RSAES-PKCS1-v1_5 key imported successfully:', {
algorithm: legacyDecryptionKey.algorithm,
usages: legacyDecryptionKey.usages,
});
} catch (err) {
console.warn('[S/MIME] legacy RSAES-PKCS1-v1_5 key import failed:', err);
}
}
return { signingKey, decryptionKey, legacyDecryptionKey };
}
/** Get decrypted PKCS#8 bytes (for export flow). */
+49 -6
View File
@@ -15,8 +15,10 @@ export interface DecryptionInput {
cmsBytes: ArrayBuffer;
/** All imported key records to try matching against */
keyRecords: SmimeKeyRecord[];
/** Unlocked CryptoKey map: keyRecordId → CryptoKey */
/** Unlocked CryptoKey map: keyRecordId → CryptoKey (RSA-OAEP) */
unlockedKeys: Map<string, CryptoKey>;
/** Unlocked legacy CryptoKey map: keyRecordId → CryptoKey (RSAES-PKCS1-v1_5 via webcrypto-liner) */
legacyUnlockedKeys?: Map<string, CryptoKey>;
}
export interface DecryptionResult {
@@ -34,12 +36,24 @@ export interface DecryptionResult {
* @throws Error if no matching key is found, key is locked, or decryption fails
*/
export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> {
const { cmsBytes, keyRecords, unlockedKeys } = input;
const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input;
// Parse the CMS ContentInfo wrapper
const contentInfo = parseContentInfo(cmsBytes);
const envelopedData = extractEnvelopedData(contentInfo);
// Log CMS algorithm details for diagnostics
const contentEncOid = envelopedData.encryptedContentInfo?.contentEncryptionAlgorithm?.algorithmId;
const recipientAlgs = envelopedData.recipientInfos?.map((ri) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ri as any).value?.keyEncryptionAlgorithm?.algorithmId as string | undefined,
);
console.debug('[S/MIME] CMS algorithms:', {
contentEncryption: contentEncOid,
keyTransport: recipientAlgs,
legacyKeysAvailable: legacyUnlockedKeys?.size ?? 0,
});
// Find matching key records
const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords);
@@ -51,6 +65,19 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
for (const { keyRecord, recipientIndex } of matchedRecords) {
const privateKey = unlockedKeys.get(keyRecord.id);
if (!privateKey) {
// Try legacy key (RSAES-PKCS1-v1_5) if no RSA-OAEP key
const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
if (legacyKey) {
try {
const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
return {
mimeBytes: new Uint8Array(decrypted),
keyRecordId: keyRecord.id,
};
} catch {
continue;
}
}
continue; // Key exists but isn't unlocked — skip, caller should unlock first
}
@@ -60,16 +87,32 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
mimeBytes: new Uint8Array(decrypted),
keyRecordId: keyRecord.id,
};
} catch {
// This key didn't work, try the next one
} catch (oaepError) {
// RSA-OAEP key didn't work, try legacy RSAES-PKCS1-v1_5 key
console.debug('[S/MIME] RSA-OAEP decrypt failed:', oaepError instanceof Error ? oaepError.message : oaepError);
const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
console.debug('[S/MIME] legacy key available:', !!legacyKey, legacyKey ? { algorithm: (legacyKey as CryptoKey).algorithm } : undefined);
if (legacyKey) {
try {
const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
return {
mimeBytes: new Uint8Array(decrypted),
keyRecordId: keyRecord.id,
};
} catch (legacyError) {
// Legacy key also didn't work, try the next record
console.debug('[S/MIME] RSAES-PKCS1-v1_5 decrypt also failed:', legacyError instanceof Error ? legacyError.message : legacyError);
}
}
continue;
}
}
// Check if we had matching records but none were unlocked
const hasLockedMatch = matchedRecords.some(m => !unlockedKeys.has(m.keyRecord.id));
const isUnlocked = (id: string) => unlockedKeys.has(id) || (legacyUnlockedKeys?.has(id) ?? false);
const hasLockedMatch = matchedRecords.some(m => !isUnlocked(m.keyRecord.id));
if (hasLockedMatch) {
const lockedRecord = matchedRecords.find(m => !unlockedKeys.has(m.keyRecord.id))!;
const lockedRecord = matchedRecords.find(m => !isUnlocked(m.keyRecord.id))!;
throw new SmimeKeyLockedError(
'S/MIME key is locked. Unlock it to decrypt this message.',
lockedRecord.keyRecord.id,
+2
View File
@@ -1,6 +1,7 @@
/** Stored record for an imported S/MIME private key + certificate. */
export interface SmimeKeyRecord {
id: string;
accountId?: string;
email: string;
certificate: ArrayBuffer; // DER-encoded X.509 leaf cert
certificateChain: ArrayBuffer[]; // DER-encoded intermediates
@@ -34,6 +35,7 @@ export interface SmimeUnlockedKey {
/** A recipient or contact public certificate. */
export interface SmimePublicCert {
id: string;
accountId?: string;
email: string;
certificate: ArrayBuffer; // DER-encoded X.509
issuer: string;
+56 -1
View File
@@ -1,6 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Mailbox } from "./jmap/types";
import { debug } from "./debug";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -98,6 +99,7 @@ const ROLE_PRIORITY: Record<string, number> = {
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
const result: Mailbox[] = [];
const removed: { id: string; name: string; matchedRole: string; parentId?: string }[] = [];
// Group role mailboxes by account so deduplication is scoped per-account
const rolesByAccount = new Map<string, Mailbox[]>();
@@ -109,6 +111,12 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
}
});
// Build a set of IDs that are referenced as parents
const referencedParentIds = new Set<string>();
mailboxes.forEach(mb => {
if (mb.parentId) referencedParentIds.add(mb.parentId);
});
// Filter out duplicates scoped to the same account
mailboxes.forEach(mb => {
// If this mailbox has a role, always keep it
@@ -121,26 +129,47 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
const accountKey = mb.accountId || '';
const accountRoles = rolesByAccount.get(accountKey) || [];
const lowerName = mb.name.toLowerCase();
const isDuplicate = accountRoles.some(roleMb => {
const matchedRole = accountRoles.find(roleMb => {
const roleLowerName = roleMb.name.toLowerCase();
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
});
const isDuplicate = !!matchedRole;
// Only keep if not a duplicate
if (!isDuplicate) {
result.push(mb);
} else {
removed.push({ id: mb.id, name: mb.name, matchedRole: matchedRole!.name, parentId: mb.parentId });
// Warn if this removed mailbox is a parent of other mailboxes (orphan risk)
if (referencedParentIds.has(mb.id)) {
debug.warn(
`[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;
}
// Build a hierarchical tree structure from flat mailbox array
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
debug.log(`[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`);
// Deduplicate mailboxes first
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
const ownMailboxes = 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
const orphanedMailboxes: { id: string; name: string; parentId: string }[] = [];
ownMailboxes.forEach(mailbox => {
const node = mailboxMap.get(mailbox.id)!;
@@ -176,13 +206,38 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
parent.children.push(node);
} else {
// Root level mailbox or orphaned mailbox
if (mailbox.parentId) {
orphanedMailboxes.push({ id: mailbox.id, name: mailbox.name, parentId: mailbox.parentId });
}
rootMailboxes.push(node);
}
});
if (orphanedMailboxes.length > 0) {
debug.warn(
`[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`,
orphanedMailboxes
);
}
// Third pass: correctly calculate depths from the root down
recalculateDepths(rootMailboxes, 0);
// Log tree depth statistics
const maxDepth = (nodes: MailboxNode[]): number => {
let max = 0;
for (const node of nodes) {
max = Math.max(max, node.depth);
if (node.children.length > 0) max = Math.max(max, maxDepth(node.children));
}
return max;
};
debug.log(
`[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` +
`max depth: ${maxDepth(rootMailboxes)}, ` +
`total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}`
);
// If we have shared mailboxes, create a virtual "Shared Folders" parent
if (sharedMailboxes.length > 0) {
// Group shared mailboxes by account
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "benutzer@beispiel.de",
"password_label": "Passwort",
"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",
"signing_in": "Wird angemeldet...",
"loading": "Lädt...",
@@ -115,6 +118,7 @@
"demo_reset": "Zurücksetzen",
"demo_tour": "Tour",
"tags": "Tags",
"folders": "Ordner",
"mail": "E-Mail",
"nav_label": "Navigation",
"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_conversation_selected": "Keine Unterhaltung ausgewählt",
"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)",
"loading_email": "E-Mail wird geladen...",
"loading": "Lädt...",
@@ -477,7 +483,7 @@
"smime_sign_off": "S/MIME-Signatur deaktiviert",
"smime_encrypt_on": "S/MIME-Verschlüsselung aktiviert",
"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_message": "Geben Sie die Passphrase ein, um Ihren S/MIME-Schlüssel zu entsperren.",
"smime_unlock_button": "Entsperren",
@@ -683,6 +689,14 @@
"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."
},
"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": {
@@ -799,9 +813,18 @@
"label": "Spam dauerhaft löschen",
"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": {
"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": {
"label": "Konversationsgruppierung deaktivieren",
@@ -811,6 +834,10 @@
"label": "Nur Klartext",
"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": {
"label": "Aktion beim Klick auf Anhänge",
"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",
"disabled_success": "Verschlüsselung im Ruhezustand deaktiviert",
"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": {
@@ -1859,6 +1894,7 @@
"title": "Titel",
"description": "Beschreibung",
"location": "Ort",
"meeting_link": "Meeting-Link",
"start_date": "Startdatum",
"end_date": "Enddatum",
"start_time": "Startzeit",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Aufgaben aktivieren",
"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_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": {
"monday": "Montag",
@@ -2015,7 +2058,7 @@
"importing": "Termine werden importiert...",
"success": "{count} Termine erfolgreich importiert",
"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"
},
"management": {
+45 -3
View File
@@ -5,6 +5,9 @@
"username_placeholder": "user@example.com",
"password_label": "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",
"signing_in": "Signing in...",
"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.",
"server_error": "The server is temporarily unavailable. Please try again later.",
"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.",
"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_tour": "Tour",
"tags": "Tags",
"folders": "Folders",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps"
@@ -196,6 +201,8 @@
"no_email_description": "Select an email from the list to view it here",
"no_conversation_selected": "No conversation selected",
"no_conversation_description": "Choose a conversation from the list to read it here",
"compose": "Compose",
"compose_hint": "Compose new message",
"no_subject": "(No Subject)",
"loading_email": "Loading email...",
"loading": "Loading...",
@@ -683,6 +690,14 @@
"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."
},
"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": {
@@ -799,9 +814,18 @@
"label": "Permanently Delete Junk",
"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": {
"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": {
"label": "Disable Conversation Grouping",
@@ -811,6 +835,10 @@
"label": "Plain Text Only",
"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": {
"label": "Attachment Click Action",
"description": "Choose whether clicking a file attachment previews it or downloads it immediately",
@@ -1867,6 +1895,7 @@
"title": "Title",
"description": "Description",
"location": "Location",
"meeting_link": "Meeting link",
"start_date": "Start date",
"end_date": "End date",
"start_time": "Start time",
@@ -1954,7 +1983,14 @@
"enable_tasks": "Enable tasks",
"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_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": {
"monday": "Monday",
@@ -2023,7 +2059,7 @@
"importing": "Importing events...",
"success": "{count} events imported successfully",
"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"
},
"management": {
@@ -2073,6 +2109,12 @@
"interval_1440": "Every day",
"subscribe": "Subscribe",
"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",
"success": "Subscribed to \"{name}\"",
"error": "Failed to add subscription",
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "usuario@ejemplo.com",
"password_label": "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",
"signing_in": "Iniciando sesión...",
"loading": "Cargando...",
@@ -115,6 +118,7 @@
"demo_reset": "Restablecer",
"demo_tour": "Tour",
"tags": "Etiquetas",
"folders": "Carpetas",
"mail": "Correo",
"nav_label": "Navegación",
"add_app": "Apps"
@@ -196,6 +200,8 @@
"no_email_description": "Seleccione un correo de la lista para verlo aquí",
"no_conversation_selected": "Ninguna conversación seleccionada",
"no_conversation_description": "Elija una conversación de la lista para leerla aquí",
"compose": "Redactar",
"compose_hint": "Redactar nuevo mensaje",
"no_subject": "(Sin Asunto)",
"loading_email": "Cargando correo...",
"loading": "Cargando...",
@@ -477,7 +483,7 @@
"smime_sign_off": "Firma S/MIME desactivada",
"smime_encrypt_on": "Cifrado S/MIME activado",
"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_message": "Introduce la contraseña para desbloquear tu clave S/MIME.",
"smime_unlock_button": "Desbloquear",
@@ -683,6 +689,14 @@
"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."
},
"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": {
@@ -799,6 +813,14 @@
"label": "Eliminar spam permanentemente",
"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": {
"label": "Desactivar agrupación de conversaciones",
"description": "Mostrar correos como mensajes individuales en lugar de agruparlos por conversación"
@@ -807,9 +829,14 @@
"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"
},
"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": {
"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": {
"label": "Acción al hacer clic en adjuntos",
@@ -1023,6 +1050,14 @@
"enabled": "Cifrado en reposo habilitado",
"disabled_success": "Cifrado en reposo deshabilitado",
"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": {
@@ -1859,6 +1894,7 @@
"title": "Título",
"description": "Descripción",
"location": "Ubicación",
"meeting_link": "Enlace de reunión",
"start_date": "Fecha de inicio",
"end_date": "Fecha de fin",
"start_time": "Hora de inicio",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Activar tareas",
"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_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": {
"monday": "Lunes",
@@ -2015,7 +2058,7 @@
"importing": "Importando eventos...",
"success": "{count} eventos importados correctamente",
"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"
},
"management": {
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "utilisateur@exemple.com",
"password_label": "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",
"signing_in": "Connexion en cours...",
"loading": "Chargement...",
@@ -115,6 +118,7 @@
"demo_reset": "Réinitialiser",
"demo_tour": "Visite",
"tags": "Étiquettes",
"folders": "Dossiers",
"mail": "Messagerie",
"nav_label": "Navigation",
"add_app": "Apps"
@@ -196,6 +200,8 @@
"no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
"no_conversation_selected": "Aucune conversation sélectionnée",
"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)",
"loading_email": "Chargement de l'email...",
"loading": "Chargement...",
@@ -477,7 +483,7 @@
"smime_sign_off": "Signature S/MIME désactivée",
"smime_encrypt_on": "Chiffrement S/MIME activé",
"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_message": "Entrez la phrase secrète pour déverrouiller votre clé S/MIME.",
"smime_unlock_button": "Déverrouiller",
@@ -683,6 +689,14 @@
"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."
},
"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": {
@@ -799,6 +813,14 @@
"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"
},
"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": {
"label": "Désactiver le regroupement 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",
"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": {
"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": {
"label": "Action au clic sur les pièces jointes",
@@ -1023,6 +1050,14 @@
"enabled": "Chiffrement au repos activé",
"disabled_success": "Chiffrement au repos désactivé",
"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": {
@@ -1859,6 +1894,7 @@
"title": "Titre",
"description": "Description",
"location": "Lieu",
"meeting_link": "Lien de réunion",
"start_date": "Date de début",
"end_date": "Date de fin",
"start_time": "Heure de début",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Activer les tâches",
"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_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": {
"monday": "Lundi",
@@ -2015,7 +2058,7 @@
"importing": "Importation en cours...",
"success": "{count} événements importés avec succès",
"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"
},
"management": {
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "utente@esempio.com",
"password_label": "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",
"signing_in": "Accesso in corso...",
"loading": "Caricamento...",
@@ -115,6 +118,7 @@
"demo_reset": "Reimposta",
"demo_tour": "Tour",
"tags": "Etichette",
"folders": "Cartelle",
"mail": "Posta",
"nav_label": "Navigazione",
"add_app": "App"
@@ -196,6 +200,8 @@
"no_email_description": "Seleziona un messaggio dall'elenco per visualizzarlo qui",
"no_conversation_selected": "Nessuna conversazione selezionata",
"no_conversation_description": "Scegli una conversazione dall'elenco per leggerla qui",
"compose": "Componi",
"compose_hint": "Componi nuovo messaggio",
"no_subject": "(Nessun oggetto)",
"loading_email": "Caricamento messaggio...",
"loading": "Caricamento...",
@@ -477,7 +483,7 @@
"smime_sign_off": "Firma S/MIME disattivata",
"smime_encrypt_on": "Cifratura S/MIME attivata",
"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_message": "Inserisci la passphrase per sbloccare la tua chiave S/MIME.",
"smime_unlock_button": "Sblocca",
@@ -683,6 +689,14 @@
"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."
},
"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": {
@@ -799,6 +813,14 @@
"label": "Elimina spam definitivamente",
"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": {
"label": "Disabilita raggruppamento conversazioni",
"description": "Mostra le email come messaggi singoli anziché raggruppati per conversazione"
@@ -807,9 +829,14 @@
"label": "Solo testo normale",
"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": {
"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": {
"label": "Azione al clic sugli allegati",
@@ -1023,6 +1050,14 @@
"enabled": "Crittografia a riposo abilitata",
"disabled_success": "Crittografia a riposo disabilitata",
"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": {
@@ -1859,6 +1894,7 @@
"title": "Titolo",
"description": "Descrizione",
"location": "Luogo",
"meeting_link": "Link riunione",
"start_date": "Data di inizio",
"end_date": "Data di fine",
"start_time": "Ora di inizio",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Abilita attività",
"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_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": {
"monday": "Lunedì",
@@ -2015,7 +2058,7 @@
"importing": "Importazione eventi...",
"success": "{count} eventi importati con successo",
"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"
},
"management": {
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "user@example.com",
"password_label": "パスワード",
"password_placeholder": "パスワードを入力",
"jmap_endpoint_label": "JMAPサーバー",
"jmap_endpoint_placeholder": "https://mail.example.com",
"jmap_endpoint_cors_hint": "サーバーはこのドメインからのCORSリクエストを許可する必要があります。",
"sign_in": "サインイン",
"signing_in": "サインイン中...",
"loading": "読み込み中...",
@@ -115,6 +118,7 @@
"demo_reset": "リセット",
"demo_tour": "ツアー",
"tags": "タグ",
"folders": "フォルダ",
"mail": "メール",
"nav_label": "ナビゲーション",
"add_app": "アプリ"
@@ -196,6 +200,8 @@
"no_email_description": "リストからメールを選択して表示してください",
"no_conversation_selected": "会話が選択されていません",
"no_conversation_description": "リストから会話を選択して読んでください",
"compose": "作成",
"compose_hint": "新しいメッセージを作成",
"no_subject": "(件名なし)",
"loading_email": "メールを読み込み中...",
"loading": "読み込み中...",
@@ -477,7 +483,7 @@
"smime_sign_off": "S/MIME 署名を無効化しました",
"smime_encrypt_on": "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_message": "S/MIME 鍵を解除するためのパスフレーズを入力してください。",
"smime_unlock_button": "ロック解除",
@@ -683,6 +689,14 @@
"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."
},
"hide_account_switcher": {
"label": "サイドバーのアカウント切替を非表示",
"description": "フォルダサイドバー上部のアカウント選択を非表示にします。下部のナビゲーションレールからアカウントを切り替えることは引き続き可能です。"
},
"show_rail_account_list": {
"label": "ナビゲーションレールにアカウントアバターを表示",
"description": "ナビゲーションレールの下部に個々のアカウントの丸を表示して素早く切り替えできるようにし、その下にサインアウトボタンを配置します。"
}
},
"keywords": {
@@ -799,6 +813,14 @@
"label": "迷惑メールを完全に削除",
"description": "迷惑メールフォルダのメールをゴミ箱に移動せずに完全に削除する"
},
"mail_layout": {
"label": "メールレイアウト",
"description": "クラシックな分割読み取りペインとGmailスタイルの集中読み取りフローから選択します。",
"split": "分割ペイン",
"split_description": "メッセージリストと読み取りペインを並べて表示します。",
"focus": "集中リスト",
"focus_description": "メッセージごとに1行表示し、フォルダサイドバーを表示したままメールを全幅で開きます。"
},
"disable_threading": {
"label": "会話グループ化を無効にする",
"description": "メールを会話ごとにグループ化せず、個別のメッセージとして表示します"
@@ -807,9 +829,14 @@
"label": "プレーンテキストのみ",
"description": "リッチテキストエディターを無効にし、返信や転送を含むすべてのメールをプレーンテキストのみで送信します"
},
"auto_select_reply_identity": {
"label": "返信元アドレスを自動選択",
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
},
"show_preview": {
"label": "プレビューテキストを表示",
"description": "リストにメールのプレビューを表示"
"description": "リストにメールのプレビューを表示",
"focus_description": "集中型1行メッセージリスト内にインラインプレビューテキストを表示"
},
"attachment_click_action": {
"label": "添付ファイルクリック時の動作",
@@ -1023,6 +1050,14 @@
"enabled": "保存時の暗号化が有効になりました",
"disabled_success": "保存時の暗号化が無効になりました",
"error": "暗号化設定を更新できませんでした"
},
"email_client": {
"title": "メールクライアントの設定",
"description": "これらの資格情報を使用して、デスクトップまたはモバイルのメールクライアント(Thunderbird、Apple Mail、Outlookなど)を設定します",
"jmap_username_label": "JMAPユーザー名",
"copy": "コピー",
"copied": "コピー済み",
"password_instructions": "上記のJMAPユーザー名とアプリパスワードを使用してメールクライアントにサインインします。まだ作成していない場合は、上のセクションでアプリパスワードを作成してください。"
}
},
"identities": {
@@ -1859,6 +1894,7 @@
"title": "タイトル",
"description": "説明",
"location": "場所",
"meeting_link": "会議リンク",
"start_date": "開始日",
"end_date": "終了日",
"start_time": "開始時刻",
@@ -1946,7 +1982,14 @@
"enable_tasks": "タスクを有効化",
"enable_tasks_desc": "カレンダーにタスク表示を追加して、ToDoを管理します",
"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": {
"monday": "月曜日",
@@ -2015,7 +2058,7 @@
"importing": "イベントをインポート中...",
"success": "{count}件のイベントをインポートしました",
"error": "カレンダーのインポートに失敗しました",
"file_too_large": "ファイルサイズが5MBを超えています",
"file_too_large": "ファイルサイズが10MBを超えています",
"invalid_format": "無効なカレンダーファイル形式"
},
"management": {
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "gebruiker@voorbeeld.nl",
"password_label": "Wachtwoord",
"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",
"signing_in": "Aanmelden...",
"loading": "Laden...",
@@ -115,6 +118,7 @@
"demo_reset": "Resetten",
"demo_tour": "Rondleiding",
"tags": "Labels",
"folders": "Mappen",
"mail": "E-mail",
"nav_label": "Navigatie",
"add_app": "Apps"
@@ -196,6 +200,8 @@
"no_email_description": "Selecteer een e-mail uit de lijst om deze hier te bekijken",
"no_conversation_selected": "Geen gesprek geselecteerd",
"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)",
"loading_email": "E-mail laden...",
"loading": "Laden...",
@@ -477,7 +483,7 @@
"smime_sign_off": "S/MIME-ondertekening uitgeschakeld",
"smime_encrypt_on": "S/MIME-versleuteling ingeschakeld",
"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_message": "Voer de wachtwoordzin in om uw S/MIME-sleutel te ontgrendelen.",
"smime_unlock_button": "Ontgrendelen",
@@ -683,6 +689,14 @@
"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."
},
"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": {
@@ -799,6 +813,14 @@
"label": "Spam permanent verwijderen",
"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": {
"label": "Conversatiegroepering uitschakelen",
"description": "Toon e-mails als afzonderlijke berichten in plaats van gegroepeerd op gesprek"
@@ -807,9 +829,14 @@
"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"
},
"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": {
"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": {
"label": "Actie bij klikken op bijlagen",
@@ -1023,6 +1050,14 @@
"enabled": "Versleuteling in rust ingeschakeld",
"disabled_success": "Versleuteling in rust uitgeschakeld",
"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": {
@@ -1859,6 +1894,7 @@
"title": "Titel",
"description": "Beschrijving",
"location": "Locatie",
"meeting_link": "Vergaderlink",
"start_date": "Startdatum",
"end_date": "Einddatum",
"start_time": "Starttijd",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Taken inschakelen",
"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_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": {
"monday": "Maandag",
@@ -2015,7 +2058,7 @@
"importing": "Evenementen importeren...",
"success": "{count} evenementen succesvol geïmporteerd",
"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"
},
"management": {
+47 -4
View File
@@ -5,6 +5,9 @@
"username_placeholder": "usuario@exemplo.com",
"password_label": "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",
"signing_in": "Entrando...",
"loading": "Carregando...",
@@ -115,6 +118,7 @@
"demo_reset": "Repor",
"demo_tour": "Tour",
"tags": "Etiquetas",
"folders": "Pastas",
"mail": "E-mail",
"nav_label": "Navegação",
"add_app": "Apps"
@@ -196,6 +200,8 @@
"no_email_description": "Selecione um e-mail da lista para visualizá-lo aqui",
"no_conversation_selected": "Nenhuma conversa selecionada",
"no_conversation_description": "Escolha uma conversa da lista para lê-la aqui",
"compose": "Redigir",
"compose_hint": "Redigir nova mensagem",
"no_subject": "(Sem Assunto)",
"loading_email": "Carregando e-mail...",
"loading": "Carregando...",
@@ -477,7 +483,7 @@
"smime_sign_off": "Assinatura S/MIME desativada",
"smime_encrypt_on": "Criptografia S/MIME ativada",
"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_message": "Insira a frase secreta para desbloquear sua chave S/MIME.",
"smime_unlock_button": "Desbloquear",
@@ -683,6 +689,14 @@
"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."
},
"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": {
@@ -799,6 +813,14 @@
"label": "Excluir spam permanentemente",
"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": {
"label": "Desativar agrupamento de conversas",
"description": "Mostrar e-mails como mensagens individuais em vez de agrupados por conversa"
@@ -807,9 +829,14 @@
"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"
},
"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": {
"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": {
"label": "Ação ao clicar em anexos",
@@ -1023,6 +1050,14 @@
"enabled": "Criptografia em repouso habilitada",
"disabled_success": "Criptografia em repouso desabilitada",
"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": {
@@ -1859,6 +1894,7 @@
"title": "Título",
"description": "Descrição",
"location": "Local",
"meeting_link": "Link da reunião",
"start_date": "Data de início",
"end_date": "Data de término",
"start_time": "Hora de início",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Ativar tarefas",
"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_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": {
"monday": "Segunda-feira",
@@ -2015,7 +2058,7 @@
"importing": "Importando eventos...",
"success": "{count} eventos importados com sucesso",
"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"
},
"management": {
+46 -3
View File
@@ -5,6 +5,9 @@
"username_placeholder": "user@пример.рф",
"password_label": "Пароль",
"password_placeholder": "Введите пароль",
"jmap_endpoint_label": "Сервер JMAP",
"jmap_endpoint_placeholder": "https://mail.example.com",
"jmap_endpoint_cors_hint": "Сервер должен разрешать CORS-запросы с этого домена.",
"sign_in": "Войти",
"signing_in": "Вход...",
"loading": "Загрузка...",
@@ -115,6 +118,7 @@
"demo_reset": "Сбросить",
"demo_tour": "Тур",
"tags": "Теги",
"folders": "Папки",
"mail": "Почта",
"nav_label": "Навигация",
"add_app": "Приложения"
@@ -196,6 +200,8 @@
"no_email_description": "Выберите письмо из списка, чтобы просмотреть его здесь",
"no_conversation_selected": "Беседа не выбрана",
"no_conversation_description": "Выберите беседу из списка, чтобы прочитать её здесь",
"compose": "Написать",
"compose_hint": "Написать новое сообщение",
"no_subject": "(Без темы)",
"loading_email": "Загрузка письма...",
"loading": "Загрузка...",
@@ -683,6 +689,14 @@
"toolbar_labels": {
"label": "Показывать подписи панели инструментов",
"description": "Отображать текстовые подписи рядом с иконками панели. Отключите для экономии места, когда освоитесь с иконками."
},
"hide_account_switcher": {
"label": "Скрыть переключатель аккаунтов в боковой панели",
"description": "Скрыть выбор аккаунта в верхней части боковой панели папок. Вы по-прежнему можете переключать аккаунты через нижнюю панель навигации."
},
"show_rail_account_list": {
"label": "Показать аватары аккаунтов на панели навигации",
"description": "Отображать отдельные круги аккаунтов в нижней части панели навигации для быстрого переключения, с кнопкой выхода ниже."
}
},
"keywords": {
@@ -799,6 +813,14 @@
"label": "Удалять спам навсегда",
"description": "Удалять письма из папки «Спам» навсегда вместо перемещения в корзину"
},
"mail_layout": {
"label": "Макет почты",
"description": "Выберите между классической разделённой панелью чтения и сфокусированным потоком чтения в стиле Gmail.",
"split": "Разделённая панель",
"split_description": "Список сообщений и панель чтения отображаются рядом друг с другом.",
"focus": "Сфокусированный список",
"focus_description": "Показывать одну строку на сообщение и открывать письма на всю ширину, сохраняя видимой боковую панель папок."
},
"disable_threading": {
"label": "Отключить группировку по беседам",
"description": "Отображать письма как отдельные сообщения, а не сгруппированные по беседам"
@@ -807,9 +829,14 @@
"label": "Только простой текст",
"description": "Отключить редактор форматированного текста и отправлять все письма только в виде простого текста, включая ответы и пересылки"
},
"auto_select_reply_identity": {
"label": "Автоматически выбирать адрес для ответа",
"description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
},
"show_preview": {
"label": "Показывать текст предпросмотра",
"description": "Отображать предпросмотр письма в списке"
"description": "Отображать предпросмотр письма в списке",
"focus_description": "Отображать встроенный текст предпросмотра в сфокусированном однострочном списке сообщений"
},
"attachment_click_action": {
"label": "Действие при нажатии на вложение",
@@ -1023,6 +1050,14 @@
"enabled": "Шифрование хранилища включено",
"disabled_success": "Шифрование хранилища отключено",
"error": "Не удалось обновить настройки шифрования"
},
"email_client": {
"title": "Настройка почтового клиента",
"description": "Используйте эти учётные данные для настройки вашего почтового клиента (Thunderbird, Apple Mail, Outlook и др.)",
"jmap_username_label": "Имя пользователя JMAP",
"copy": "Копировать",
"copied": "Скопировано",
"password_instructions": "Используйте указанное выше имя пользователя JMAP вместе с паролем приложения для входа в почтовый клиент. Создайте пароль приложения в разделе выше, если вы ещё этого не сделали."
}
},
"identities": {
@@ -1859,6 +1894,7 @@
"title": "Название",
"description": "Описание",
"location": "Место",
"meeting_link": "Ссылка на встречу",
"start_date": "Дата начала",
"end_date": "Дата окончания",
"start_time": "Время начала",
@@ -1946,7 +1982,14 @@
"enable_tasks": "Включить задачи",
"enable_tasks_desc": "Показывать представление задач в календаре для управления делами",
"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": {
"monday": "Понедельник",
@@ -2015,7 +2058,7 @@
"importing": "Импортирование событий...",
"success": "{count} событий успешно импортировано",
"error": "Не удалось импортировать календарь",
"file_too_large": "Файл превышает лимит 5 МБ",
"file_too_large": "Файл превышает лимит 10 МБ",
"invalid_format": "Неверный формат файла календаря"
},
"management": {
+66 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.4.9",
"version": "1.4.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.4.9",
"version": "1.4.10",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.18",
@@ -3282,6 +3282,70 @@
"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": {
"version": "4.2.1",
"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",
"version": "1.4.9",
"version": "1.4.10",
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+34
View File
@@ -339,5 +339,39 @@ describe('filter-store', () => {
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
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);
});
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[] }> = [
{ key: 'cors_blocked', matches: ['CORS_ERROR'] },
{ key: 'totp_required', matches: ['TOTP_REQUIRED'] },
{ key: 'invalid_credentials', matches: ['Invalid username or password', '401', 'Unauthorized'] },
{ 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'] },
@@ -371,6 +372,47 @@ export const useAuthStore = create<AuthState>()(
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
clients.set(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);
@@ -379,7 +421,7 @@ export const useAuthStore = create<AuthState>()(
label: primaryIdentity?.name || username,
serverUrl,
username,
authMode: 'basic',
authMode: effectiveAuthMode,
rememberMe: !!rememberMe,
displayName: primaryIdentity?.name || 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)
accountStore.updateAccount(accountId, {
authMode: effectiveAuthMode,
rememberMe: !!rememberMe,
isConnected: true,
hasError: false,
@@ -402,7 +445,8 @@ export const useAuthStore = create<AuthState>()(
// Store session cookie BEFORE setting isAuthenticated to avoid a race
// condition: setting isAuthenticated triggers navigation to the main page,
// 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 {
const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, {
method: 'POST',
@@ -426,15 +470,20 @@ export const useAuthStore = create<AuthState>()(
...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: 'basic',
authMode: effectiveAuthMode,
rememberMe: !!rememberMe,
accessToken: null,
tokenExpiresAt: null,
accessToken: oauthAccessToken,
tokenExpiresAt: oauthAccessToken ? Date.now() + oauthExpiresIn * 1000 : null,
connectionLost: false,
error: null,
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)
fetchConfig().then(config => {
if (!config.settingsSyncEnabled) return;
@@ -965,7 +1014,7 @@ export const useAuthStore = create<AuthState>()(
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
}
} 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) {
const { serverUrl, username, password } = await res.json();
targetClient = new JMAPClient(serverUrl, username, password);
@@ -1130,7 +1179,7 @@ export const useAuthStore = create<AuthState>()(
throw new Error(`Token refresh failed: ${res.status}`);
}
} 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) {
const { serverUrl, username, password } = await res.json();
const client = new JMAPClient(serverUrl, username, password);
@@ -1321,7 +1370,7 @@ export const useAuthStore = create<AuthState>()(
if (state.authMode === 'basic') {
set({ isLoading: true, isRateLimited: false, rateLimitUntil: null });
try {
const res = await fetch('/api/auth/session');
const res = await fetch('/api/auth/session', { method: 'PUT' });
if (res.ok) {
const data = await res.json();
if (!data.serverUrl || !data.username || !data.password) {
+246 -254
View File
@@ -5,6 +5,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/ty
import { debug } from '@/lib/debug';
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
@@ -132,6 +133,7 @@ interface CalendarStore {
// iCal subscriptions
icalSubscriptions: ICalSubscription[];
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>;
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
@@ -190,13 +192,17 @@ export const useCalendarStore = create<CalendarStore>()(
before: end,
});
// Filter out malformed events missing required 'start' field
const events = rawEvents.filter(e => typeof e.start === 'string' && e.start);
const droppedEvents = rawEvents.length - events.length;
const validEvents = rawEvents.filter(e => typeof e.start === 'string' && e.start);
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', {
start,
end,
rawCount: rawEvents.length,
usableCount: events.length,
validCount: validEvents.length,
expandedCount: events.length,
droppedEvents,
});
if (droppedEvents > 0) {
@@ -291,10 +297,18 @@ export const useCalendarStore = create<CalendarStore>()(
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
set({ error: null });
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 realId = storeEvent?.originalId || id;
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
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
if (cleanUpdates.calendarIds) {
@@ -305,54 +319,26 @@ export const useCalendarStore = create<CalendarStore>()(
}
cleanUpdates.calendarIds = remapped;
}
try {
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) => ({
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e),
}));
return;
}
}
throw updateError;
}
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
set((state) => ({
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e),
events: state.events.map(e => {
if (e.id !== id) return e;
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();
}
}
}
return merged;
}),
}));
} catch (error) {
debug.error('Failed to update event:', error);
@@ -370,7 +356,7 @@ export const useCalendarStore = create<CalendarStore>()(
throw new Error('Invalid participant ID');
}
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 realId = storeEvent?.originalId || eventId;
const targetAccountId = storeEvent?.accountId;
@@ -383,65 +369,12 @@ export const useCalendarStore = create<CalendarStore>()(
if (replyTo) {
patch.replyTo = replyTo;
}
try {
await client.updateCalendarEvent(
realId,
patch as unknown as Partial<CalendarEvent>,
true,
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;
}
await client.updateCalendarEvent(
realId,
patch as unknown as Partial<CalendarEvent>,
true,
targetAccountId
);
set((state) => ({
events: state.events.map(e => {
if (e.id !== eventId || !e.participants?.[participantId]) return e;
@@ -462,117 +395,175 @@ export const useCalendarStore = create<CalendarStore>()(
},
importEvents: async (client, events, calendarId) => {
let imported = 0;
// Resolve shared calendar IDs
const cal = get().calendars.find(c => c.id === calendarId);
const realCalendarId = cal?.originalId || calendarId;
const targetAccountId = cal?.accountId;
for (const event of events) {
const src = sanitizeOutgoingCalendarEventData(event as Partial<CalendarEvent>);
try {
let cleanParticipants: Record<string, CalendarParticipant> | null = null;
if (src.participants) {
cleanParticipants = {};
for (const [key, p] of Object.entries(src.participants)) {
const participant: Record<string, unknown> = {
'@type': 'Participant',
name: p.name,
email: p.email,
calendarAddress: p.calendarAddress,
description: p.description,
sendTo: p.sendTo,
kind: p.kind,
roles: p.roles,
participationStatus: p.participationStatus,
participationComment: p.participationComment,
expectReply: p.expectReply,
scheduleAgent: p.scheduleAgent,
scheduleForceSend: p.scheduleForceSend,
scheduleId: p.scheduleId,
delegatedTo: p.delegatedTo,
delegatedFrom: p.delegatedFrom,
memberOf: p.memberOf,
locationId: p.locationId,
language: p.language,
links: p.links,
};
Object.keys(participant).forEach(k => {
if (participant[k] === undefined || participant[k] === null) delete participant[k];
});
cleanParticipants[key] = participant as unknown as CalendarParticipant;
}
}
const data: Partial<CalendarEvent> = {
calendarIds: { [realCalendarId]: true },
uid: src.uid,
title: src.title,
description: src.description,
descriptionContentType: src.descriptionContentType,
start: src.start,
duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration,
timeZone: src.showWithoutTime ? null : src.timeZone,
showWithoutTime: src.showWithoutTime,
status: src.status,
freeBusyStatus: src.freeBusyStatus,
privacy: src.privacy,
color: src.color,
keywords: src.keywords,
categories: src.categories,
locale: src.locale,
replyTo: src.replyTo || (src.organizerCalendarAddress ? { imip: src.organizerCalendarAddress } : undefined),
locations: src.locations,
virtualLocations: src.virtualLocations,
links: src.links,
recurrenceRules: src.recurrenceRules,
recurrenceOverrides: src.recurrenceOverrides,
excludedRecurrenceRules: src.excludedRecurrenceRules,
alerts: src.alerts,
participants: cleanParticipants,
};
Object.keys(data).forEach(k => {
const v = (data as Record<string, unknown>)[k];
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
});
const created = await client.createCalendarEvent(data, undefined, targetAccountId);
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++;
// 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 {
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;
}
try {
const all = await client.queryCalendarEvents({}, undefined, undefined, targetAccountId);
const matching = all.filter((e) => e.uid === src.uid);
if (matching.length > 0) {
const existingIds = new Set(storeEvents.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
if (newEvents.length > 0) {
set((state) => ({ events: [...state.events, ...newEvents] }));
}
imported++;
continue;
}
} catch {
// fall through to error
}
// Exists in another calendar — link to target calendar
eventsToLink.push({
eventId: existing.id,
calendarIds: { ...existing.calendarIds, [realCalendarId]: true },
});
}
debug.error('Failed to import event:', event.title, error);
}
// 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;
if (src.participants) {
cleanParticipants = {};
for (const [key, p] of Object.entries(src.participants)) {
const participant: Record<string, unknown> = {
'@type': 'Participant',
name: p.name,
email: p.email,
calendarAddress: p.calendarAddress,
description: p.description,
sendTo: p.sendTo,
kind: p.kind,
roles: p.roles,
participationStatus: p.participationStatus,
participationComment: p.participationComment,
expectReply: p.expectReply,
scheduleAgent: p.scheduleAgent,
scheduleForceSend: p.scheduleForceSend,
scheduleId: p.scheduleId,
delegatedTo: p.delegatedTo,
delegatedFrom: p.delegatedFrom,
memberOf: p.memberOf,
locationId: p.locationId,
language: p.language,
links: p.links,
};
Object.keys(participant).forEach(k => {
if (participant[k] === undefined || participant[k] === null) delete participant[k];
});
cleanParticipants[key] = participant as unknown as CalendarParticipant;
}
}
const data: Partial<CalendarEvent> = {
calendarIds: { [realCalendarId]: true },
uid: src.uid,
title: src.title,
description: src.description,
descriptionContentType: src.descriptionContentType,
start: src.start,
duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration,
timeZone: src.showWithoutTime ? null : src.timeZone,
showWithoutTime: src.showWithoutTime,
status: src.status,
freeBusyStatus: src.freeBusyStatus,
privacy: src.privacy,
color: src.color,
keywords: src.keywords,
categories: src.categories,
locale: src.locale,
replyTo: src.replyTo || (src.organizerCalendarAddress ? { imip: src.organizerCalendarAddress } : undefined),
locations: src.locations,
virtualLocations: src.virtualLocations,
links: src.links,
recurrenceRules: src.recurrenceRules,
recurrenceOverrides: src.recurrenceOverrides,
excludedRecurrenceRules: src.excludedRecurrenceRules,
alerts: src.alerts,
participants: cleanParticipants,
};
Object.keys(data).forEach(k => {
const v = (data as Record<string, unknown>)[k];
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
});
prepared.push(data);
}
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 {
const { created, failed } = await client.batchCreateCalendarEvents(batch, targetAccountId);
imported += created.length;
if (failed.length > 0) {
debug.warn(`Import batch ${i / BATCH_SIZE + 1}: ${failed.length} events failed`);
}
} catch (error) {
debug.error(`Import batch ${i / BATCH_SIZE + 1} failed:`, error);
}
}
return imported;
// 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 + linked;
},
deleteEvent: async (client, id, sendSchedulingMessages) => {
set({ error: null });
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 realId = storeEvent?.originalId || id;
const targetAccountId = storeEvent?.accountId;
@@ -586,39 +577,14 @@ export const useCalendarStore = create<CalendarStore>()(
debug.error('Failed to send cancellation emails:', e);
}
}
try {
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;
}
}
debug.log('Calendar deleteEvent', {
storeId: id,
realId,
uid: storeEvent?.uid,
recurrenceId: storeEvent?.recurrenceId,
targetAccountId,
});
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
set((state) => ({
events: state.events.filter(e => e.id !== id),
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) => {
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
if (!sub) return;
@@ -858,24 +856,18 @@ export const useCalendarStore = create<CalendarStore>()(
// Import only events that don't already exist on server
const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid));
// Remove stale local events for this calendar
set((state) => ({
events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]),
}));
// Import new events
// Import new events (importEvents will re-fetch all events at the end)
if (eventsToImport.length > 0) {
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
set((state) => ({
icalSubscriptions: state.icalSubscriptions.map(s =>
+43 -10
View File
@@ -342,6 +342,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
// Capture position from current email count before the async call
const position = emails.length;
let result;
const { searchFilters } = get();
@@ -355,9 +358,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (hasFilters) {
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, emails.length);
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, position);
} else {
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position);
}
} else {
// 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)
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({
emails: [...emails, ...result.emails],
emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false
@@ -1242,12 +1254,30 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
get().handleNewEmailNotification(result.emails[0]);
}
// Skip state update if emails haven't actually changed to avoid
// unnecessary re-renders that cause a visible list flicker
// Merge the refreshed first page with the existing loaded emails.
// 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 =
currentEmails.length !== result.emails.length ||
result.emails.some((email, i) => {
currentEmails.length !== merged.length ||
merged.some((email, i) => {
const curr = currentEmails[i];
if (!curr) return true;
return (
curr.id !== email.id ||
curr.threadId !== email.threadId ||
@@ -1256,9 +1286,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
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({
emails: result.emails,
hasMoreEmails: result.hasMore,
emails: merged,
hasMoreEmails: hasMore,
totalEmails: result.total,
});
}
+16 -5
View File
@@ -52,8 +52,12 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
const capabilities = client.getSieveCapabilities();
set({ sieveCapabilities: capabilities });
const scripts = await client.getSieveScripts();
debug.log('Sieve scripts fetched:', scripts.length);
const allScripts = await client.getSieveScripts();
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];
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
// 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];
let rules = previousRules;
@@ -182,7 +188,10 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
const content = generateScript(rules, vacation.isEnabled ? vacation : undefined);
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({
activeScriptId: activeScript.id,
rawScript: content,
@@ -191,7 +200,9 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
isOpaque: false,
});
} 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({
activeScriptId: script.id,
rawScript: content,
+1
View File
@@ -21,6 +21,7 @@ import { usePolicyStore } from '@/stores/policy-store';
const SLOT_NAMES: SlotName[] = [
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
'calendar-event-actions', 'admin-plugin-page',
];
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 ToolbarPosition = 'top' | 'below-subject';
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 HoverActionsMode = 'inline' | 'floating';
@@ -105,6 +107,7 @@ interface SettingsState {
deleteAction: DeleteAction;
permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash
showPreview: boolean;
mailLayout: MailLayout;
emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy;
mailAttachmentAction: MailAttachmentAction;
@@ -119,6 +122,7 @@ interface SettingsState {
autoSaveDraftInterval: number; // milliseconds
sendConfirmation: boolean;
defaultReplyMode: ReplyMode;
autoSelectReplyIdentity: boolean;
plainTextMode: boolean; // Send plain text only (no rich text editor)
// Privacy & Security
@@ -131,6 +135,7 @@ interface SettingsState {
// Calendar
showTimeInMonthView: boolean;
showWeekNumbers: boolean;
calendarHoverPreview: CalendarHoverPreview;
// Calendar Tasks
enableCalendarTasks: boolean;
@@ -149,6 +154,8 @@ interface SettingsState {
// Layout
toolbarPosition: ToolbarPosition;
showToolbarLabels: boolean;
hideAccountSwitcher: boolean;
showRailAccountList: boolean;
// Email Display
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
@@ -224,6 +231,7 @@ const DEFAULT_SETTINGS = {
deleteAction: 'trash' as DeleteAction,
permanentlyDeleteJunk: false,
showPreview: true,
mailLayout: 'split' as MailLayout,
emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy,
mailAttachmentAction: 'preview' as MailAttachmentAction,
@@ -238,6 +246,7 @@ const DEFAULT_SETTINGS = {
autoSaveDraftInterval: 60000, // 1 minute
sendConfirmation: false,
defaultReplyMode: 'reply' as ReplyMode,
autoSelectReplyIdentity: false,
plainTextMode: false,
// Privacy & Security
@@ -250,6 +259,7 @@ const DEFAULT_SETTINGS = {
// Calendar
showTimeInMonthView: false,
showWeekNumbers: false,
calendarHoverPreview: 'delay-500ms' as CalendarHoverPreview,
// Calendar Tasks
enableCalendarTasks: false,
@@ -268,6 +278,8 @@ const DEFAULT_SETTINGS = {
// Layout
toolbarPosition: 'top' as ToolbarPosition,
showToolbarLabels: true,
hideAccountSwitcher: false,
showRailAccountList: false,
// Email Display
disableThreading: false,
@@ -333,6 +345,7 @@ export const useSettingsStore = create<SettingsState>()(
markAsReadDelay: state.markAsReadDelay,
deleteAction: state.deleteAction,
showPreview: state.showPreview,
mailLayout: state.mailLayout,
emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy,
mailAttachmentAction: state.mailAttachmentAction,
@@ -346,6 +359,7 @@ export const useSettingsStore = create<SettingsState>()(
autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,
@@ -359,7 +373,10 @@ export const useSettingsStore = create<SettingsState>()(
expandedFilterView: state.expandedFilterView,
showTimeInMonthView: state.showTimeInMonthView,
showWeekNumbers: state.showWeekNumbers,
calendarHoverPreview: state.calendarHoverPreview,
toolbarPosition: state.toolbarPosition,
hideAccountSwitcher: state.hideAccountSwitcher,
showRailAccountList: state.showRailAccountList,
senderFavicons: state.senderFavicons,
folderIcons: state.folderIcons,
emailKeywords: state.emailKeywords,
+137 -26
View File
@@ -91,10 +91,12 @@ function clearRememberedUnlocks(): void {
async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
unlockedKeys: Map<string, CryptoKey>;
unlockedDecryptionKeys: Map<string, CryptoKey>;
unlockedLegacyDecryptionKeys: Map<string, CryptoKey>;
}> {
const rememberedUnlocks = readRememberedUnlocks();
const unlockedKeys = new Map<string, CryptoKey>();
const unlockedDecryptionKeys = new Map<string, CryptoKey>();
const unlockedLegacyDecryptionKeys = new Map<string, CryptoKey>();
let removedStaleEntries = false;
for (const record of keyRecords) {
@@ -104,11 +106,14 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
}
try {
const { signingKey, decryptionKey } = await unlockPrivateKey(record, passphrase);
const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase);
unlockedKeys.set(record.id, signingKey);
if (decryptionKey) {
unlockedDecryptionKeys.set(record.id, decryptionKey);
}
if (legacyDecryptionKey) {
unlockedLegacyDecryptionKeys.set(record.id, legacyDecryptionKey);
}
} catch {
delete rememberedUnlocks[record.id];
removedStaleEntries = true;
@@ -119,29 +124,40 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
writeRememberedUnlocks(rememberedUnlocks);
}
return { unlockedKeys, unlockedDecryptionKeys };
return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys };
}
interface SmimePersistedState {
identityKeyBindings: Record<string, string>; // identityId → keyRecordId
defaultSignIdentity: Record<string, boolean>; // identityId → sign by default
defaultEncrypt: boolean;
/** Account-scoped preferences: accountId → { identityKeyBindings, defaultSignIdentity, defaultEncrypt } */
accountPreferences: Record<string, {
identityKeyBindings: Record<string, string>;
defaultSignIdentity: Record<string, boolean>;
defaultEncrypt: boolean;
}>;
rememberUnlockedKeys: boolean;
autoImportSignerCerts: boolean;
}
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
keyRecords: SmimeKeyRecord[];
publicCerts: SmimePublicCert[];
// Runtime only — never persisted
unlockedKeys: Map<string, CryptoKey>;
unlockedDecryptionKeys: Map<string, CryptoKey>;
unlockedLegacyDecryptionKeys: Map<string, CryptoKey>;
isLoading: boolean;
error: string | null;
// Actions
load: () => Promise<void>;
load: (accountId?: string) => Promise<void>;
clearState: () => void;
importPKCS12: (file: ArrayBuffer, p12Passphrase: string, storagePassphrase: string) => Promise<SmimeKeyRecord>;
importPublicCert: (data: ArrayBuffer | string, source: SmimePublicCert['source'], contactId?: string) => Promise<SmimePublicCert>;
bindIdentityToKey: (identityId: string, keyRecordId: string | null) => void;
@@ -166,26 +182,47 @@ export const useSmimeStore = create<SmimeStore>()(
persist(
(set, get) => ({
// Persisted preferences
accountPreferences: {},
rememberUnlockedKeys: false,
autoImportSignerCerts: true,
// Runtime state
currentAccountId: null,
identityKeyBindings: {},
defaultSignIdentity: {},
defaultEncrypt: false,
rememberUnlockedKeys: false,
autoImportSignerCerts: false,
// Runtime state
keyRecords: [],
publicCerts: [],
unlockedKeys: new Map(),
unlockedDecryptionKeys: new Map(),
unlockedLegacyDecryptionKeys: new Map(),
isLoading: false,
error: null,
load: async () => {
set({ isLoading: true, error: null });
load: async (accountId) => {
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 {
const [keyRecords, publicCerts] = await Promise.all([
listKeyRecords(),
listPublicCerts(),
listKeyRecords(acctId ?? undefined),
listPublicCerts(acctId ?? undefined),
]);
if (get().rememberUnlockedKeys) {
@@ -201,6 +238,10 @@ export const useSmimeStore = create<SmimeStore>()(
...state.unlockedDecryptionKeys,
...restoredKeys.unlockedDecryptionKeys,
]),
unlockedLegacyDecryptionKeys: new Map([
...state.unlockedLegacyDecryptionKeys,
...restoredKeys.unlockedLegacyDecryptionKeys,
]),
isLoading: false,
}));
return;
@@ -219,6 +260,8 @@ export const useSmimeStore = create<SmimeStore>()(
set({ isLoading: true, error: null });
try {
const { keyRecord } = await importPkcs12(file, p12Passphrase, storagePassphrase);
const acctId = get().currentAccountId;
if (acctId) keyRecord.accountId = acctId;
await saveKeyRecord(keyRecord);
set((state) => ({
keyRecords: [...state.keyRecords, keyRecord],
@@ -245,6 +288,7 @@ export const useSmimeStore = create<SmimeStore>()(
const publicCert: SmimePublicCert = {
id: crypto.randomUUID(),
accountId: get().currentAccountId ?? undefined,
email: email.toLowerCase(),
certificate: der,
issuer: info.issuer,
@@ -279,7 +323,15 @@ export const useSmimeStore = create<SmimeStore>()(
} else {
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);
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
unlockedDecryptionKeys.delete(id);
const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys);
unlockedLegacyDecryptionKeys.delete(id);
// Remove any identity bindings pointing to this key
const bindings = { ...state.identityKeyBindings };
for (const [identityId, keyId] of Object.entries(bindings)) {
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 {
keyRecords: state.keyRecords.filter((k) => k.id !== id),
unlockedKeys,
unlockedDecryptionKeys,
unlockedLegacyDecryptionKeys,
identityKeyBindings: bindings,
accountPreferences,
};
});
},
@@ -316,7 +377,7 @@ export const useSmimeStore = create<SmimeStore>()(
const record = get().keyRecords.find((k) => k.id === id);
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) {
rememberUnlockedKey(id, passphrase);
}
@@ -327,7 +388,11 @@ export const useSmimeStore = create<SmimeStore>()(
if (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);
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
unlockedDecryptionKeys.delete(id);
return { unlockedKeys, unlockedDecryptionKeys };
const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys);
unlockedLegacyDecryptionKeys.delete(id);
return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys };
});
},
lockAllKeys: () => {
clearRememberedUnlocks();
set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map() });
set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), unlockedLegacyDecryptionKeys: new Map() });
},
getKeyRecordForIdentity: (identityId) => {
@@ -378,13 +445,32 @@ export const useSmimeStore = create<SmimeStore>()(
},
setSignDefault: (identityId, value) => {
set((state) => ({
defaultSignIdentity: { ...state.defaultSignIdentity, [identityId]: value },
}));
set((state) => {
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) => {
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) => {
@@ -403,17 +489,42 @@ export const useSmimeStore = create<SmimeStore>()(
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 }),
}),
{
name: 'smime-preferences',
partialize: (state): SmimePersistedState => ({
identityKeyBindings: state.identityKeyBindings,
defaultSignIdentity: state.defaultSignIdentity,
defaultEncrypt: state.defaultEncrypt,
accountPreferences: state.accountPreferences,
rememberUnlockedKeys: state.rememberUnlockedKeys,
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 type { CalendarTask } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { debug } from '@/lib/debug';
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
@@ -36,18 +37,29 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
setShowCompleted: (show) => set({ showCompleted: show }),
fetchTasks: async (client, calendarIds) => {
debug.log('TaskStore/fetchTasks start', { calendarIds: calendarIds || 'all' });
set({ isLoading: true, error: null });
try {
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 });
} catch (error) {
console.error('Failed to fetch tasks:', error);
debug.error('TaskStore/fetchTasks failed', error);
set({ isLoading: false, error: 'Failed to fetch tasks' });
}
},
createTask: async (client, task) => {
debug.log('TaskStore/createTask', 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] });
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