Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01c8afbfe8 | ||
|
|
be2e0f2b68 | ||
|
|
aa40c8be26 | ||
|
|
b3d4c9241c | ||
|
|
34dd5122b3 | ||
|
|
dab3606b04 | ||
|
|
0f7638055c | ||
|
|
66fe7fd359 | ||
|
|
1b2ee7da3a | ||
|
|
f6bec519f4 | ||
|
|
7102add194 | ||
|
|
a3d894730b | ||
|
|
79b99ed4f8 | ||
|
|
68214c3e91 | ||
|
|
5da0e2bdf1 | ||
|
|
92ada0460a | ||
|
|
1cce5c3c8a | ||
|
|
40fd7799e9 | ||
|
|
aaa283357e | ||
|
|
066ab1bc32 | ||
|
|
92fb9bf132 | ||
|
|
4c804d5d2b | ||
|
|
63b464641b | ||
|
|
67210c9924 | ||
|
|
8937777bcd | ||
|
|
789d211a71 | ||
|
|
c512352f77 | ||
|
|
c859230862 | ||
|
|
0fb8b81acd | ||
|
|
1eebec292a | ||
|
|
2d983c9853 | ||
|
|
a453f7fa67 | ||
|
|
4c853f176b | ||
|
|
5583d95ecc | ||
|
|
9d1302ef0a | ||
|
|
75b5d31414 | ||
|
|
1e90885f3f | ||
|
|
02577c7502 | ||
|
|
375c0a7339 | ||
|
|
99d5276cfa | ||
|
|
60b2cf5911 | ||
|
|
8d7cd26fa9 | ||
|
|
1ba8e2fd47 | ||
|
|
c7c22bd210 | ||
|
|
6e0c79ca2c | ||
|
|
9140110435 | ||
|
|
e49f7c1a75 | ||
|
|
098127148e | ||
|
|
308adf0101 | ||
|
|
574bd7ecef | ||
|
|
d6b0714b4e | ||
|
|
af8ea8349e | ||
|
|
8eeabfc995 | ||
|
|
ddb636ed73 | ||
|
|
92ff5fe449 |
+8
-1
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.11 (2026-03-31)
|
||||
|
||||
### Features
|
||||
|
||||
- **Logging**: Add logging categories for better log management
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Security**: Harden security with CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, and PDF iframe sandbox
|
||||
- **Security**: Harden proxy authentication and SSRF defenses
|
||||
- **Security**: Block plugins with dangerous JS patterns and enforce strict session secret length validation
|
||||
- **S/MIME**: Add self-signed certificate detection and update status messages for S/MIME signatures
|
||||
- **Email**: Auto-focus input fields in email composer for improved user experience (#126)
|
||||
- **Mailbox**: Prevent orphaning of nested mailboxes by restricting deduplication to root-level folders
|
||||
- **JMAP**: Strip server-immutable fields from updates before sending to JMAP (#128)
|
||||
- **Files**: Update file feature disabled messages and add stability warnings
|
||||
- **i18n**: Add missing translation keys to all non-English locales
|
||||
|
||||
## 1.4.10 (2026-03-31)
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin
|
||||
- **Calendar**: Implement client-side recurrence expansion for calendar events
|
||||
- **Calendar**: Add iCal subscription editing and batch event import
|
||||
- **Calendar**: Add hover preview settings and functionality
|
||||
- **Calendar**: Add virtual location input for calendar events (#121)
|
||||
- **Email**: Add reply-to addresses support in email composer
|
||||
- **Email**: Add mail layout settings and update email list components
|
||||
- **Email**: Add auto-select reply identity feature with settings and localization
|
||||
- **Email**: Enhance compose functionality with button integration and translations
|
||||
- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts
|
||||
- **Filters**: Skip server-managed vacation script in Sieve script handling
|
||||
- **Settings**: Add support for custom JMAP server endpoints in login and settings
|
||||
- **Settings**: Add folder expansion state management and settings navigation
|
||||
- **UI**: Add options to hide account switcher and show account avatars on navigation rail
|
||||
- **i18n**: Add JMAP server endpoint labels and hints in multiple languages
|
||||
- **i18n**: Add missing translation keys to all non-English locales
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Security**: Patch critical auth bypass and credential leak vulnerabilities
|
||||
- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35)
|
||||
- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35)
|
||||
- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116)
|
||||
- **Calendar**: Enhance calendar event handling to distinguish between events and tasks
|
||||
- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113)
|
||||
- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113)
|
||||
- **Calendar**: Fix events disappearing after iCal import/subscription refresh
|
||||
- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments
|
||||
- **Calendar**: Use UTC timestamps for timed event rendering
|
||||
- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query
|
||||
- **Email**: Enhance email loading and deduplication logic in email store (#119)
|
||||
- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60)
|
||||
- **Email**: Match hover action background to selected row state
|
||||
- **Email**: Align tag counts with mailbox folder counts in sidebar
|
||||
- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117)
|
||||
- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118)
|
||||
- **UI**: Improve dark mode handling for media elements and background images
|
||||
- **UI**: Adjust account list spacing and remove push connection indicator
|
||||
- **UI**: Fix nested button in theme card
|
||||
|
||||
## 1.4.9 (2026-03-27)
|
||||
|
||||
### Features
|
||||
|
||||
+1
-1
@@ -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?
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
@@ -66,9 +67,9 @@ export default function CalendarPage() {
|
||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||
refreshAllSubscriptions,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
|
||||
const taskStore = useTaskStore();
|
||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||
const { identities } = useIdentityStore();
|
||||
@@ -82,6 +83,7 @@ export default function CalendarPage() {
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||
@@ -337,18 +339,28 @@ export default function CalendarPage() {
|
||||
|
||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||
if (isMobile) return;
|
||||
if (calendarHoverPreview === 'off') return;
|
||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||
// Don't show hover popover if the sidebar is already open for this event
|
||||
if (showEventModal && editEvent?.id === event.id) return;
|
||||
setDetailEvent(event);
|
||||
setDetailAnchorRect(anchorRect);
|
||||
}, [isMobile, showEventModal, editEvent]);
|
||||
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
|
||||
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setDetailEvent(event);
|
||||
setDetailAnchorRect(anchorRect);
|
||||
}, ms);
|
||||
} else {
|
||||
setDetailEvent(event);
|
||||
setDetailAnchorRect(anchorRect);
|
||||
}
|
||||
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
|
||||
|
||||
const handleHoverLeave = useCallback(() => {
|
||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setDetailEvent(null);
|
||||
setDetailAnchorRect(null);
|
||||
}, 200);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleEditFromDetail = useCallback(() => {
|
||||
@@ -385,12 +397,16 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [client, fetchEvents]);
|
||||
|
||||
const focusCalendarOnEvent = useCallback((event: Pick<CalendarEvent, "start">) => {
|
||||
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||
if (!event.start) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventDate = parseISO(event.start);
|
||||
const eventDate = getEventStartDate({
|
||||
start: event.start,
|
||||
utcStart: event.utcStart ?? null,
|
||||
showWithoutTime: event.showWithoutTime ?? false,
|
||||
});
|
||||
if (Number.isNaN(eventDate.getTime())) {
|
||||
return;
|
||||
}
|
||||
@@ -749,7 +765,7 @@ export default function CalendarPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
debug.log('Calendar visibility summary', {
|
||||
debug.log('calendar', 'Calendar visibility summary', {
|
||||
totalEvents: events.length,
|
||||
visibleEvents: visibleEvents.length,
|
||||
hiddenEvents: hiddenEvents.length,
|
||||
@@ -873,7 +889,7 @@ export default function CalendarPage() {
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex flex-col overflow-hidden">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{viewContent}
|
||||
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||
@@ -933,6 +949,7 @@ export default function CalendarPage() {
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : undefined}
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||
client={client}
|
||||
/>
|
||||
</div>
|
||||
@@ -1099,6 +1116,18 @@ export default function CalendarPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSubscription && client && (() => {
|
||||
const sub = icalSubscriptions.find(s => s.id === editingSubscription);
|
||||
if (!sub) return null;
|
||||
return (
|
||||
<ICalSubscriptionModal
|
||||
client={client}
|
||||
editSubscription={sub}
|
||||
onClose={() => setEditingSubscription(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<RecurrenceScopeDialog
|
||||
isOpen={!!pendingScopeAction}
|
||||
|
||||
@@ -17,15 +17,18 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { FileBrowser } from "@/components/files/file-browser";
|
||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
export default function FilesPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("files");
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
@@ -393,11 +396,24 @@ export default function FilesPage() {
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{supportsFiles === false ? (
|
||||
{!filesEnabled ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="max-w-lg text-center space-y-3 px-4">
|
||||
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||
<p className="text-sm font-medium">{t("disabled_title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : supportsFiles === false ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
|
||||
</div>
|
||||
<FileBrowser
|
||||
currentPath={currentPath}
|
||||
resources={resources}
|
||||
@@ -442,6 +458,7 @@ export default function FilesPage() {
|
||||
onToggleDetails={handleToggleDetails}
|
||||
detailResource={detailResource}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||
|
||||
const APP_VERSION = "1.4.9";
|
||||
const APP_VERSION = "1.4.10";
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||
@@ -32,13 +32,14 @@ export default function LoginPage() {
|
||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = useConfig();
|
||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [showTotpField, setShowTotpField] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
@@ -73,6 +74,12 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [appName, serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverUrl && !jmapEndpoint) {
|
||||
setJmapEndpoint(serverUrl);
|
||||
}
|
||||
}, [serverUrl, jmapEndpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||
@@ -91,6 +98,14 @@ export default function LoginPage() {
|
||||
prevError.current = error;
|
||||
}, [error]);
|
||||
|
||||
// Auto-show and focus TOTP field when server requires it
|
||||
useEffect(() => {
|
||||
if (error === 'totp_required') {
|
||||
setShowTotpField(true);
|
||||
setTimeout(() => totpInputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
const saved = localStorage.getItem("webmail_usernames");
|
||||
@@ -264,7 +279,7 @@ export default function LoginPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!serverUrl && !demoMode) {
|
||||
if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||
<div className="w-full max-w-md mx-auto px-4 text-center">
|
||||
@@ -362,7 +377,7 @@ export default function LoginPage() {
|
||||
|
||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||
sessionStorage.setItem("oauth_state", state);
|
||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
||||
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
|
||||
if (isAddAccountMode) {
|
||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||
}
|
||||
@@ -382,8 +397,9 @@ export default function LoginPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
|
||||
const success = await login(
|
||||
serverUrl,
|
||||
effectiveServerUrl,
|
||||
formData.username,
|
||||
formData.password,
|
||||
totpCode || undefined,
|
||||
@@ -749,6 +765,27 @@ export default function LoginPage() {
|
||||
/* Login Form */
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<fieldset disabled={isLoading} className="space-y-4">
|
||||
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
|
||||
{allowCustomJmapEndpoint && (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
||||
{t("jmap_endpoint_label")}
|
||||
</label>
|
||||
<Input
|
||||
id="jmap-endpoint"
|
||||
type="url"
|
||||
value={jmapEndpoint}
|
||||
onChange={(e) => setJmapEndpoint(e.target.value)}
|
||||
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||
placeholder={t("jmap_endpoint_placeholder")}
|
||||
required
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t("jmap_endpoint_cors_hint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username field */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
||||
@@ -861,7 +898,10 @@ export default function LoginPage() {
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
|
||||
className={cn(
|
||||
"h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest",
|
||||
error === 'totp_required' && "border-primary ring-2 ring-primary/30"
|
||||
)}
|
||||
placeholder={t("totp_placeholder")}
|
||||
autoComplete="one-time-code"
|
||||
aria-label={t("totp_label")}
|
||||
|
||||
+54
-35
@@ -30,6 +30,7 @@ import {
|
||||
ComposerErrorFallback,
|
||||
} from "@/components/error";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
@@ -53,6 +54,7 @@ export default function Home() {
|
||||
const t = useTranslations();
|
||||
const tCommon = useTranslations('common');
|
||||
const { appName } = useConfig();
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
@@ -350,13 +352,13 @@ export default function Home() {
|
||||
|
||||
if (pushEnabled) {
|
||||
setPushConnected(true);
|
||||
debug.log('[Push] Push notifications successfully enabled');
|
||||
debug.log('push', '[Push] Push notifications successfully enabled');
|
||||
} else {
|
||||
debug.log('[Push] Push notifications not available on this server');
|
||||
debug.log('push', '[Push] Push notifications not available on this server');
|
||||
}
|
||||
} catch (error) {
|
||||
// Push notifications are optional - don't break the app if they fail
|
||||
debug.log('[Push] Failed to setup push notifications:', error);
|
||||
debug.log('push', '[Push] Failed to setup push notifications:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading email data:', error);
|
||||
@@ -390,7 +392,7 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
// Clear any existing timeout when email changes
|
||||
if (markAsReadTimeoutRef.current) {
|
||||
debug.log('[Mark as Read] Clearing previous timeout');
|
||||
debug.log('email', '[Mark as Read] Clearing previous timeout');
|
||||
clearTimeout(markAsReadTimeoutRef.current);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}
|
||||
@@ -402,20 +404,20 @@ export default function Home() {
|
||||
|
||||
// Get current setting value
|
||||
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
||||
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
||||
debug.log('email', '[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
||||
|
||||
if (markAsReadDelay === -1) {
|
||||
// Never mark as read automatically
|
||||
debug.log('[Mark as Read] Never mode - email will stay unread');
|
||||
debug.log('email', '[Mark as Read] Never mode - email will stay unread');
|
||||
} else if (markAsReadDelay === 0) {
|
||||
// Mark as read instantly
|
||||
debug.log('[Mark as Read] Instant mode - marking as read now');
|
||||
debug.log('email', '[Mark as Read] Instant mode - marking as read now');
|
||||
markAsRead(client, selectedEmail.id, true);
|
||||
} else {
|
||||
// Mark as read after delay
|
||||
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
|
||||
debug.log('email', '[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
|
||||
markAsReadTimeoutRef.current = setTimeout(() => {
|
||||
debug.log('[Mark as Read] Timeout fired - marking as read now');
|
||||
debug.log('email', '[Mark as Read] Timeout fired - marking as read now');
|
||||
markAsRead(client, selectedEmail.id, true);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}, markAsReadDelay);
|
||||
@@ -424,7 +426,7 @@ export default function Home() {
|
||||
// Cleanup on unmount or when dependencies change
|
||||
return () => {
|
||||
if (markAsReadTimeoutRef.current) {
|
||||
debug.log('[Mark as Read] Cleanup - clearing timeout');
|
||||
debug.log('email', '[Mark as Read] Cleanup - clearing timeout');
|
||||
clearTimeout(markAsReadTimeoutRef.current);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}
|
||||
@@ -439,7 +441,7 @@ export default function Home() {
|
||||
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||
playNotificationSound(notificationSoundChoice);
|
||||
}
|
||||
debug.log('New email received:', newEmailNotification.subject);
|
||||
debug.log('email', 'New email received:', newEmailNotification.subject);
|
||||
clearNewEmailNotification();
|
||||
}
|
||||
}, [newEmailNotification, clearNewEmailNotification]);
|
||||
@@ -519,7 +521,10 @@ export default function Home() {
|
||||
|
||||
const handleEditDraft = async (email?: Email) => {
|
||||
if (!client) return;
|
||||
let draft = email || selectedEmail;
|
||||
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
|
||||
? email
|
||||
: selectedEmail;
|
||||
let draft = draftCandidate;
|
||||
if (!draft) return;
|
||||
|
||||
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
|
||||
@@ -967,6 +972,10 @@ export default function Home() {
|
||||
|
||||
// Get current mailbox name for mobile header
|
||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
||||
|
||||
// Handle email selection with mobile view switching
|
||||
const handleEmailSelect = async (email: { id: string }) => {
|
||||
@@ -1017,6 +1026,9 @@ export default function Home() {
|
||||
setConversationEmails([]);
|
||||
}
|
||||
selectEmail(null);
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
setActiveView("list");
|
||||
};
|
||||
|
||||
@@ -1199,10 +1211,9 @@ export default function Home() {
|
||||
// Tablet/Desktop: fixed width with collapse animation
|
||||
"md:flex-shrink-0 md:shadow-sm",
|
||||
!isResizing && "transition-all duration-200 ease-out",
|
||||
// Tablet: collapse when email selected
|
||||
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||
)}
|
||||
style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined}
|
||||
style={!isMobile && !shouldCollapseListPane ? { width: emailListWidth } : undefined}
|
||||
>
|
||||
{/* Mobile Header for List View */}
|
||||
<MobileHeader
|
||||
@@ -1487,24 +1498,27 @@ export default function Home() {
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Floating Compose Button (mobile) */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
setActiveView('viewer');
|
||||
}}
|
||||
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
|
||||
aria-label={t('sidebar.compose')}
|
||||
>
|
||||
<PenSquare className="h-6 w-6" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Floating Compose Button */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
}}
|
||||
className={cn(
|
||||
"absolute z-40 rounded-full shadow-lg",
|
||||
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
|
||||
)}
|
||||
aria-label={t('sidebar.compose')}
|
||||
title={t('sidebar.compose_hint')}
|
||||
data-tour="compose-button"
|
||||
>
|
||||
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Email list resize handle (desktop only) */}
|
||||
{!isMobile && !isTablet && (
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||
@@ -1521,7 +1535,8 @@ export default function Home() {
|
||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||
// Tablet/Desktop: relative
|
||||
"md:relative"
|
||||
"md:relative",
|
||||
shouldHideViewerPane && "md:hidden"
|
||||
)}
|
||||
>
|
||||
{/* Inline Composer - shown in viewer pane */}
|
||||
@@ -1537,8 +1552,10 @@ export default function Home() {
|
||||
mode={pendingDraft?.mode ?? composerMode}
|
||||
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
@@ -1648,14 +1665,15 @@ export default function Home() {
|
||||
}}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
onBack={() => {
|
||||
setTabletListVisible(true);
|
||||
selectEmail(null);
|
||||
}}
|
||||
onBack={handleMobileBack}
|
||||
onNavigateNext={handleNavigateNext}
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onEditDraft={handleEditDraft}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
}}
|
||||
currentUserEmail={client?.getUsername()}
|
||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||
@@ -1709,6 +1727,7 @@ export default function Home() {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<TotpReauthDialog />
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
@@ -78,13 +79,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
}, [pathname]);
|
||||
|
||||
function getJmapHeaders(): Record<string, string> {
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return {};
|
||||
return {
|
||||
'Authorization': client.getAuthHeader(),
|
||||
'X-JMAP-Server-URL': client.getServerUrl(),
|
||||
'X-JMAP-Username': client.getUsername(),
|
||||
};
|
||||
return getActiveAccountSlotHeaders();
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)'}
|
||||
|
||||
@@ -19,6 +19,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
@@ -28,10 +29,12 @@ const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
||||
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
||||
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
||||
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
@@ -69,10 +70,19 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// If session cookie exists, update it with the new password
|
||||
const cookieStore = await cookies();
|
||||
|
||||
if (creds.hasSessionCookie) {
|
||||
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
|
||||
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
|
||||
}
|
||||
|
||||
if (creds.authHeader.startsWith('Basic ')) {
|
||||
setStalwartAuthContextInStore(cookieStore, creds.slot, {
|
||||
serverUrl: creds.serverUrl,
|
||||
username: creds.username,
|
||||
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const code = await jsFile.async('string');
|
||||
|
||||
// Block plugins with dangerous JS patterns
|
||||
const DANGEROUS_JS_PATTERNS = [
|
||||
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||
{ pattern: /document\.write/g, label: 'document.write' },
|
||||
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||
];
|
||||
const dangerousFindings: string[] = [];
|
||||
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
|
||||
if (pattern.test(code)) dangerousFindings.push(label);
|
||||
pattern.lastIndex = 0;
|
||||
}
|
||||
if (dangerousFindings.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate permissions
|
||||
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
||||
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -139,12 +139,18 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
const code = await entryFile.async('string');
|
||||
|
||||
// Security warnings (logged but not blocking for admin)
|
||||
// Security: block plugins containing dangerous JS patterns
|
||||
const warnings: string[] = [];
|
||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||
pattern.lastIndex = 0;
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const plugin: ServerPlugin = {
|
||||
@@ -157,14 +163,17 @@ export async function POST(request: NextRequest) {
|
||||
permissions: (manifest.permissions as string[]) || [],
|
||||
entrypoint: manifest.entrypoint as string,
|
||||
enabled: true,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip);
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
|
||||
|
||||
return NextResponse.json({ plugin, warnings });
|
||||
return NextResponse.json({ plugin });
|
||||
} catch (error) {
|
||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
||||
@@ -4,6 +4,11 @@ import { logger } from '@/lib/logger';
|
||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import {
|
||||
clearStalwartAuthContextInStore,
|
||||
setStalwartAuthContextInStore,
|
||||
} from '@/lib/stalwart/auth-context';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
...getCookieOptions(),
|
||||
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
const cookieName = sessionCookieName(slot);
|
||||
const token = encryptSession(serverUrl, username, password);
|
||||
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||
const token = encryptSession(normalizedServerUrl, username, password);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
authHeader,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
@@ -56,9 +72,66 @@ export async function GET(request: NextRequest) {
|
||||
const credentials = decryptSession(token);
|
||||
if (!credentials) {
|
||||
cookieStore.delete(cookieName);
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||
}
|
||||
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||
});
|
||||
|
||||
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
||||
const { serverUrl, username } = credentials;
|
||||
return NextResponse.json(
|
||||
{ serverUrl, username },
|
||||
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT — retrieve full credentials (including password) for session restoration.
|
||||
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
||||
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
// Require all Sec-Fetch-* headers to match a same-origin fetch() call.
|
||||
// Browsers set these automatically and they cannot be overridden by JS.
|
||||
const secFetchSite = request.headers.get('sec-fetch-site');
|
||||
const secFetchMode = request.headers.get('sec-fetch-mode');
|
||||
const secFetchDest = request.headers.get('sec-fetch-dest');
|
||||
if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const slot = getSlot(request);
|
||||
const cookieName = sessionCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(cookieName)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||
}
|
||||
|
||||
const credentials = decryptSession(token);
|
||||
if (!credentials) {
|
||||
cookieStore.delete(cookieName);
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||
}
|
||||
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||
});
|
||||
|
||||
return NextResponse.json(credentials, {
|
||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
||||
});
|
||||
@@ -77,10 +150,12 @@ export async function DELETE(request: NextRequest) {
|
||||
// Delete all session cookies (slots 0-4)
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
cookieStore.delete(sessionCookieName(i));
|
||||
clearStalwartAuthContextInStore(cookieStore, i);
|
||||
}
|
||||
} else {
|
||||
const slot = getSlot(request);
|
||||
cookieStore.delete(sessionCookieName(slot));
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
|
||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||
return bodySlot;
|
||||
}
|
||||
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
if (raw === null) return 0;
|
||||
|
||||
const slot = parseInt(raw, 10);
|
||||
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
|
||||
|
||||
if (!serverUrl || !username || !authHeader) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = getSlot(request, bodySlot);
|
||||
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||
|
||||
await setStalwartAuthContext(slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
authHeader,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
logger.error('Failed to store Stalwart auth context', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export async function GET() {
|
||||
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
|
||||
+86
-21
@@ -1,9 +1,39 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
function isValidExternalUrl(urlString: string): boolean {
|
||||
const blockedAddressRanges = new BlockList();
|
||||
blockedAddressRanges.addAddress('0.0.0.0');
|
||||
blockedAddressRanges.addAddress('127.0.0.1');
|
||||
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
||||
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
||||
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
||||
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
||||
blockedAddressRanges.addAddress('::', 'ipv6');
|
||||
blockedAddressRanges.addAddress('::1', 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
||||
}
|
||||
|
||||
function isBlockedIpAddress(hostname: string): boolean {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const family = isIP(normalized);
|
||||
if (family === 4) {
|
||||
return blockedAddressRanges.check(normalized, 'ipv4');
|
||||
}
|
||||
if (family === 6) {
|
||||
return blockedAddressRanges.check(normalized, 'ipv6');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isValidExternalUrl(urlString: string): Promise<boolean> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
|
||||
// Block private/internal hostnames
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
hostname.endsWith('.localhost') ||
|
||||
hostname.endsWith('.local') ||
|
||||
hostname.endsWith('.internal') ||
|
||||
hostname.endsWith('.arpa') ||
|
||||
hostname.startsWith('10.') ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('169.254.') ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
||||
hostname.endsWith('.localdomain')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
if (isBlockedIpAddress(hostname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIP(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await lookup(hostname, { all: true, verbatim: true });
|
||||
if (records.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return records.every((record) => !isBlockedIpAddress(record.address));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isValidExternalUrl(url)) {
|
||||
if (!(await isValidExternalUrl(url))) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -64,20 +106,43 @@ export async function POST(request: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
if (!(await isValidExternalUrl(currentUrl))) {
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
response = await fetch(currentUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
},
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) {
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||
}
|
||||
// Resolve relative redirects
|
||||
currentUrl = new URL(location, currentUrl).toString();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response || !response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Remote server returned ${response.status}` },
|
||||
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,16 +53,14 @@ function isEnabled(): boolean {
|
||||
/**
|
||||
* Verify identity against session cookies across all account slots.
|
||||
* With multi-account, the requesting account may be on any slot (0-4).
|
||||
* Returns true if any slot matches OR if no session cookies exist at all.
|
||||
* Returns true only if a matching session cookie is found.
|
||||
*/
|
||||
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
let hasAnyCookie = false;
|
||||
|
||||
for (let slot = 0; slot <= 4; slot++) {
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (!token) continue;
|
||||
hasAnyCookie = true;
|
||||
|
||||
const session = decryptSession(token);
|
||||
if (session && session.username === username && session.serverUrl === serverUrl) {
|
||||
@@ -70,10 +68,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
|
||||
}
|
||||
}
|
||||
|
||||
// No cookies at all → can't verify, allow (same-origin protection applies)
|
||||
if (!hasAnyCookie) return true;
|
||||
|
||||
// Cookies exist but none matched → identity mismatch
|
||||
// No matching session found (or no cookies at all) → reject
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+32
-8
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
|
||||
|
||||
function normalizeDavRelativePath(rawPath: string): string {
|
||||
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
|
||||
const segments = sanitized.split('/').filter(Boolean);
|
||||
|
||||
return segments.map((segment) => {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(segment);
|
||||
} catch {
|
||||
throw new Error('Invalid WebDAV path encoding');
|
||||
}
|
||||
|
||||
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
|
||||
throw new Error('Invalid WebDAV path segment');
|
||||
}
|
||||
|
||||
return encodeURIComponent(decoded);
|
||||
}).join('/');
|
||||
}
|
||||
|
||||
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
|
||||
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
|
||||
const relativePath = normalizeDavRelativePath(rawPath);
|
||||
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webdav
|
||||
* Proxies WebDAV requests to the Stalwart server.
|
||||
@@ -29,11 +55,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
||||
const cleanPath = davPath.replace(/^\/+/, '');
|
||||
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
||||
const targetUrl = cleanPath
|
||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
|
||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
||||
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
||||
|
||||
// Build headers for the upstream request
|
||||
const upstreamHeaders: Record<string, string> = {
|
||||
@@ -50,10 +73,7 @@ export async function POST(request: NextRequest) {
|
||||
// For MOVE/COPY, construct the full Destination URL from the relative path
|
||||
const destination = request.headers.get('X-WebDAV-Destination');
|
||||
if (destination) {
|
||||
const cleanDest = destination.replace(/^\/+/, '');
|
||||
upstreamHeaders['Destination'] = cleanDest
|
||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
|
||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
||||
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
|
||||
}
|
||||
|
||||
const overwrite = request.headers.get('Overwrite');
|
||||
@@ -104,6 +124,10 @@ export async function POST(request: NextRequest) {
|
||||
status: response.status,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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}` : ""}`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
|
||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||
@@ -76,8 +77,10 @@ interface EmailComposerProps {
|
||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
replyTo?: {
|
||||
from?: { email?: string; name?: string }[];
|
||||
replyToAddresses?: { email?: string; name?: string }[];
|
||||
to?: { email?: string; name?: string }[];
|
||||
cc?: { email?: string; name?: string }[];
|
||||
bcc?: { email?: string; name?: string }[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
htmlBody?: string;
|
||||
@@ -100,17 +103,20 @@ export function EmailComposer({
|
||||
const tCommon = useTranslations('common');
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = () => {
|
||||
if (!replyTo) return "";
|
||||
// RFC 5322: use Reply-To header if present, otherwise fall back to From
|
||||
const replyTarget = replyTo.replyToAddresses?.length
|
||||
? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ")
|
||||
: replyTo.from?.[0]?.email || "";
|
||||
if (mode === 'reply') {
|
||||
const email = replyTo.from?.[0]?.email || "";
|
||||
return email ? email + ', ' : "";
|
||||
return replyTarget ? replyTarget + ', ' : "";
|
||||
} else if (mode === 'replyAll') {
|
||||
const from = replyTo.from?.[0]?.email || "";
|
||||
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
||||
const combined = [from, originalTo].filter(Boolean).join(", ");
|
||||
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
|
||||
return combined ? combined + ', ' : "";
|
||||
}
|
||||
return "";
|
||||
@@ -226,6 +232,31 @@ export function EmailComposer({
|
||||
const currentIdentity = selectedIdentityId
|
||||
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||
: primaryIdentity;
|
||||
useEffect(() => {
|
||||
if (!autoSelectReplyIdentity) return;
|
||||
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||
|
||||
const matchedIdentityId = findReplyIdentityId(identities, {
|
||||
to: replyTo?.to,
|
||||
cc: replyTo?.cc,
|
||||
bcc: replyTo?.bcc,
|
||||
});
|
||||
|
||||
if (matchedIdentityId) {
|
||||
setSelectedIdentityId(matchedIdentityId);
|
||||
}
|
||||
}, [
|
||||
autoSelectReplyIdentity,
|
||||
identities,
|
||||
initialData?.selectedIdentityId,
|
||||
mode,
|
||||
replyTo?.bcc,
|
||||
replyTo?.cc,
|
||||
replyTo?.to,
|
||||
selectedIdentityId,
|
||||
]);
|
||||
|
||||
const composerSignatureHtml = currentIdentity?.htmlSignature
|
||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||
: currentIdentity?.textSignature
|
||||
@@ -301,6 +332,17 @@ export function EmailComposer({
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, []);
|
||||
|
||||
// Auto-focus the To field when composing a new email or forwarding
|
||||
useEffect(() => {
|
||||
if (mode === 'forward' || mode === 'compose') {
|
||||
// Small delay to ensure the input is rendered
|
||||
const timer = setTimeout(() => {
|
||||
toInputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
||||
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||
@@ -308,10 +350,26 @@ export function EmailComposer({
|
||||
const toInputRef = useRef<HTMLInputElement>(null);
|
||||
const ccInputRef = useRef<HTMLInputElement>(null);
|
||||
const bccInputRef = useRef<HTMLInputElement>(null);
|
||||
const subjectInputRef = useRef<HTMLInputElement>(null);
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const toDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const ccDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const bccDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const focusSubject = useCallback(() => {
|
||||
subjectInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const focusBody = useCallback(() => {
|
||||
if (plainTextMode) {
|
||||
bodyRef.current?.focus();
|
||||
} else {
|
||||
const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null;
|
||||
proseMirror?.focus();
|
||||
}
|
||||
}, [plainTextMode]);
|
||||
|
||||
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
|
||||
if (autocompleteTimeoutRef.current) {
|
||||
clearTimeout(autocompleteTimeoutRef.current);
|
||||
@@ -1040,6 +1098,7 @@ export function EmailComposer({
|
||||
onInsertAutocomplete={insertAutocomplete}
|
||||
validationError={validationErrors.to}
|
||||
validationMessage={t('validation.recipient_required')}
|
||||
onTab={focusSubject}
|
||||
/>
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
<Button
|
||||
@@ -1109,6 +1168,7 @@ export function EmailComposer({
|
||||
<div className="flex items-center gap-2 px-4 py-2.5">
|
||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
|
||||
<Input
|
||||
ref={subjectInputRef}
|
||||
type="text"
|
||||
placeholder={t('subject_placeholder')}
|
||||
value={subject}
|
||||
@@ -1116,6 +1176,12 @@ export function EmailComposer({
|
||||
setSubject(e.target.value);
|
||||
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Tab' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
focusBody();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
|
||||
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
|
||||
@@ -1128,6 +1194,7 @@ export function EmailComposer({
|
||||
{/* Body */}
|
||||
{plainTextMode ? (
|
||||
<textarea
|
||||
ref={bodyRef}
|
||||
value={body}
|
||||
onChange={(e) => {
|
||||
setBody(e.target.value);
|
||||
@@ -1142,16 +1209,18 @@ export function EmailComposer({
|
||||
aria-invalid={validationErrors.body || undefined}
|
||||
/>
|
||||
) : (
|
||||
<RichTextEditor
|
||||
content={body}
|
||||
onChange={(html) => {
|
||||
setBody(html);
|
||||
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||
}}
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
/>
|
||||
<div ref={editorContainerRef}>
|
||||
<RichTextEditor
|
||||
content={body}
|
||||
onChange={(html) => {
|
||||
setBody(html);
|
||||
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||
}}
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plainTextMode ? (
|
||||
@@ -1483,6 +1552,7 @@ function RecipientChipInput({
|
||||
onInsertAutocomplete,
|
||||
validationError,
|
||||
validationMessage,
|
||||
onTab,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -1499,6 +1569,7 @@ function RecipientChipInput({
|
||||
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
|
||||
validationError?: boolean;
|
||||
validationMessage?: string;
|
||||
onTab?: () => void;
|
||||
}) {
|
||||
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const hasTrailingComma = value.trimEnd().endsWith(',');
|
||||
@@ -1532,7 +1603,18 @@ function RecipientChipInput({
|
||||
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
|
||||
if (e.key !== 'Tab') e.preventDefault();
|
||||
commitCurrentInput();
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
if (e.key === 'Tab' && onTab) {
|
||||
e.preventDefault();
|
||||
setTimeout(() => onTab(), 0);
|
||||
} else {
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Tab' && !e.shiftKey && onTab) {
|
||||
e.preventDefault();
|
||||
onTab();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
HelpCircle,
|
||||
EditIcon,
|
||||
PlayCircle,
|
||||
PenSquare,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
@@ -121,6 +122,7 @@ interface EmailViewerProps {
|
||||
onNavigatePrev?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCompose?: () => void;
|
||||
currentUserEmail?: string;
|
||||
currentUserName?: string;
|
||||
currentMailboxRole?: string;
|
||||
@@ -865,6 +867,7 @@ export function EmailViewer({
|
||||
onNavigatePrev,
|
||||
onShowShortcuts,
|
||||
onEditDraft,
|
||||
onCompose,
|
||||
currentUserEmail,
|
||||
currentUserName,
|
||||
currentMailboxRole,
|
||||
@@ -887,8 +890,10 @@ export function EmailViewer({
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
// Detect if current mailbox is Junk folder
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
@@ -906,7 +911,7 @@ export function EmailViewer({
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode } = useAuthStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const { startTour } = useTour();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
@@ -960,9 +965,9 @@ export function EmailViewer({
|
||||
|
||||
// Ensure S/MIME key records are loaded from IndexedDB
|
||||
useLayoutEffect(() => {
|
||||
smimeStore.load();
|
||||
smimeStore.load(activeAccountId ?? undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [activeAccountId]);
|
||||
|
||||
// Build mailbox tree for move-to dropdown
|
||||
const moveTargetIds = useMemo(() => new Set(
|
||||
@@ -1540,8 +1545,25 @@ export function EmailViewer({
|
||||
|
||||
if (detection.type === 'enveloped-data') {
|
||||
// Encrypted message
|
||||
const { keyRecords, unlockedDecryptionKeys } = smimeStore;
|
||||
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
|
||||
const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
|
||||
smimeDebug('[S/MIME] decrypt attempt:', {
|
||||
keyRecordCount: keyRecords.length,
|
||||
unlockedKeyCount: unlockedDecryptionKeys.size,
|
||||
legacyKeyCount: unlockedLegacyDecryptionKeys.size,
|
||||
keyRecordIds: keyRecords.map(k => k.id),
|
||||
});
|
||||
|
||||
// Short-circuit: no keys imported at all
|
||||
if (keyRecords.length === 0) {
|
||||
smimeDebug('[S/MIME] no key records available, skipping decrypt');
|
||||
setSmimeStatus({
|
||||
isSigned: false,
|
||||
isEncrypted: true,
|
||||
decryptionError: 'no-key',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
||||
let lastError: unknown = null;
|
||||
@@ -1552,6 +1574,7 @@ export function EmailViewer({
|
||||
cmsBytes: candidate.normalized,
|
||||
keyRecords,
|
||||
unlockedKeys: unlockedDecryptionKeys,
|
||||
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
|
||||
});
|
||||
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
||||
source: candidate.source,
|
||||
@@ -1633,8 +1656,15 @@ export function EmailViewer({
|
||||
if (!existing) {
|
||||
try {
|
||||
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
|
||||
} catch { /* ignore import errors */ }
|
||||
smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint });
|
||||
} catch (importErr) {
|
||||
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||
}
|
||||
} else {
|
||||
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||
}
|
||||
} else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) {
|
||||
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email });
|
||||
}
|
||||
} catch (error) {
|
||||
smimeError('[S/MIME] nested signature verify failed:', {
|
||||
@@ -1672,10 +1702,12 @@ export function EmailViewer({
|
||||
decryptionError: 'locked',
|
||||
});
|
||||
} else {
|
||||
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
|
||||
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
|
||||
setSmimeStatus({
|
||||
isSigned: false,
|
||||
isEncrypted: true,
|
||||
decryptionError: err instanceof Error ? err.message : 'Decryption failed',
|
||||
decryptionError: isNoKeyError ? 'no-key' : errMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1737,8 +1769,15 @@ export function EmailViewer({
|
||||
if (!existing) {
|
||||
try {
|
||||
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
|
||||
} catch { /* ignore import errors */ }
|
||||
smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint });
|
||||
} catch (importErr) {
|
||||
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||
}
|
||||
} else {
|
||||
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||
}
|
||||
} else if (result.status.signatureValid && result.status.signerCert) {
|
||||
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email });
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
@@ -1771,6 +1810,7 @@ export function EmailViewer({
|
||||
smimeStore.autoImportSignerCerts,
|
||||
smimeStore.keyRecords,
|
||||
smimeStore.unlockedDecryptionKeys,
|
||||
smimeStore.unlockedLegacyDecryptionKeys,
|
||||
smimeStore,
|
||||
]);
|
||||
|
||||
@@ -1780,12 +1820,12 @@ export function EmailViewer({
|
||||
|
||||
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
||||
if (!tnefAtt?.blobId) {
|
||||
debug.log('TNEF: No winmail.dat attachment found in email', email?.id);
|
||||
debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id);
|
||||
return;
|
||||
}
|
||||
|
||||
debug.group('TNEF Processing');
|
||||
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
||||
debug.group('TNEF Processing', 'email');
|
||||
debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
||||
|
||||
// Check if the email already has a usable HTML body with real content
|
||||
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||
@@ -1795,46 +1835,46 @@ export function EmailViewer({
|
||||
let hasRealHtmlBody = !!htmlValue;
|
||||
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
|
||||
hasRealHtmlBody = false;
|
||||
debug.log('TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
|
||||
debug.log('email', 'TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
|
||||
}
|
||||
if (hasRealHtmlBody) {
|
||||
debug.log('TNEF: Email has real HTML body, will extract attachments only');
|
||||
debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only');
|
||||
} else {
|
||||
debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
|
||||
debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function processTnef() {
|
||||
try {
|
||||
debug.time('TNEF fetch blob');
|
||||
debug.time('TNEF fetch blob', 'email');
|
||||
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
||||
debug.timeEnd('TNEF fetch blob');
|
||||
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||
debug.timeEnd('TNEF fetch blob', 'email');
|
||||
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||
|
||||
if (cancelled) {
|
||||
debug.log('TNEF: Processing cancelled after fetch');
|
||||
debug.log('email', 'TNEF: Processing cancelled after fetch');
|
||||
debug.groupEnd();
|
||||
return;
|
||||
}
|
||||
if (blobBytes.byteLength === 0) {
|
||||
debug.warn('TNEF: Fetched blob is empty (0 bytes)');
|
||||
debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)');
|
||||
debug.groupEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const tnefData = new Uint8Array(blobBytes);
|
||||
debug.time('TNEF parse');
|
||||
debug.time('TNEF parse', 'email');
|
||||
const parsed = parseTnef(tnefData);
|
||||
debug.timeEnd('TNEF parse');
|
||||
debug.timeEnd('TNEF parse', 'email');
|
||||
|
||||
if (cancelled) {
|
||||
debug.log('TNEF: Processing cancelled after parse');
|
||||
debug.log('email', 'TNEF: Processing cancelled after parse');
|
||||
debug.groupEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
||||
debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
||||
|
||||
if (parsed.htmlBody && !hasRealHtmlBody) {
|
||||
setTnefHtml(parsed.htmlBody);
|
||||
@@ -1844,11 +1884,11 @@ export function EmailViewer({
|
||||
}
|
||||
if (parsed.attachments.length > 0) {
|
||||
setTnefAttachments(parsed.attachments);
|
||||
debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
|
||||
debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
|
||||
}
|
||||
|
||||
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
||||
debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
||||
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
||||
}
|
||||
|
||||
debug.groupEnd();
|
||||
@@ -1886,13 +1926,13 @@ export function EmailViewer({
|
||||
const hasRealText = !!textValue;
|
||||
|
||||
if (hasRealHtml || hasRealText) {
|
||||
debug.log('Embedded RFC822: Outer email has real body content, not unwrapping');
|
||||
debug.log('email', 'Embedded RFC822: Outer email has real body content, not unwrapping');
|
||||
return;
|
||||
}
|
||||
|
||||
debug.group('Embedded RFC822 Unwrapping');
|
||||
debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
||||
debug.log('Outer email body is empty, will unwrap embedded email');
|
||||
debug.group('Embedded RFC822 Unwrapping', 'email');
|
||||
debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
|
||||
debug.log('email', 'Outer email body is empty, will unwrap embedded email');
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -1901,7 +1941,7 @@ export function EmailViewer({
|
||||
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
||||
if (cancelled) { debug.groupEnd(); return; }
|
||||
if (blobBytes.byteLength === 0) {
|
||||
debug.warn('Embedded RFC822: Fetched blob is empty');
|
||||
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
|
||||
debug.groupEnd();
|
||||
return;
|
||||
}
|
||||
@@ -1911,7 +1951,7 @@ export function EmailViewer({
|
||||
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
||||
if (cancelled) { debug.groupEnd(); return; }
|
||||
|
||||
debug.log('Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
||||
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
||||
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
||||
', attachments:', parsed.attachments?.length ?? 0);
|
||||
|
||||
@@ -1923,7 +1963,7 @@ export function EmailViewer({
|
||||
}
|
||||
if (parsed.attachments && parsed.attachments.length > 0) {
|
||||
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
|
||||
debug.log('Embedded RFC822 attachments:', parsed.attachments.map(
|
||||
debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map(
|
||||
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
|
||||
).join(', '));
|
||||
}
|
||||
@@ -2526,14 +2566,21 @@ export function EmailViewer({
|
||||
|
||||
// If email has native dark mode, let it handle its own theming
|
||||
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
|
||||
// Re-invert leaf media elements so they appear normal.
|
||||
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
|
||||
// double re-inverting images nested inside those containers.
|
||||
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
||||
html { background: #1a1a1a; }
|
||||
body { filter: invert(1) hue-rotate(180deg); }
|
||||
img, video, picture, svg, canvas, object, embed,
|
||||
[style*="background-image"], [style*="background:"],
|
||||
[background], [bgcolor],
|
||||
td[background], table[background],
|
||||
img[src], input[type="image"] {
|
||||
img, video, svg, canvas, object, embed, input[type="image"] {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||
[background]:not(:has(img, video, svg, canvas, object, embed)),
|
||||
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
|
||||
td[background]:not(:has(img, video, svg, canvas, object, embed)),
|
||||
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
` : '';
|
||||
@@ -2572,11 +2619,35 @@ export function EmailViewer({
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
// Dark mode: re-invert elements with stylesheet-defined background images
|
||||
// (CSS attribute selectors only catch inline styles, not <style> block rules)
|
||||
if (isDark && !emailHasNativeDarkMode) {
|
||||
const win = doc.defaultView;
|
||||
if (win) {
|
||||
doc.body.querySelectorAll('*').forEach(el => {
|
||||
const htmlEl = el as HTMLElement;
|
||||
// Skip elements already handled by CSS attribute selectors
|
||||
if (htmlEl.style.backgroundImage || htmlEl.style.background ||
|
||||
htmlEl.hasAttribute('background') || htmlEl.hasAttribute('bgcolor')) return;
|
||||
// Skip leaf media elements (already re-inverted by CSS)
|
||||
const tag = htmlEl.tagName;
|
||||
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
|
||||
const computed = win.getComputedStyle(htmlEl);
|
||||
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||||
// Only re-invert if this container doesn't have media children
|
||||
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) {
|
||||
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin restrictions - iframe will still display content
|
||||
}
|
||||
}, []);
|
||||
}, [isDark, emailHasNativeDarkMode]);
|
||||
|
||||
// Export email as .eml file
|
||||
const handleExportEmail = async () => {
|
||||
@@ -2775,6 +2846,12 @@ export function EmailViewer({
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||
{onCompose && (
|
||||
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t('compose')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2790,7 +2867,7 @@ export function EmailViewer({
|
||||
<>
|
||||
{/* Left: Reply actions */}
|
||||
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
||||
{showBackButton && isTablet && !tabletListVisible && onBack && (
|
||||
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -2805,7 +2882,7 @@ export function EmailViewer({
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEditDraft}
|
||||
onClick={() => onEditDraft()}
|
||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('tooltips.edit_draft')}
|
||||
>
|
||||
@@ -3524,7 +3601,7 @@ export function EmailViewer({
|
||||
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||
{/* Back button (for below-subject mode on tablet) */}
|
||||
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && (
|
||||
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -4403,7 +4480,7 @@ export function EmailViewer({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onEditDraft}
|
||||
onClick={() => onEditDraft()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<EditIcon className="w-3.5 h-3.5" />
|
||||
@@ -4672,7 +4749,7 @@ export function EmailViewer({
|
||||
</button>
|
||||
{isDraft && onEditDraft ? (
|
||||
<button
|
||||
onClick={onEditDraft}
|
||||
onClick={() => onEditDraft()}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
||||
aria-label={t('tooltips.edit_draft')}
|
||||
>
|
||||
|
||||
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
||||
text: t('unlock_key_desc'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else if (status.decryptionError === 'no-key') {
|
||||
items.push({
|
||||
icon: <Lock className="w-4 h-4" />,
|
||||
text: t('status_encrypted_no_key'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldX className="w-4 h-4" />,
|
||||
@@ -49,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
||||
// Signature status
|
||||
if (status.isSigned) {
|
||||
if (status.signatureValid === true) {
|
||||
if (status.signerEmailMatch === false) {
|
||||
if (status.selfSigned) {
|
||||
items.push({
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
text: t('status_signed_self_signed'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else if (status.signerEmailMatch === false) {
|
||||
items.push({
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
text: t('status_signed_mismatch'),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
sandbox="allow-same-origin"
|
||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||
title={name}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield } from "lucide-react";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check } from "lucide-react";
|
||||
import { AccountSwitcher } from "./account-switcher";
|
||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
@@ -16,8 +16,12 @@ import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { getInitials } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -164,20 +168,60 @@ export function NavigationRail({
|
||||
const { mailboxes } = useEmailStore();
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
|
||||
// Account list for rail
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||
const [logoutMenuOpen, setLogoutMenuOpen] = useState(false);
|
||||
const logoutBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const logoutPopoverRef = useRef<HTMLDivElement>(null);
|
||||
const [logoutPopoverStyle, setLogoutPopoverStyle] = useState<React.CSSProperties>({});
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
|
||||
const updateLogoutPosition = useCallback(() => {
|
||||
if (!logoutBtnRef.current) return;
|
||||
const rect = logoutBtnRef.current.getBoundingClientRect();
|
||||
setLogoutPopoverStyle({
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logoutMenuOpen) return;
|
||||
updateLogoutPosition();
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
logoutBtnRef.current?.contains(e.target as Node) ||
|
||||
logoutPopoverRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setLogoutMenuOpen(false);
|
||||
};
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setLogoutMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [logoutMenuOpen, updateLogoutPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const { client } = useAuthStore.getState();
|
||||
if (!client) return;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': client.getAuthHeader(),
|
||||
'X-JMAP-Server-URL': client.getServerUrl(),
|
||||
'X-JMAP-Username': client.getUsername(),
|
||||
};
|
||||
const headers = getActiveAccountSlotHeaders();
|
||||
if (!headers['X-JMAP-Cookie-Slot']) return;
|
||||
fetch('/api/admin/stalwart-check', { headers })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
@@ -199,7 +243,7 @@ export function NavigationRail({
|
||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
|
||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
|
||||
];
|
||||
|
||||
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||
@@ -504,6 +548,22 @@ export function NavigationRail({
|
||||
<Keyboard className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
{!onShowShortcuts && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowShortcutsModal(true)}
|
||||
data-tour="nav-shortcuts"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={t("keyboard_shortcuts")}
|
||||
>
|
||||
<Keyboard className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<KeyboardShortcutsModal
|
||||
isOpen={showShortcutsModal}
|
||||
onClose={() => setShowShortcutsModal(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{quota && quota.total > 0 && (
|
||||
<div data-tour="storage-quota">
|
||||
@@ -511,21 +571,85 @@ export function NavigationRail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPushConnected != null && (
|
||||
<span
|
||||
className="relative group"
|
||||
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
|
||||
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{onLogout && showRailAccountList && accounts.length > 0 && (
|
||||
<>
|
||||
<div className="w-8 border-t my-1" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
|
||||
|
||||
{/* Account circles */}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||
return (
|
||||
<button
|
||||
key={account.id}
|
||||
onClick={() => {
|
||||
if (!isActive) switchAccount(account.id);
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
|
||||
isActive
|
||||
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
: "opacity-70 hover:opacity-100"
|
||||
)}
|
||||
style={{ backgroundColor: account.avatarColor }}
|
||||
title={`${account.displayName || account.label} (${account.email || account.username})`}
|
||||
>
|
||||
{initials}
|
||||
{isActive && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="w-2 h-2 text-primary-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Logout button with popover */}
|
||||
<button
|
||||
ref={logoutBtnRef}
|
||||
onClick={() => setLogoutMenuOpen(!logoutMenuOpen)}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={t("sign_out")}
|
||||
aria-expanded={logoutMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{logoutMenuOpen && createPortal(
|
||||
<div
|
||||
ref={logoutPopoverRef}
|
||||
style={logoutPopoverStyle}
|
||||
className="w-56 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
onClick={() => { setLogoutMenuOpen(false); logout(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("sign_out")}
|
||||
</button>
|
||||
{accounts.length > 1 && (
|
||||
<button
|
||||
onClick={() => { setLogoutMenuOpen(false); logoutAll(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-destructive hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("sign_out_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onLogout && (
|
||||
{onLogout && !showRailAccountList && (
|
||||
<AccountSwitcher variant="rail" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
+132
-37
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import { useConfig } from '@/hooks/use-config';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
|
||||
|
||||
export function AdvancedSettings() {
|
||||
const t = useTranslations('settings.advanced');
|
||||
const tCommon = useTranslations('common');
|
||||
const { debugMode, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||
useSettingsStore();
|
||||
const { settingsSyncEnabled } = useConfig();
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
@@ -72,6 +73,30 @@ export function AdvancedSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Debug Categories */}
|
||||
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
|
||||
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
|
||||
{ALL_DEBUG_CATEGORIES.map((cat) => (
|
||||
<SettingItem
|
||||
key={cat.id}
|
||||
label={t(`debug_categories.${cat.labelKey}`)}
|
||||
description={t(`debug_categories.${cat.labelKey}_description`)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={debugCategories?.[cat.id] !== false}
|
||||
onChange={(checked) => {
|
||||
updateSetting('debugCategories', {
|
||||
...debugCategories,
|
||||
[cat.id]: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Sync */}
|
||||
{settingsSyncEnabled && (
|
||||
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tTour = useTranslations('tour');
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
|
||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore();
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
|
||||
@@ -145,6 +145,22 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Hide Account Switcher */}
|
||||
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
|
||||
<ToggleSwitch
|
||||
checked={hideAccountSwitcher}
|
||||
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Show Rail Account List */}
|
||||
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showRailAccountList}
|
||||
onChange={(checked) => updateSetting('showRailAccountList', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Animations */}
|
||||
{!isSettingHidden('animationsEnabled') && (
|
||||
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
|
||||
@@ -164,6 +165,7 @@ export function CalendarManagementSettings() {
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
||||
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
|
||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||
const tImport = useTranslations('calendar.import');
|
||||
@@ -202,7 +204,10 @@ export function CalendarManagementSettings() {
|
||||
|
||||
fetch('/api/caldav/discover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getActiveAccountSlotHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
|
||||
}),
|
||||
@@ -629,6 +634,14 @@ export function CalendarManagementSettings() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingSubscription(sub)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={tSub('edit')}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRefreshSubscription(sub.id)}
|
||||
@@ -667,6 +680,14 @@ export function CalendarManagementSettings() {
|
||||
onClose={() => setShowSubscriptionModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSubscription && client && (
|
||||
<ICalSubscriptionModal
|
||||
client={client}
|
||||
editSubscription={editingSubscription}
|
||||
onClose={() => setEditingSubscription(null)}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export default [
|
||||
".next/**",
|
||||
"node_modules/**",
|
||||
"repos/**",
|
||||
"data/admin/plugins/**",
|
||||
"*.config.js",
|
||||
"*.config.mjs",
|
||||
"e2e/**",
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
const makeMailbox = (overrides: Partial<Mailbox> = {}): Mailbox => ({
|
||||
id: 'mb-1',
|
||||
name: 'Test',
|
||||
sortOrder: 0,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
},
|
||||
isSubscribed: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: walk the tree and collect { id, depth, parentId } for every node.
|
||||
*/
|
||||
function collectNodes(tree: MailboxNode[]): { id: string; depth: number; parentName?: string }[] {
|
||||
const result: { id: string; depth: number; parentName?: string }[] = [];
|
||||
const walk = (nodes: MailboxNode[], parentName?: string) => {
|
||||
for (const node of nodes) {
|
||||
result.push({ id: node.id, depth: node.depth, parentName });
|
||||
if (node.children.length > 0) walk(node.children, node.name);
|
||||
}
|
||||
};
|
||||
walk(tree);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('mailbox deep nesting (depth 4+)', () => {
|
||||
// Reproduce the exact scenario from the bug report:
|
||||
// INBOX > PRIVAT > BOOKINGS > BOOKING1/BOOKING2/FLIGHTS/HOTEL1/HOTEL2/HOTEL3
|
||||
// HOTEL2 > RESTAURANT (depth 4)
|
||||
it('should correctly nest the reported folder structure (depth 4)', () => {
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
|
||||
makeMailbox({ id: 'booking1', name: 'BOOKING1', parentId: 'bookings' }),
|
||||
makeMailbox({ id: 'booking2', name: 'BOOKING2', parentId: 'bookings' }),
|
||||
makeMailbox({ id: 'flights', name: 'FLIGHTS', parentId: 'bookings' }),
|
||||
makeMailbox({ id: 'hotel1', name: 'HOTEL1', parentId: 'bookings' }),
|
||||
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||
makeMailbox({ id: 'hotel3', name: 'HOTEL3', parentId: 'bookings' }),
|
||||
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||
|
||||
// RESTAURANT should be at depth 4, NOT depth 0
|
||||
expect(byId['restaurant'].depth).toBe(4);
|
||||
// Verify the full chain of depths
|
||||
expect(byId['inbox'].depth).toBe(0);
|
||||
expect(byId['privat'].depth).toBe(1);
|
||||
expect(byId['bookings'].depth).toBe(2);
|
||||
expect(byId['hotel2'].depth).toBe(3);
|
||||
expect(byId['restaurant'].depth).toBe(4);
|
||||
});
|
||||
|
||||
it('should not orphan depth-4 folders to root level', () => {
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
|
||||
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
|
||||
// RESTAURANT must NOT appear at root level
|
||||
const rootNames = tree.map(n => n.name);
|
||||
expect(rootNames).not.toContain('RESTAURANT');
|
||||
|
||||
// It must be nested under HOTEL2
|
||||
const nodes = collectNodes(tree);
|
||||
const restaurant = nodes.find(n => n.id === 'restaurant');
|
||||
expect(restaurant).toBeDefined();
|
||||
expect(restaurant!.depth).toBe(4);
|
||||
expect(restaurant!.parentName).toBe('HOTEL2');
|
||||
});
|
||||
|
||||
it('should handle nesting up to depth 6', () => {
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'l0', name: 'Level0', sortOrder: 0 }),
|
||||
makeMailbox({ id: 'l1', name: 'Level1', parentId: 'l0', sortOrder: 0 }),
|
||||
makeMailbox({ id: 'l2', name: 'Level2', parentId: 'l1', sortOrder: 0 }),
|
||||
makeMailbox({ id: 'l3', name: 'Level3', parentId: 'l2', sortOrder: 0 }),
|
||||
makeMailbox({ id: 'l4', name: 'Level4', parentId: 'l3', sortOrder: 0 }),
|
||||
makeMailbox({ id: 'l5', name: 'Level5', parentId: 'l4', sortOrder: 0 }),
|
||||
makeMailbox({ id: 'l6', name: 'Level6', parentId: 'l5', sortOrder: 0 }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||
|
||||
// Only 1 root node
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe('Level0');
|
||||
|
||||
// Verify all depths are correct
|
||||
for (let i = 0; i <= 6; i++) {
|
||||
expect(byId[`l${i}`].depth).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle nesting up to depth 10 (Stalwart default max)', () => {
|
||||
const mailboxes: Mailbox[] = [];
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
mailboxes.push(
|
||||
makeMailbox({
|
||||
id: `level-${i}`,
|
||||
name: `Folder${i}`,
|
||||
parentId: i === 0 ? undefined : `level-${i - 1}`,
|
||||
sortOrder: 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
|
||||
// Should have exactly 11 nodes total
|
||||
expect(flat).toHaveLength(11);
|
||||
// Only 1 root
|
||||
expect(tree).toHaveLength(1);
|
||||
|
||||
// Each node at correct depth
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const node = flat.find(n => n.id === `level-${i}`);
|
||||
expect(node).toBeDefined();
|
||||
expect(node!.depth).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly count children at each level in deep trees', () => {
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'root', name: 'Root', sortOrder: 0 }),
|
||||
makeMailbox({ id: 'a', name: 'A', parentId: 'root' }),
|
||||
makeMailbox({ id: 'b', name: 'B', parentId: 'root' }),
|
||||
makeMailbox({ id: 'a1', name: 'A1', parentId: 'a' }),
|
||||
makeMailbox({ id: 'a2', name: 'A2', parentId: 'a' }),
|
||||
makeMailbox({ id: 'a1x', name: 'A1X', parentId: 'a1' }),
|
||||
makeMailbox({ id: 'a1y', name: 'A1Y', parentId: 'a1' }),
|
||||
makeMailbox({ id: 'a1x_deep', name: 'A1X_DEEP', parentId: 'a1x' }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||
|
||||
// Verify structure
|
||||
expect(tree).toHaveLength(1); // just Root
|
||||
expect(tree[0].children).toHaveLength(2); // A, B
|
||||
|
||||
// Check depths
|
||||
expect(byId['root'].depth).toBe(0);
|
||||
expect(byId['a'].depth).toBe(1);
|
||||
expect(byId['b'].depth).toBe(1);
|
||||
expect(byId['a1'].depth).toBe(2);
|
||||
expect(byId['a2'].depth).toBe(2);
|
||||
expect(byId['a1x'].depth).toBe(3);
|
||||
expect(byId['a1y'].depth).toBe(3);
|
||||
expect(byId['a1x_deep'].depth).toBe(4);
|
||||
});
|
||||
|
||||
it('should flatten deep trees in correct parent-before-child order', () => {
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'l0', name: 'L0', sortOrder: 0 }),
|
||||
makeMailbox({ id: 'l1', name: 'L1', parentId: 'l0', sortOrder: 0 }),
|
||||
makeMailbox({ id: 'l2', name: 'L2', parentId: 'l1', sortOrder: 0 }),
|
||||
makeMailbox({ id: 'l3', name: 'L3', parentId: 'l2', sortOrder: 0 }),
|
||||
makeMailbox({ id: 'l4', name: 'L4', parentId: 'l3', sortOrder: 0 }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
const ids = flat.map(n => n.id);
|
||||
|
||||
// Parent must always come before child in the flat list
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const parentIdx = ids.indexOf(`l${i}`);
|
||||
const childIdx = ids.indexOf(`l${i + 1}`);
|
||||
expect(parentIdx).toBeLessThan(childIdx);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle mailboxes provided in reverse order (children before parents)', () => {
|
||||
// JMAP doesn't guarantee order - children might arrive before parents
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
|
||||
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||
|
||||
// All nodes present
|
||||
expect(flat).toHaveLength(5);
|
||||
// Only 1 root
|
||||
expect(tree).toHaveLength(1);
|
||||
// Correct depths regardless of input order
|
||||
expect(byId['inbox'].depth).toBe(0);
|
||||
expect(byId['privat'].depth).toBe(1);
|
||||
expect(byId['bookings'].depth).toBe(2);
|
||||
expect(byId['hotel2'].depth).toBe(3);
|
||||
expect(byId['restaurant'].depth).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle wide + deep trees without orphaning', () => {
|
||||
// Mix of wide (many siblings) and deep nesting
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||
// 5 children of inbox
|
||||
...Array.from({ length: 5 }, (_, i) =>
|
||||
makeMailbox({ id: `child-${i}`, name: `Child${i}`, parentId: 'inbox' })
|
||||
),
|
||||
// Each child has 2 sub-children
|
||||
...Array.from({ length: 5 }, (_, i) => [
|
||||
makeMailbox({ id: `gc-${i}-0`, name: `GC${i}-0`, parentId: `child-${i}` }),
|
||||
makeMailbox({ id: `gc-${i}-1`, name: `GC${i}-1`, parentId: `child-${i}` }),
|
||||
]).flat(),
|
||||
// Some grandchildren have great-grandchildren (depth 3)
|
||||
makeMailbox({ id: 'ggc-0', name: 'GGC0', parentId: 'gc-0-0' }),
|
||||
makeMailbox({ id: 'ggc-1', name: 'GGC1', parentId: 'gc-2-1' }),
|
||||
// depth 4
|
||||
makeMailbox({ id: 'gggc-0', name: 'GGGC0', parentId: 'ggc-0' }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
const rootNames = tree.map(n => n.name);
|
||||
|
||||
// Only INBOX should be at root
|
||||
expect(rootNames).toEqual(['INBOX']);
|
||||
// Total nodes
|
||||
expect(flat).toHaveLength(mailboxes.length);
|
||||
|
||||
// Verify depth-4 node
|
||||
const gggc = flat.find(n => n.id === 'gggc-0');
|
||||
expect(gggc).toBeDefined();
|
||||
expect(gggc!.depth).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () => {
|
||||
it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
|
||||
// Reporter's exact scenario: two subfolders with the same name.
|
||||
// The dedup uses substring matching and removes non-role folders whose name
|
||||
// matches a role folder — even if they're deep in the tree with children.
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
||||
// User-created subfolder also named "Sent" nested under Inbox
|
||||
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
|
||||
// Child of the custom "Sent" folder — becomes orphaned if parent is deduped
|
||||
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
const rootIds = tree.map(n => n.id);
|
||||
|
||||
// sent-custom MUST be kept because it has children — removing it orphans sent-child
|
||||
const sentCustom = flat.find(n => n.id === 'sent-custom');
|
||||
expect(sentCustom).toBeDefined();
|
||||
expect(sentCustom!.depth).toBe(1); // nested under Inbox
|
||||
|
||||
const sentChild = flat.find(n => n.id === 'sent-child');
|
||||
expect(sentChild).toBeDefined();
|
||||
expect(sentChild!.depth).toBe(2); // nested under sent-custom
|
||||
expect(rootIds).not.toContain('sent-child'); // must NOT be orphaned at root
|
||||
});
|
||||
|
||||
it('should keep nested folders when name is substring of a role name', () => {
|
||||
// "Draft" is a substring of "Drafts" — dedup removes it, orphaning children
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
|
||||
makeMailbox({ id: 'draft-folder', name: 'Draft', parentId: 'inbox' }),
|
||||
makeMailbox({ id: 'draft-child', name: 'Notes', parentId: 'draft-folder' }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
const rootIds = tree.map(n => n.id);
|
||||
|
||||
const draftChild = flat.find(n => n.id === 'draft-child');
|
||||
expect(draftChild).toBeDefined();
|
||||
expect(draftChild!.depth).toBe(2);
|
||||
expect(rootIds).not.toContain('draft-child');
|
||||
});
|
||||
|
||||
it('should handle the exact reported structure with duplicate names at different depths', () => {
|
||||
// Stalwart allows creating subfolders with the same name at different levels.
|
||||
// If any of those names match a role mailbox name, dedup could remove them.
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||
makeMailbox({ id: 'trash-role', name: 'Trash', role: 'trash' }),
|
||||
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
|
||||
// User created a subfolder named "Trash" under BOOKINGS (e.g. for old bookings)
|
||||
makeMailbox({ id: 'trash-custom', name: 'Trash', parentId: 'bookings' }),
|
||||
// Depth 4: child of the custom Trash folder
|
||||
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'trash-custom' }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
const rootIds = tree.map(n => n.id);
|
||||
|
||||
// RESTAURANT must be at depth 4, not orphaned at root
|
||||
const restaurant = flat.find(n => n.id === 'restaurant');
|
||||
expect(restaurant).toBeDefined();
|
||||
expect(restaurant!.depth).toBe(4);
|
||||
expect(rootIds).not.toContain('restaurant');
|
||||
|
||||
// Custom "Trash" must be kept as it has children
|
||||
const trashCustom = flat.find(n => n.id === 'trash-custom');
|
||||
expect(trashCustom).toBeDefined();
|
||||
expect(trashCustom!.depth).toBe(3);
|
||||
});
|
||||
|
||||
it('should only dedup root-level non-role mailboxes that duplicate role mailboxes', () => {
|
||||
// Dedup should only remove mailboxes that are BOTH:
|
||||
// 1. At root level (no parentId) — same structural position as role mailbox
|
||||
// 2. Name-matching a role mailbox
|
||||
// Nested mailboxes with matching names should always be kept.
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
||||
makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level duplicate — OK to remove
|
||||
makeMailbox({ id: 'proj', name: 'Projects', parentId: 'inbox' }),
|
||||
makeMailbox({ id: 'sent-nested', name: 'Sent', parentId: 'proj' }), // nested — must keep
|
||||
makeMailbox({ id: 'report', name: 'Report', parentId: 'sent-nested' }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
|
||||
// "Sent Mail" at root (no parentId) can be deduped — that's fine
|
||||
// But "Sent" nested under Projects must be kept
|
||||
const sentNested = flat.find(n => n.id === 'sent-nested');
|
||||
expect(sentNested).toBeDefined();
|
||||
expect(sentNested!.depth).toBe(2);
|
||||
|
||||
const report = flat.find(n => n.id === 'report');
|
||||
expect(report).toBeDefined();
|
||||
expect(report!.depth).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailbox orphan behavior (missing parent)', () => {
|
||||
it('should expose orphan-to-root behavior when parent is missing', () => {
|
||||
// Simulates what happens if JMAP response is incomplete (e.g., truncated at 500 objects)
|
||||
// Middle parent "bookings" is missing from the response
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||
// 'bookings' is MISSING — simulating truncated JMAP response
|
||||
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
const rootIds = tree.map(n => n.id);
|
||||
|
||||
// hotel2's parent ("bookings") is missing from the response.
|
||||
// Current behavior: hotel2 becomes a root node (orphaned).
|
||||
// This means RESTAURANT also appears under hotel2 at root, but at depth 1 instead of depth 4.
|
||||
const hotel2 = flat.find(n => n.id === 'hotel2');
|
||||
expect(hotel2).toBeDefined();
|
||||
|
||||
// Diagnostic: document that orphaning DOES happen
|
||||
if (rootIds.includes('hotel2')) {
|
||||
expect(hotel2!.depth).toBe(0); // orphaned at root
|
||||
const restaurant = flat.find(n => n.id === 'restaurant');
|
||||
expect(restaurant!.depth).toBe(1); // child of orphaned root
|
||||
console.warn(
|
||||
'CONFIRMED: Missing intermediate parent causes orphan-to-root. ' +
|
||||
'hotel2 (parentId: "bookings") is at root with depth 0 instead of depth 3.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should expose orphan behavior with multiple missing parents in chain', () => {
|
||||
// Deep chain where two intermediate parents are missing
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'root', name: 'Root', sortOrder: 0 }),
|
||||
// 'level1' is MISSING
|
||||
// 'level2' is MISSING
|
||||
makeMailbox({ id: 'level3', name: 'Level3', parentId: 'level2' }),
|
||||
makeMailbox({ id: 'level4', name: 'Level4', parentId: 'level3' }),
|
||||
];
|
||||
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const flat = flattenMailboxTree(tree);
|
||||
const rootIds = tree.map(n => n.id);
|
||||
|
||||
// level3's parent (level2) is missing → level3 becomes root
|
||||
expect(rootIds).toContain('level3');
|
||||
|
||||
// level4 is correctly nested under level3 (since level3 IS in the map)
|
||||
const level4 = flat.find(n => n.id === 'level4');
|
||||
expect(level4).toBeDefined();
|
||||
expect(level4!.depth).toBe(1); // child of orphaned level3 (depth 0)
|
||||
|
||||
console.warn(
|
||||
'CONFIRMED: Missing parents in chain cause subtree to float to root. ' +
|
||||
'Level3 (expected depth 2) is at depth 0, Level4 (expected depth 3) is at depth 1.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,8 @@ function resetStore() {
|
||||
'settings-section': [],
|
||||
'context-menu-email': [],
|
||||
'navigation-rail-bottom': [],
|
||||
'calendar-event-actions': [],
|
||||
'admin-plugin-page': [],
|
||||
},
|
||||
initialized: false,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -17,6 +17,16 @@ function getThemesDir(): string {
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
|
||||
export interface PluginConfigField {
|
||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
placeholder?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
export interface ServerPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -28,6 +38,7 @@ export interface ServerPlugin {
|
||||
entrypoint: string;
|
||||
enabled: boolean;
|
||||
forceEnabled?: boolean;
|
||||
configSchema?: Record<string, PluginConfigField>;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
+22
-1
@@ -8,9 +8,17 @@ const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
const MIN_SECRET_LENGTH = 32;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||
if (secret.length < MIN_SECRET_LENGTH) {
|
||||
throw new Error(
|
||||
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
|
||||
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
||||
);
|
||||
}
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
@@ -116,11 +124,24 @@ export async function clearAdminSessionCookie(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Get the client IP from the request headers.
|
||||
*
|
||||
* Proxies typically *append* to X-Forwarded-For, so the last entry
|
||||
* before our trusted proxy is the most reliable client IP. When a
|
||||
* single reverse proxy sits in front of the app the rightmost entry
|
||||
* is the one added by that proxy. We take the rightmost entry to
|
||||
* avoid trusting attacker-controlled values prepended to the header.
|
||||
*
|
||||
* If you run behind multiple trusted proxies, set TRUSTED_PROXY_DEPTH
|
||||
* to the number of trusted proxies (default 1).
|
||||
*/
|
||||
export function getClientIP(request: Request): string {
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
if (forwarded) {
|
||||
return forwarded.split(',')[0].trim();
|
||||
const parts = forwarded.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const depth = Math.max(1, parseInt(process.env.TRUSTED_PROXY_DEPTH || '1', 10));
|
||||
// Take the entry at position (length - depth), clamped to 0
|
||||
const index = Math.max(0, parts.length - depth);
|
||||
return parts[index] || '0.0.0.0';
|
||||
}
|
||||
return request.headers.get('x-real-ip') || '0.0.0.0';
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface FeatureGates {
|
||||
debugModeEnabled: boolean;
|
||||
folderIconsEnabled: boolean;
|
||||
hoverActionsConfigEnabled: boolean;
|
||||
filesEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
@@ -54,6 +55,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
debugModeEnabled: true,
|
||||
folderIconsEnabled: true,
|
||||
hoverActionsConfigEnabled: true,
|
||||
filesEnabled: true,
|
||||
};
|
||||
|
||||
export interface ThemePolicy {
|
||||
@@ -120,6 +122,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
|
||||
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
|
||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
export function getActiveAccountSlot(): number | null {
|
||||
const authState = useAuthStore.getState();
|
||||
const accountState = useAccountStore.getState();
|
||||
const activeAccountId = authState.activeAccountId ?? accountState.activeAccountId;
|
||||
const activeAccount = activeAccountId
|
||||
? accountState.getAccountById(activeAccountId)
|
||||
: accountState.getActiveAccount();
|
||||
|
||||
return typeof activeAccount?.cookieSlot === 'number' ? activeAccount.cookieSlot : null;
|
||||
}
|
||||
|
||||
export function getActiveAccountSlotHeaders(): Record<string, string> {
|
||||
const slot = getActiveAccountSlot();
|
||||
return slot === null ? {} : { 'X-JMAP-Cookie-Slot': String(slot) };
|
||||
}
|
||||
@@ -5,9 +5,17 @@ const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
const MIN_SECRET_LENGTH = 32;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
||||
if (secret.length < MIN_SECRET_LENGTH) {
|
||||
throw new Error(
|
||||
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
|
||||
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
||||
);
|
||||
}
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const VERIFY_TIMEOUT_MS = 10000;
|
||||
|
||||
export class JmapAuthVerificationError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'JmapAuthVerificationError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function isSupportedProtocol(protocol: string): boolean {
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
}
|
||||
|
||||
export function normalizeJmapServerUrl(serverUrl: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(serverUrl);
|
||||
} catch {
|
||||
throw new JmapAuthVerificationError('Invalid server URL', 400);
|
||||
}
|
||||
|
||||
if (!isSupportedProtocol(url.protocol)) {
|
||||
throw new JmapAuthVerificationError('Unsupported server URL protocol', 400);
|
||||
}
|
||||
|
||||
url.hash = '';
|
||||
url.search = '';
|
||||
return url.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function validateProxyAuthHeader(authHeader: string): void {
|
||||
if (!/^(?:Basic|Bearer)\s+\S+$/i.test(authHeader)) {
|
||||
throw new JmapAuthVerificationError('Invalid Authorization header', 400);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise<string> {
|
||||
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
|
||||
validateProxyAuthHeader(authHeader);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new JmapAuthVerificationError(
|
||||
response.status === 401 || response.status === 403
|
||||
? 'Authentication failed'
|
||||
: 'Failed to verify JMAP session',
|
||||
response.status === 401 || response.status === 403 ? 401 : 502,
|
||||
);
|
||||
}
|
||||
|
||||
const session = await response.json().catch(() => null) as { apiUrl?: unknown; accounts?: unknown } | null;
|
||||
if (!session || typeof session.apiUrl !== 'string' || typeof session.accounts !== 'object' || session.accounts === null) {
|
||||
throw new JmapAuthVerificationError('Invalid JMAP session response', 502);
|
||||
}
|
||||
|
||||
return normalizedServerUrl;
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new JmapAuthVerificationError('JMAP session verification timed out', 504);
|
||||
}
|
||||
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
|
||||
import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
|
||||
import { parseDuration } from "@/components/calendar/event-card";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
@@ -11,15 +11,63 @@ export interface CalendarWeekSegment {
|
||||
continuesAfter: boolean;
|
||||
}
|
||||
|
||||
export interface TimedEventLayout {
|
||||
event: CalendarEvent;
|
||||
column: number;
|
||||
totalColumns: number;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
continuesBefore: boolean;
|
||||
continuesAfter: boolean;
|
||||
}
|
||||
|
||||
export function getEventStartDate(
|
||||
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
|
||||
): Date {
|
||||
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
|
||||
return parseISO(source);
|
||||
}
|
||||
|
||||
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
|
||||
rawSegments.sort((left, right) => {
|
||||
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
|
||||
if (left.span !== right.span) return right.span - left.span;
|
||||
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
|
||||
return left.event.showWithoutTime ? -1 : 1;
|
||||
}
|
||||
const timeDiff = getEventStartDate(left.event).getTime() - getEventStartDate(right.event).getTime();
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return (left.event.title || "").localeCompare(right.event.title || "");
|
||||
});
|
||||
|
||||
const rowEndIndices: number[] = [];
|
||||
return rawSegments.map((segment) => {
|
||||
const segmentEndIndex = segment.startIndex + segment.span - 1;
|
||||
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
|
||||
if (row === -1) {
|
||||
row = rowEndIndices.length;
|
||||
rowEndIndices.push(segmentEndIndex);
|
||||
} else {
|
||||
rowEndIndices[row] = segmentEndIndex;
|
||||
}
|
||||
return { ...segment, row };
|
||||
});
|
||||
}
|
||||
|
||||
export function getEventEndDate(event: CalendarEvent): Date {
|
||||
const start = parseISO(event.start);
|
||||
if (!event.showWithoutTime && event.utcEnd) {
|
||||
return parseISO(event.utcEnd);
|
||||
}
|
||||
|
||||
const start = getEventStartDate(event);
|
||||
if (!event.duration) return start;
|
||||
return new Date(start.getTime() + parseDuration(event.duration) * 60000);
|
||||
}
|
||||
|
||||
export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
||||
const end = getEventEndDate(event);
|
||||
if (!event.showWithoutTime || end.getTime() <= parseISO(event.start).getTime()) {
|
||||
const start = getEventStartDate(event);
|
||||
if (!event.showWithoutTime || end.getTime() <= start.getTime()) {
|
||||
return end;
|
||||
}
|
||||
return subMilliseconds(end, 1);
|
||||
@@ -27,11 +75,44 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
||||
|
||||
export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } {
|
||||
return {
|
||||
startDay: startOfDay(parseISO(event.start)),
|
||||
startDay: startOfDay(getEventStartDate(event)),
|
||||
endDay: startOfDay(getEventDisplayEndDate(event)),
|
||||
};
|
||||
}
|
||||
|
||||
export function getTimedEventBoundsForDay(
|
||||
event: CalendarEvent,
|
||||
day: Date,
|
||||
): { startMinutes: number; endMinutes: number; continuesBefore: boolean; continuesAfter: boolean } | null {
|
||||
if (event.showWithoutTime) return null;
|
||||
|
||||
const eventStart = getEventStartDate(event);
|
||||
const eventEnd = getEventEndDate(event);
|
||||
const dayStart = startOfDay(day);
|
||||
const nextDayStart = addDays(dayStart, 1);
|
||||
|
||||
if (eventEnd <= dayStart || eventStart >= nextDayStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clippedStart = eventStart > dayStart ? eventStart : dayStart;
|
||||
const clippedEnd = eventEnd < nextDayStart ? eventEnd : nextDayStart;
|
||||
const startMinutes = Math.max(0, Math.floor((clippedStart.getTime() - dayStart.getTime()) / 60000));
|
||||
const endMinutes = Math.min(1440, Math.ceil((clippedEnd.getTime() - dayStart.getTime()) / 60000));
|
||||
|
||||
return {
|
||||
startMinutes,
|
||||
endMinutes,
|
||||
continuesBefore: eventStart < dayStart,
|
||||
continuesAfter: eventEnd > nextDayStart,
|
||||
};
|
||||
}
|
||||
|
||||
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
|
||||
const bounds = getTimedEventBoundsForDay(event, day);
|
||||
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
|
||||
}
|
||||
|
||||
export function normalizeAllDayDuration(duration: string | undefined): string | undefined {
|
||||
if (!duration) return undefined;
|
||||
const totalMinutes = parseDuration(duration);
|
||||
@@ -44,7 +125,7 @@ export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
|
||||
return `P${dayCount}D`;
|
||||
}
|
||||
|
||||
export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
||||
export function buildWeekSegmentsRaw(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
||||
if (weekDays.length === 0) return [];
|
||||
|
||||
const weekStart = startOfDay(weekDays[0]);
|
||||
@@ -71,59 +152,89 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca
|
||||
} satisfies CalendarWeekSegment];
|
||||
});
|
||||
|
||||
rawSegments.sort((left, right) => {
|
||||
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
|
||||
if (left.span !== right.span) return right.span - left.span;
|
||||
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
|
||||
return left.event.showWithoutTime ? -1 : 1;
|
||||
return rawSegments;
|
||||
}
|
||||
|
||||
export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
||||
return packWeekSegments(buildWeekSegmentsRaw(events, weekDays));
|
||||
}
|
||||
|
||||
export function buildTimedFullDayWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
||||
if (weekDays.length === 0) return [];
|
||||
|
||||
const rawSegments = events.flatMap((event) => {
|
||||
const fullDayIndices = weekDays
|
||||
.map((day, index) => (isTimedEventFullDayOnDate(event, day) ? index : -1))
|
||||
.filter((index) => index >= 0);
|
||||
|
||||
if (fullDayIndices.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const timeDiff = parseISO(left.event.start).getTime() - parseISO(right.event.start).getTime();
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return (left.event.title || "").localeCompare(right.event.title || "");
|
||||
|
||||
const segments: CalendarWeekSegment[] = [];
|
||||
let rangeStart = fullDayIndices[0];
|
||||
let previousIndex = fullDayIndices[0];
|
||||
|
||||
const pushSegment = (startIndex: number, endIndex: number) => {
|
||||
const startDay = weekDays[startIndex];
|
||||
const endDay = weekDays[endIndex];
|
||||
segments.push({
|
||||
event,
|
||||
startIndex,
|
||||
span: endIndex - startIndex + 1,
|
||||
row: -1,
|
||||
continuesBefore: isTimedEventFullDayOnDate(event, addDays(startDay, -1)),
|
||||
continuesAfter: isTimedEventFullDayOnDate(event, addDays(endDay, 1)),
|
||||
});
|
||||
};
|
||||
|
||||
for (let index = 1; index < fullDayIndices.length; index++) {
|
||||
const currentIndex = fullDayIndices[index];
|
||||
if (currentIndex !== previousIndex + 1) {
|
||||
pushSegment(rangeStart, previousIndex);
|
||||
rangeStart = currentIndex;
|
||||
}
|
||||
previousIndex = currentIndex;
|
||||
}
|
||||
|
||||
pushSegment(rangeStart, previousIndex);
|
||||
return segments;
|
||||
});
|
||||
|
||||
const rowEndIndices: number[] = [];
|
||||
return rawSegments.map((segment) => {
|
||||
const segmentEndIndex = segment.startIndex + segment.span - 1;
|
||||
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
|
||||
if (row === -1) {
|
||||
row = rowEndIndices.length;
|
||||
rowEndIndices.push(segmentEndIndex);
|
||||
} else {
|
||||
rowEndIndices[row] = segmentEndIndex;
|
||||
}
|
||||
return { ...segment, row };
|
||||
});
|
||||
return packWeekSegments(rawSegments);
|
||||
}
|
||||
|
||||
export function layoutOverlappingEvents(
|
||||
events: CalendarEvent[],
|
||||
): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
||||
const sorted = [...events].sort((a, b) => {
|
||||
const diff = parseISO(a.start).getTime() - parseISO(b.start).getTime();
|
||||
day: Date,
|
||||
): TimedEventLayout[] {
|
||||
const layoutInputs = events.flatMap((event) => {
|
||||
const bounds = getTimedEventBoundsForDay(event, day);
|
||||
return bounds ? [{ event, ...bounds }] : [];
|
||||
});
|
||||
|
||||
const sorted = layoutInputs.sort((a, b) => {
|
||||
const diff = a.startMinutes - b.startMinutes;
|
||||
if (diff !== 0) return diff;
|
||||
return parseDuration(b.duration) - parseDuration(a.duration);
|
||||
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
|
||||
});
|
||||
|
||||
const columns: { event: CalendarEvent; end: number }[][] = [];
|
||||
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
|
||||
const result: TimedEventLayout[] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
const start = parseISO(event.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const endMin = Math.min(1440, startMin + Math.max(15, parseDuration(event.duration)));
|
||||
let placed = false;
|
||||
for (let col = 0; col < columns.length; col++) {
|
||||
if (columns[col].every(e => e.end <= startMin)) {
|
||||
columns[col].push({ event, end: endMin });
|
||||
result.push({ event, column: col, totalColumns: 0 });
|
||||
if (columns[col].every(e => e.end <= event.startMinutes)) {
|
||||
columns[col].push({ event: event.event, end: event.endMinutes });
|
||||
result.push({ ...event, column: col, totalColumns: 0 });
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
columns.push([{ event, end: endMin }]);
|
||||
result.push({ event, column: columns.length - 1, totalColumns: 0 });
|
||||
columns.push([{ event: event.event, end: event.endMinutes }]);
|
||||
result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+57
-24
@@ -1,25 +1,53 @@
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { DebugCategory } from '@/stores/settings-store';
|
||||
|
||||
/**
|
||||
* Debug logger that respects the debugMode setting.
|
||||
* Check if debug logging is enabled, optionally for a specific category.
|
||||
* When a category is provided, both debugMode AND that category must be enabled.
|
||||
*/
|
||||
function isEnabled(category?: DebugCategory): boolean {
|
||||
const state = useSettingsStore.getState();
|
||||
if (!state.debugMode) return false;
|
||||
if (!category) return true;
|
||||
return state.debugCategories?.[category] !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug logger that respects the debugMode setting and category filters.
|
||||
* Use this instead of console.log for conditional debug output.
|
||||
*
|
||||
* Each method accepts an optional category as the first argument.
|
||||
* When a category is provided, the message only logs if that category is enabled
|
||||
* in Settings > Advanced > Debug Categories.
|
||||
*
|
||||
* Usage:
|
||||
* debug.log('calendar', 'Event created', event); // Only logs when 'calendar' category is on
|
||||
* debug.log('Uncategorized message'); // Logs whenever debugMode is on
|
||||
*/
|
||||
export const debug = {
|
||||
/**
|
||||
* Log a debug message (only when debugMode is enabled)
|
||||
* Log a debug message (only when debugMode is enabled and category is active)
|
||||
*/
|
||||
log: (...args: unknown[]) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.log('[DEBUG]', ...args);
|
||||
log: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
|
||||
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
|
||||
if (isEnabled(categoryOrMsg)) {
|
||||
console.log(`[DEBUG:${categoryOrMsg}]`, ...args);
|
||||
}
|
||||
} else if (isEnabled()) {
|
||||
console.log('[DEBUG]', categoryOrMsg, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log a warning message (only when debugMode is enabled)
|
||||
* Log a warning message (only when debugMode is enabled and category is active)
|
||||
*/
|
||||
warn: (...args: unknown[]) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.warn('[DEBUG]', ...args);
|
||||
warn: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => {
|
||||
if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) {
|
||||
if (isEnabled(categoryOrMsg)) {
|
||||
console.warn(`[DEBUG:${categoryOrMsg}]`, ...args);
|
||||
}
|
||||
} else if (isEnabled()) {
|
||||
console.warn('[DEBUG]', categoryOrMsg, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -31,11 +59,11 @@ export const debug = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a collapsed console group (only when debugMode is enabled)
|
||||
* Start a collapsed console group (only when debugMode is enabled and category is active)
|
||||
*/
|
||||
group: (label: string) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.group(`[DEBUG] ${label}`);
|
||||
group: (label: string, category?: DebugCategory) => {
|
||||
if (isEnabled(category)) {
|
||||
console.group(`[DEBUG${category ? ':' + category : ''}] ${label}`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -43,35 +71,40 @@ export const debug = {
|
||||
* End a console group (only when debugMode is enabled)
|
||||
*/
|
||||
groupEnd: () => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
if (isEnabled()) {
|
||||
console.groupEnd();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a performance timer (only when debugMode is enabled)
|
||||
* Start a performance timer (only when debugMode is enabled and category is active)
|
||||
*/
|
||||
time: (label: string) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.time(`[DEBUG] ${label}`);
|
||||
time: (label: string, category?: DebugCategory) => {
|
||||
if (isEnabled(category)) {
|
||||
console.time(`[DEBUG${category ? ':' + category : ''}] ${label}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* End a performance timer (only when debugMode is enabled)
|
||||
*/
|
||||
timeEnd: (label: string) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.timeEnd(`[DEBUG] ${label}`);
|
||||
timeEnd: (label: string, category?: DebugCategory) => {
|
||||
if (isEnabled(category)) {
|
||||
console.timeEnd(`[DEBUG${category ? ':' + category : ''}] ${label}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log a table (only when debugMode is enabled)
|
||||
* Log a table (only when debugMode is enabled and category is active)
|
||||
*/
|
||||
table: (data: unknown) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
table: (data: unknown, category?: DebugCategory) => {
|
||||
if (isEnabled(category)) {
|
||||
console.table(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const CATEGORY_KEYS = new Set<string>(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']);
|
||||
function isCategoryKey(value: string): value is DebugCategory {
|
||||
return CATEGORY_KEYS.has(value);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -11,9 +11,10 @@ export const EMAIL_SANITIZE_CONFIG = {
|
||||
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
FORCE_BODY: true,
|
||||
// Allow blob: URIs so authenticated inline images (CID) are not stripped
|
||||
// Allow blob: URIs so authenticated inline images (CID) are not stripped.
|
||||
// data: is restricted to image/* MIME types to prevent SVG script injection.
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||
FORBID_TAGS: [
|
||||
'script', 'iframe', 'object', 'embed', 'form',
|
||||
'input', 'button', 'meta', 'link', 'base',
|
||||
|
||||
@@ -19,6 +19,9 @@ export interface IJMAPClient {
|
||||
getServerUrl(): string;
|
||||
getAuthHeader(): string;
|
||||
updateAccessToken(token: string): void;
|
||||
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void;
|
||||
enableTotpReauth(basePassword: string, callback: () => Promise<string | null>): void;
|
||||
updateBasicAuth(newPassword: string): void;
|
||||
getAccountId(): string;
|
||||
getUsername(): string;
|
||||
|
||||
@@ -193,6 +196,7 @@ export interface IJMAPClient {
|
||||
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
|
||||
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
|
||||
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
|
||||
batchCreateCalendarEvents(events: Partial<CalendarEvent>[], targetAccountId?: string): Promise<{ created: CalendarEvent[]; failed: string[] }>;
|
||||
updateCalendarEvent(
|
||||
eventId: string,
|
||||
updates: Partial<CalendarEvent>,
|
||||
|
||||
+635
-62
File diff suppressed because it is too large
Load Diff
@@ -660,6 +660,7 @@ export interface CalendarEventFilter {
|
||||
attendee?: string;
|
||||
participationStatus?: string;
|
||||
uid?: string;
|
||||
types?: string[];
|
||||
}
|
||||
|
||||
// JMAP Push Notification Types (RFC 8620 Section 7)
|
||||
|
||||
@@ -31,7 +31,7 @@ function playFile(file: string) {
|
||||
const audio = new Audio(file);
|
||||
audio.volume = 0.3;
|
||||
audio.play().catch((e) => {
|
||||
debug.log('Could not play audio file, falling back to beep:', e);
|
||||
debug.log('push', 'Could not play audio file, falling back to beep:', e);
|
||||
playBeep();
|
||||
});
|
||||
}
|
||||
@@ -47,6 +47,6 @@ export function playNotificationSound(sound?: NotificationSoundChoice) {
|
||||
playBeep();
|
||||
}
|
||||
} catch (e) {
|
||||
debug.log('Could not play notification sound:', e);
|
||||
debug.log('push', 'Could not play notification sound:', e);
|
||||
}
|
||||
}
|
||||
|
||||
+81
-1
@@ -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
@@ -218,6 +218,12 @@ export const calendarHooks = {
|
||||
onCalendarAlertAcknowledge: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.2b Calendar Form Hooks (UI integration)
|
||||
export const calendarFormHooks = {
|
||||
onCalendarEventFormOpen: new HookBus(),
|
||||
onCalendarEventFormSave: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.3 Contact Hooks
|
||||
export const contactHooks = {
|
||||
onContactOpen: new HookBus(),
|
||||
@@ -396,7 +402,7 @@ export const sidebarAppHooks = {
|
||||
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
||||
|
||||
const allHookGroups = [
|
||||
emailHooks, calendarHooks, contactHooks, fileHooks,
|
||||
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
|
||||
authHooks, settingsHooks, identityHooks, filterHooks,
|
||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
|
||||
+32
-1
@@ -94,7 +94,9 @@ export type SlotName =
|
||||
| 'email-detail-sidebar'
|
||||
| 'settings-section'
|
||||
| 'context-menu-email'
|
||||
| 'navigation-rail-bottom';
|
||||
| 'navigation-rail-bottom'
|
||||
| 'calendar-event-actions'
|
||||
| 'admin-plugin-page';
|
||||
|
||||
export interface SlotRegistration {
|
||||
pluginId: string;
|
||||
@@ -147,6 +149,32 @@ export interface ContextMenuItem {
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface AdminPageSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
render: React.ComponentType;
|
||||
}
|
||||
|
||||
export interface CalendarEventAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick: (eventData: CalendarEventFormView, helpers: { setVirtualLocation: (url: string) => void }) => void;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface CalendarEventFormView {
|
||||
title: string;
|
||||
description: string;
|
||||
start: string;
|
||||
end: string;
|
||||
isAllDay: boolean;
|
||||
location: string;
|
||||
virtualLocation: string;
|
||||
calendarId: string;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
id: string;
|
||||
keys: string;
|
||||
@@ -377,6 +405,8 @@ export const ALL_PERMISSIONS = [
|
||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
||||
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||
'ui:calendar-action', 'ui:admin-page',
|
||||
'admin:config',
|
||||
'app:lifecycle',
|
||||
] as const;
|
||||
|
||||
@@ -397,6 +427,7 @@ export const ALLOWED_PLUGIN_FILES = new Set([
|
||||
export const DISALLOWED_CSS_PATTERNS = [
|
||||
/@import\b/i,
|
||||
/url\s*\(\s*['"]?https?:/i,
|
||||
/url\s*\(\s*['"]?data:/i,
|
||||
/expression\s*\(/i,
|
||||
/javascript\s*:/i,
|
||||
/-moz-binding/i,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
|
||||
|
||||
for (const rule of enabledRules) {
|
||||
if (rule.conditions.length === 0 || rule.actions.length === 0) {
|
||||
debug.warn(`Skipping rule "${rule.name}": empty conditions or actions`);
|
||||
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ export function parseScript(content: string): ParseResult {
|
||||
try {
|
||||
metadata = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
debug.warn('Failed to parse Sieve metadata JSON:', e);
|
||||
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
|
||||
return OPAQUE;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,9 @@ beforeEach(() => {
|
||||
defaultSignIdentity: {},
|
||||
defaultEncrypt: false,
|
||||
rememberUnlockedKeys: false,
|
||||
autoImportSignerCerts: false,
|
||||
autoImportSignerCerts: true,
|
||||
accountPreferences: {},
|
||||
currentAccountId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Verify CMS SignedData (opaque signed) and extract the inner content.
|
||||
*
|
||||
* v1 performs cryptographic signature validation and cert validity checks
|
||||
* but does NOT implement full trust-chain or revocation validation.
|
||||
* Performs cryptographic signature validation, cert validity checks,
|
||||
* and trust-chain verification.
|
||||
*/
|
||||
|
||||
import * as pkijs from 'pkijs';
|
||||
@@ -61,7 +61,7 @@ export async function smimeVerify(
|
||||
const verifyResult = await signedData.verify(
|
||||
{
|
||||
signer: 0,
|
||||
checkChain: false, // v1: no trust-chain validation
|
||||
checkChain: true,
|
||||
},
|
||||
cryptoEngine,
|
||||
);
|
||||
@@ -108,6 +108,11 @@ export async function smimeVerify(
|
||||
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
|
||||
}
|
||||
|
||||
// Detect self-signed certificates (issuer === subject)
|
||||
const issuerDer = new Uint8Array(signerCert.issuer.toSchema().toBER(false));
|
||||
const subjectDer = new Uint8Array(signerCert.subject.toSchema().toBER(false));
|
||||
const selfSigned = arraysEqual(issuerDer, subjectDer);
|
||||
|
||||
return {
|
||||
mimeBytes: innerContent,
|
||||
status: {
|
||||
@@ -117,6 +122,7 @@ export async function smimeVerify(
|
||||
signatureError,
|
||||
signerCert: signerPublicCert,
|
||||
signerEmailMatch,
|
||||
selfSigned,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Stored record for an imported S/MIME private key + certificate. */
|
||||
export interface SmimeKeyRecord {
|
||||
id: string;
|
||||
accountId?: string;
|
||||
email: string;
|
||||
certificate: ArrayBuffer; // DER-encoded X.509 leaf cert
|
||||
certificateChain: ArrayBuffer[]; // DER-encoded intermediates
|
||||
@@ -34,6 +35,7 @@ export interface SmimeUnlockedKey {
|
||||
/** A recipient or contact public certificate. */
|
||||
export interface SmimePublicCert {
|
||||
id: string;
|
||||
accountId?: string;
|
||||
email: string;
|
||||
certificate: ArrayBuffer; // DER-encoded X.509
|
||||
issuer: string;
|
||||
@@ -53,6 +55,8 @@ export interface SmimeStatus {
|
||||
signatureError?: string;
|
||||
signerCert?: SmimePublicCert;
|
||||
signerEmailMatch?: boolean;
|
||||
/** True when the signer certificate is self-signed (not chained to a trusted CA). */
|
||||
selfSigned?: boolean;
|
||||
decryptionSuccess?: boolean;
|
||||
decryptionError?: string;
|
||||
unsupportedReason?: string;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const STALWART_AUTH_CONTEXT_COOKIE = 'jmap_stalwart_ctx';
|
||||
|
||||
export interface StalwartAuthContext {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
authHeader: string;
|
||||
}
|
||||
|
||||
type CookieStore = Awaited<ReturnType<typeof cookies>>;
|
||||
|
||||
export function stalwartAuthContextCookieName(slot: number): string {
|
||||
return slot === 0 ? STALWART_AUTH_CONTEXT_COOKIE : `${STALWART_AUTH_CONTEXT_COOKIE}_${slot}`;
|
||||
}
|
||||
|
||||
function isValidContext(payload: unknown): payload is StalwartAuthContext {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = payload as Record<string, unknown>;
|
||||
return typeof candidate.serverUrl === 'string'
|
||||
&& typeof candidate.username === 'string'
|
||||
&& typeof candidate.authHeader === 'string';
|
||||
}
|
||||
|
||||
function getSessionCookieOptions() {
|
||||
const { maxAge: _maxAge, ...cookieOptions } = getCookieOptions();
|
||||
return cookieOptions;
|
||||
}
|
||||
|
||||
export function readStalwartAuthContextFromStore(
|
||||
cookieStore: CookieStore,
|
||||
slot: number,
|
||||
): StalwartAuthContext | null {
|
||||
const token = cookieStore.get(stalwartAuthContextCookieName(slot))?.value;
|
||||
if (!token) return null;
|
||||
|
||||
const payload = decryptPayload(token);
|
||||
return isValidContext(payload) ? payload : null;
|
||||
}
|
||||
|
||||
export async function readStalwartAuthContext(slot: number): Promise<StalwartAuthContext | null> {
|
||||
const cookieStore = await cookies();
|
||||
return readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
}
|
||||
|
||||
export function setStalwartAuthContextInStore(
|
||||
cookieStore: CookieStore,
|
||||
slot: number,
|
||||
context: StalwartAuthContext,
|
||||
): void {
|
||||
cookieStore.set(
|
||||
stalwartAuthContextCookieName(slot),
|
||||
encryptPayload(context as unknown as Record<string, unknown>),
|
||||
getSessionCookieOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function setStalwartAuthContext(slot: number, context: StalwartAuthContext): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
setStalwartAuthContextInStore(cookieStore, slot, context);
|
||||
}
|
||||
|
||||
export function clearStalwartAuthContextInStore(cookieStore: CookieStore, slot: number): void {
|
||||
cookieStore.delete(stalwartAuthContextCookieName(slot));
|
||||
}
|
||||
|
||||
export async function clearStalwartAuthContext(slot: number): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
}
|
||||
+30
-30
@@ -1,7 +1,7 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { decryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
|
||||
export interface StalwartCredentials {
|
||||
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
|
||||
@@ -11,6 +11,7 @@ export interface StalwartCredentials {
|
||||
authHeader: string;
|
||||
username: string;
|
||||
hasSessionCookie: boolean;
|
||||
slot: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,39 +31,38 @@ function getStalwartApiUrl(jmapServerUrl: string): string {
|
||||
/**
|
||||
* Extract credentials from the incoming request.
|
||||
*
|
||||
* Tries the explicit headers first (`Authorization`, `X-JMAP-Server-URL`,
|
||||
* `X-JMAP-Username`), then falls back to the encrypted session cookie.
|
||||
* Credentials are read from a verified, httpOnly auth-context cookie that is
|
||||
* populated after a successful JMAP login or token refresh.
|
||||
*/
|
||||
function parseSlot(raw: string | null): number | null {
|
||||
if (raw === null) return null;
|
||||
const slot = parseInt(raw, 10);
|
||||
return Number.isNaN(slot) || slot < 0 || slot > 4 ? null : slot;
|
||||
}
|
||||
|
||||
function getCandidateSlots(request: NextRequest): number[] {
|
||||
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
|
||||
?? parseSlot(request.nextUrl.searchParams.get('slot'));
|
||||
|
||||
return requestedSlot === null ? [0, 1, 2, 3, 4] : [requestedSlot];
|
||||
}
|
||||
|
||||
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const serverUrl = request.headers.get('X-JMAP-Server-URL');
|
||||
const username = request.headers.get('X-JMAP-Username');
|
||||
const cookieStore = await cookies();
|
||||
|
||||
for (const slot of getCandidateSlots(request)) {
|
||||
const context = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (!context) continue;
|
||||
|
||||
if (authHeader && serverUrl && username) {
|
||||
const cookieStore = await cookies();
|
||||
const hasSessionCookie = !!cookieStore.get(SESSION_COOKIE)?.value;
|
||||
return {
|
||||
apiUrl: getStalwartApiUrl(serverUrl),
|
||||
serverUrl,
|
||||
authHeader,
|
||||
username,
|
||||
hasSessionCookie,
|
||||
apiUrl: getStalwartApiUrl(context.serverUrl),
|
||||
serverUrl: context.serverUrl,
|
||||
authHeader: context.authHeader,
|
||||
username: context.username,
|
||||
hasSessionCookie: !!cookieStore.get(sessionCookieName(slot))?.value,
|
||||
slot,
|
||||
};
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
const credentials = decryptSession(token);
|
||||
if (!credentials) return null;
|
||||
|
||||
const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
|
||||
return {
|
||||
apiUrl: getStalwartApiUrl(credentials.serverUrl),
|
||||
serverUrl: credentials.serverUrl,
|
||||
authHeader: basic,
|
||||
username: credentials.username,
|
||||
hasSessionCookie: true,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
+27
-27
@@ -236,11 +236,11 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
debug.group('TNEF Parser');
|
||||
debug.log('Input data size:', data.byteLength, 'bytes');
|
||||
debug.group('TNEF Parser', 'email');
|
||||
debug.log('email', 'Input data size:', data.byteLength, 'bytes');
|
||||
|
||||
if (data.byteLength < 6) {
|
||||
debug.warn('TNEF data too small (< 6 bytes), skipping');
|
||||
debug.warn('email', 'TNEF data too small (< 6 bytes), skipping');
|
||||
debug.groupEnd();
|
||||
return result;
|
||||
}
|
||||
@@ -249,11 +249,11 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
||||
|
||||
const signature = r.readUint32LE();
|
||||
if (signature !== TNEF_SIGNATURE) {
|
||||
debug.warn('Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
|
||||
debug.warn('email', 'Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
|
||||
debug.groupEnd();
|
||||
return result;
|
||||
}
|
||||
debug.log('TNEF signature valid');
|
||||
debug.log('email', 'TNEF signature valid');
|
||||
|
||||
r.skip(2); // legacy key
|
||||
|
||||
@@ -268,7 +268,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
||||
attrCount++;
|
||||
|
||||
if (attrLen > r.remaining - 2) {
|
||||
debug.warn('Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
|
||||
debug.warn('email', 'Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -276,17 +276,17 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
||||
r.skip(2); // checksum
|
||||
|
||||
const levelName = level === LVL_MESSAGE ? 'MESSAGE' : level === LVL_ATTACHMENT ? 'ATTACHMENT' : 'UNKNOWN(' + level + ')';
|
||||
debug.log('Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
|
||||
debug.log('email', 'Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
|
||||
|
||||
if (level === LVL_MESSAGE) {
|
||||
if (attrID === attBody) {
|
||||
result.body = new TextDecoder('utf-8').decode(attrData);
|
||||
debug.log(' → Extracted plain text body (' + result.body.length + ' chars)');
|
||||
debug.log('email', ' → Extracted plain text body (' + result.body.length + ' chars)');
|
||||
} else if (attrID === attMAPIProps) {
|
||||
const props = parseMAPIProps(attrData);
|
||||
debug.log(' → Parsed', props.size, 'MAPI properties from message');
|
||||
debug.log('email', ' → Parsed', props.size, 'MAPI properties from message');
|
||||
props.forEach((val, propID) => {
|
||||
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||
debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||
});
|
||||
|
||||
// HTML body
|
||||
@@ -298,9 +298,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
||||
} else {
|
||||
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
|
||||
}
|
||||
debug.log(' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
|
||||
debug.log('email', ' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
|
||||
} else {
|
||||
debug.log(' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
|
||||
debug.log('email', ' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
|
||||
}
|
||||
|
||||
// Plain text body from MAPI props (fallback)
|
||||
@@ -308,9 +308,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
||||
const bodyProp = props.get(PR_BODY);
|
||||
if (bodyProp?.value instanceof Uint8Array) {
|
||||
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
|
||||
debug.log(' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
|
||||
debug.log('email', ' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
|
||||
} else {
|
||||
debug.log(' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
|
||||
debug.log('email', ' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,7 +318,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
||||
if (attrID === attAttachRenddata) {
|
||||
// Start of a new attachment — flush previous
|
||||
if (curAttach?.data) {
|
||||
debug.log(' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||
debug.log('email', ' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||
result.attachments.push({
|
||||
name: curAttach.name,
|
||||
mimeType: curAttach.mimeType,
|
||||
@@ -326,40 +326,40 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
||||
});
|
||||
}
|
||||
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
|
||||
debug.log(' → New attachment started');
|
||||
debug.log('email', ' → New attachment started');
|
||||
} else if (attrID === attAttachTitle && curAttach) {
|
||||
let len = attrData.byteLength;
|
||||
if (len > 0 && attrData[len - 1] === 0) len--;
|
||||
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
|
||||
debug.log(' → Attachment short name:', curAttach.name);
|
||||
debug.log('email', ' → Attachment short name:', curAttach.name);
|
||||
} else if (attrID === attAttachData && curAttach) {
|
||||
curAttach.data = attrData;
|
||||
debug.log(' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
|
||||
debug.log('email', ' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
|
||||
} else if (attrID === attAttachment && curAttach) {
|
||||
const props = parseMAPIProps(attrData);
|
||||
debug.log(' → Parsed', props.size, 'MAPI properties from attachment');
|
||||
debug.log('email', ' → Parsed', props.size, 'MAPI properties from attachment');
|
||||
props.forEach((val, propID) => {
|
||||
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||
debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||
});
|
||||
|
||||
const longName = props.get(PR_ATTACH_LONG_FILENAME);
|
||||
if (longName?.value instanceof Uint8Array) {
|
||||
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
|
||||
debug.log(' → Attachment long filename:', curAttach.name);
|
||||
debug.log('email', ' → Attachment long filename:', curAttach.name);
|
||||
}
|
||||
|
||||
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
|
||||
if (mimeTag?.value instanceof Uint8Array) {
|
||||
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
|
||||
debug.log(' → Attachment MIME type:', curAttach.mimeType);
|
||||
debug.log('email', ' → Attachment MIME type:', curAttach.mimeType);
|
||||
}
|
||||
|
||||
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
||||
if (attachData?.value instanceof Uint8Array) {
|
||||
curAttach.data = attachData.value;
|
||||
debug.log(' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
|
||||
debug.log('email', ' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
|
||||
} else {
|
||||
debug.log(' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
|
||||
debug.log('email', ' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,7 +367,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
||||
|
||||
// Flush last attachment
|
||||
if (curAttach?.data) {
|
||||
debug.log('Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||
debug.log('email', 'Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||
result.attachments.push({
|
||||
name: curAttach.name,
|
||||
mimeType: curAttach.mimeType,
|
||||
@@ -375,9 +375,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
||||
});
|
||||
}
|
||||
|
||||
debug.log('TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
|
||||
debug.log('email', 'TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
|
||||
if (result.attachments.length > 0) {
|
||||
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })));
|
||||
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })), 'email');
|
||||
}
|
||||
debug.groupEnd();
|
||||
|
||||
|
||||
+63
-2
@@ -1,6 +1,7 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Mailbox } from "./jmap/types";
|
||||
import { debug } from "./debug";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -98,6 +99,7 @@ const ROLE_PRIORITY: Record<string, number> = {
|
||||
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
|
||||
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
||||
const result: Mailbox[] = [];
|
||||
const removed: { id: string; name: string; matchedRole: string; parentId?: string }[] = [];
|
||||
|
||||
// Group role mailboxes by account so deduplication is scoped per-account
|
||||
const rolesByAccount = new Map<string, Mailbox[]>();
|
||||
@@ -109,6 +111,12 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
||||
}
|
||||
});
|
||||
|
||||
// Build a set of IDs that are referenced as parents
|
||||
const referencedParentIds = new Set<string>();
|
||||
mailboxes.forEach(mb => {
|
||||
if (mb.parentId) referencedParentIds.add(mb.parentId);
|
||||
});
|
||||
|
||||
// Filter out duplicates scoped to the same account
|
||||
mailboxes.forEach(mb => {
|
||||
// If this mailbox has a role, always keep it
|
||||
@@ -117,30 +125,59 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a duplicate of a role-based mailbox in the SAME account
|
||||
// Never deduplicate nested mailboxes — only root-level folders can be
|
||||
// duplicates of role-based mailboxes. Removing a nested folder that happens
|
||||
// to share a name with a role folder (e.g. a subfolder named "Sent") would
|
||||
// orphan its children to root level. (GitHub #118)
|
||||
if (mb.parentId) {
|
||||
result.push(mb);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this root-level mailbox is a duplicate of a role-based mailbox in the SAME account
|
||||
const accountKey = mb.accountId || '';
|
||||
const accountRoles = rolesByAccount.get(accountKey) || [];
|
||||
const lowerName = mb.name.toLowerCase();
|
||||
const isDuplicate = accountRoles.some(roleMb => {
|
||||
const matchedRole = accountRoles.find(roleMb => {
|
||||
const roleLowerName = roleMb.name.toLowerCase();
|
||||
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
|
||||
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
|
||||
});
|
||||
const isDuplicate = !!matchedRole;
|
||||
|
||||
// Only keep if not a duplicate
|
||||
if (!isDuplicate) {
|
||||
result.push(mb);
|
||||
} else {
|
||||
removed.push({ id: mb.id, name: mb.name, matchedRole: matchedRole!.name, parentId: mb.parentId });
|
||||
// Warn if this removed mailbox is a parent of other mailboxes (orphan risk)
|
||||
if (referencedParentIds.has(mb.id)) {
|
||||
debug.warn('jmap', `[Mailbox Tree] Deduplication removed mailbox "${mb.name}" (id: ${mb.id}) which is a parent of other mailboxes. ` +
|
||||
`Matched role mailbox: "${matchedRole!.name}" (role: ${matchedRole!.role}). ` +
|
||||
`Children referencing parentId "${mb.id}" will be orphaned to root level.`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (removed.length > 0) {
|
||||
debug.log('jmap', `[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build a hierarchical tree structure from flat mailbox array
|
||||
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
debug.log('jmap', `[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`);
|
||||
|
||||
// Deduplicate mailboxes first
|
||||
const deduplicated = deduplicateMailboxes(mailboxes);
|
||||
|
||||
if (deduplicated.length !== mailboxes.length) {
|
||||
debug.log('jmap', `[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`);
|
||||
}
|
||||
|
||||
// Separate own and shared mailboxes
|
||||
const ownMailboxes = deduplicated.filter(mb => !mb.isShared);
|
||||
const sharedMailboxes = deduplicated.filter(mb => mb.isShared);
|
||||
@@ -168,6 +205,7 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
};
|
||||
|
||||
// Second pass: build tree structure for own mailboxes
|
||||
const orphanedMailboxes: { id: string; name: string; parentId: string }[] = [];
|
||||
ownMailboxes.forEach(mailbox => {
|
||||
const node = mailboxMap.get(mailbox.id)!;
|
||||
|
||||
@@ -176,13 +214,36 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
// Root level mailbox or orphaned mailbox
|
||||
if (mailbox.parentId) {
|
||||
orphanedMailboxes.push({ id: mailbox.id, name: mailbox.name, parentId: mailbox.parentId });
|
||||
}
|
||||
rootMailboxes.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
if (orphanedMailboxes.length > 0) {
|
||||
debug.warn('jmap', `[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`,
|
||||
orphanedMailboxes
|
||||
);
|
||||
}
|
||||
|
||||
// Third pass: correctly calculate depths from the root down
|
||||
recalculateDepths(rootMailboxes, 0);
|
||||
|
||||
// Log tree depth statistics
|
||||
const maxDepth = (nodes: MailboxNode[]): number => {
|
||||
let max = 0;
|
||||
for (const node of nodes) {
|
||||
max = Math.max(max, node.depth);
|
||||
if (node.children.length > 0) max = Math.max(max, maxDepth(node.children));
|
||||
}
|
||||
return max;
|
||||
};
|
||||
debug.log('jmap', `[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` +
|
||||
`max depth: ${maxDepth(rootMailboxes)}, ` +
|
||||
`total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}`
|
||||
);
|
||||
|
||||
// If we have shared mailboxes, create a virtual "Shared Folders" parent
|
||||
if (sharedMailboxes.length > 0) {
|
||||
// Group shared mailboxes by account
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user