Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 displayed in the UI
|
||||||
APP_NAME=Bulwark Webmail
|
APP_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL of your JMAP-compatible mail server (required)
|
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||||
JMAP_SERVER_URL=https://your-jmap-server.com
|
JMAP_SERVER_URL=https://your-jmap-server.com
|
||||||
|
|
||||||
|
# Allow users to specify a custom JMAP server URL on the login form.
|
||||||
|
# When enabled, a "JMAP Server" field appears on the login page.
|
||||||
|
# Users can connect to any JMAP-compatible server.
|
||||||
|
# NOTE: External JMAP servers must include this domain in their CORS
|
||||||
|
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||||
|
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stalwart Mail Server Integration
|
# Stalwart Mail Server Integration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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,49 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.4.10 (2026-03-31)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin
|
||||||
|
- **Calendar**: Implement client-side recurrence expansion for calendar events
|
||||||
|
- **Calendar**: Add iCal subscription editing and batch event import
|
||||||
|
- **Calendar**: Add hover preview settings and functionality
|
||||||
|
- **Calendar**: Add virtual location input for calendar events (#121)
|
||||||
|
- **Email**: Add reply-to addresses support in email composer
|
||||||
|
- **Email**: Add mail layout settings and update email list components
|
||||||
|
- **Email**: Add auto-select reply identity feature with settings and localization
|
||||||
|
- **Email**: Enhance compose functionality with button integration and translations
|
||||||
|
- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts
|
||||||
|
- **Filters**: Skip server-managed vacation script in Sieve script handling
|
||||||
|
- **Settings**: Add support for custom JMAP server endpoints in login and settings
|
||||||
|
- **Settings**: Add folder expansion state management and settings navigation
|
||||||
|
- **UI**: Add options to hide account switcher and show account avatars on navigation rail
|
||||||
|
- **i18n**: Add JMAP server endpoint labels and hints in multiple languages
|
||||||
|
- **i18n**: Add missing translation keys to all non-English locales
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Patch critical auth bypass and credential leak vulnerabilities
|
||||||
|
- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35)
|
||||||
|
- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35)
|
||||||
|
- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116)
|
||||||
|
- **Calendar**: Enhance calendar event handling to distinguish between events and tasks
|
||||||
|
- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113)
|
||||||
|
- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113)
|
||||||
|
- **Calendar**: Fix events disappearing after iCal import/subscription refresh
|
||||||
|
- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments
|
||||||
|
- **Calendar**: Use UTC timestamps for timed event rendering
|
||||||
|
- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query
|
||||||
|
- **Email**: Enhance email loading and deduplication logic in email store (#119)
|
||||||
|
- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60)
|
||||||
|
- **Email**: Match hover action background to selected row state
|
||||||
|
- **Email**: Align tag counts with mailbox folder counts in sidebar
|
||||||
|
- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117)
|
||||||
|
- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118)
|
||||||
|
- **UI**: Improve dark mode handling for media elements and background images
|
||||||
|
- **UI**: Adjust account list spacing and remove push connection indicator
|
||||||
|
- **UI**: Fix nested button in theme card
|
||||||
|
|
||||||
## 1.4.9 (2026-03-27)
|
## 1.4.9 (2026-03-27)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+1
-1
@@ -195,7 +195,7 @@ webmail/
|
|||||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||||
- **Sanitize user input** and email content
|
- **Sanitize user input** and email content
|
||||||
- **Block external content** by default for privacy
|
- **Block external content** by default for privacy
|
||||||
- Report security vulnerabilities privately
|
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
|
|||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||||
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
@@ -66,9 +67,9 @@ export default function CalendarPage() {
|
|||||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||||
refreshAllSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
@@ -82,6 +83,7 @@ export default function CalendarPage() {
|
|||||||
const [showEventModal, setShowEventModal] = useState(false);
|
const [showEventModal, setShowEventModal] = useState(false);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
|
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
@@ -337,18 +339,28 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
|
if (calendarHoverPreview === 'off') return;
|
||||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||||
// Don't show hover popover if the sidebar is already open for this event
|
// Don't show hover popover if the sidebar is already open for this event
|
||||||
if (showEventModal && editEvent?.id === event.id) return;
|
if (showEventModal && editEvent?.id === event.id) return;
|
||||||
setDetailEvent(event);
|
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
|
||||||
setDetailAnchorRect(anchorRect);
|
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
|
||||||
}, [isMobile, showEventModal, editEvent]);
|
hoverTimerRef.current = setTimeout(() => {
|
||||||
|
setDetailEvent(event);
|
||||||
|
setDetailAnchorRect(anchorRect);
|
||||||
|
}, ms);
|
||||||
|
} else {
|
||||||
|
setDetailEvent(event);
|
||||||
|
setDetailAnchorRect(anchorRect);
|
||||||
|
}
|
||||||
|
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
|
||||||
|
|
||||||
const handleHoverLeave = useCallback(() => {
|
const handleHoverLeave = useCallback(() => {
|
||||||
|
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||||
hoverTimerRef.current = setTimeout(() => {
|
hoverTimerRef.current = setTimeout(() => {
|
||||||
setDetailEvent(null);
|
setDetailEvent(null);
|
||||||
setDetailAnchorRect(null);
|
setDetailAnchorRect(null);
|
||||||
}, 200);
|
}, 300);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEditFromDetail = useCallback(() => {
|
const handleEditFromDetail = useCallback(() => {
|
||||||
@@ -385,12 +397,16 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [client, fetchEvents]);
|
}, [client, fetchEvents]);
|
||||||
|
|
||||||
const focusCalendarOnEvent = useCallback((event: Pick<CalendarEvent, "start">) => {
|
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||||
if (!event.start) {
|
if (!event.start) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventDate = parseISO(event.start);
|
const eventDate = getEventStartDate({
|
||||||
|
start: event.start,
|
||||||
|
utcStart: event.utcStart ?? null,
|
||||||
|
showWithoutTime: event.showWithoutTime ?? false,
|
||||||
|
});
|
||||||
if (Number.isNaN(eventDate.getTime())) {
|
if (Number.isNaN(eventDate.getTime())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -873,7 +889,7 @@ export default function CalendarPage() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 flex flex-col overflow-hidden">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
{viewContent}
|
{viewContent}
|
||||||
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
||||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||||
@@ -933,6 +949,7 @@ export default function CalendarPage() {
|
|||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1099,6 +1116,18 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingSubscription && client && (() => {
|
||||||
|
const sub = icalSubscriptions.find(s => s.id === editingSubscription);
|
||||||
|
if (!sub) return null;
|
||||||
|
return (
|
||||||
|
<ICalSubscriptionModal
|
||||||
|
client={client}
|
||||||
|
editSubscription={sub}
|
||||||
|
onClose={() => setEditingSubscription(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<RecurrenceScopeDialog
|
<RecurrenceScopeDialog
|
||||||
isOpen={!!pendingScopeAction}
|
isOpen={!!pendingScopeAction}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
|||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
|
|
||||||
const APP_VERSION = "1.4.9";
|
const APP_VERSION = "1.4.10";
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||||
@@ -32,13 +32,14 @@ export default function LoginPage() {
|
|||||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = useConfig();
|
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
|
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||||
const [totpCode, setTotpCode] = useState("");
|
const [totpCode, setTotpCode] = useState("");
|
||||||
const [showTotpField, setShowTotpField] = useState(false);
|
const [showTotpField, setShowTotpField] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
@@ -73,6 +74,12 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [appName, serverUrl]);
|
}, [appName, serverUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverUrl && !jmapEndpoint) {
|
||||||
|
setJmapEndpoint(serverUrl);
|
||||||
|
}
|
||||||
|
}, [serverUrl, jmapEndpoint]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||||
@@ -91,6 +98,14 @@ export default function LoginPage() {
|
|||||||
prevError.current = error;
|
prevError.current = error;
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
|
// Auto-show and focus TOTP field when server requires it
|
||||||
|
useEffect(() => {
|
||||||
|
if (error === 'totp_required') {
|
||||||
|
setShowTotpField(true);
|
||||||
|
setTimeout(() => totpInputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl) return;
|
if (!serverUrl) return;
|
||||||
const saved = localStorage.getItem("webmail_usernames");
|
const saved = localStorage.getItem("webmail_usernames");
|
||||||
@@ -264,7 +279,7 @@ export default function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serverUrl && !demoMode) {
|
if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||||
<div className="w-full max-w-md mx-auto px-4 text-center">
|
<div className="w-full max-w-md mx-auto px-4 text-center">
|
||||||
@@ -362,7 +377,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||||
sessionStorage.setItem("oauth_state", state);
|
sessionStorage.setItem("oauth_state", state);
|
||||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
|
||||||
if (isAddAccountMode) {
|
if (isAddAccountMode) {
|
||||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||||
}
|
}
|
||||||
@@ -382,8 +397,9 @@ export default function LoginPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
|
||||||
const success = await login(
|
const success = await login(
|
||||||
serverUrl,
|
effectiveServerUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
formData.password,
|
formData.password,
|
||||||
totpCode || undefined,
|
totpCode || undefined,
|
||||||
@@ -749,6 +765,27 @@ export default function LoginPage() {
|
|||||||
/* Login Form */
|
/* Login Form */
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<fieldset disabled={isLoading} className="space-y-4">
|
<fieldset disabled={isLoading} className="space-y-4">
|
||||||
|
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
|
||||||
|
{allowCustomJmapEndpoint && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
||||||
|
{t("jmap_endpoint_label")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="jmap-endpoint"
|
||||||
|
type="url"
|
||||||
|
value={jmapEndpoint}
|
||||||
|
onChange={(e) => setJmapEndpoint(e.target.value)}
|
||||||
|
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||||
|
placeholder={t("jmap_endpoint_placeholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{t("jmap_endpoint_cors_hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Username field */}
|
{/* Username field */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
||||||
@@ -861,7 +898,10 @@ export default function LoginPage() {
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||||
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
|
className={cn(
|
||||||
|
"h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest",
|
||||||
|
error === 'totp_required' && "border-primary ring-2 ring-primary/30"
|
||||||
|
)}
|
||||||
placeholder={t("totp_placeholder")}
|
placeholder={t("totp_placeholder")}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
aria-label={t("totp_label")}
|
aria-label={t("totp_label")}
|
||||||
|
|||||||
+43
-24
@@ -30,6 +30,7 @@ import {
|
|||||||
ComposerErrorFallback,
|
ComposerErrorFallback,
|
||||||
} from "@/components/error";
|
} from "@/components/error";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||||
@@ -53,6 +54,7 @@ export default function Home() {
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { appName } = useConfig();
|
const { appName } = useConfig();
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const [showComposer, setShowComposer] = useState(false);
|
const [showComposer, setShowComposer] = useState(false);
|
||||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||||
const [composerDraftText, setComposerDraftText] = useState("");
|
const [composerDraftText, setComposerDraftText] = useState("");
|
||||||
@@ -519,7 +521,10 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleEditDraft = async (email?: Email) => {
|
const handleEditDraft = async (email?: Email) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
let draft = email || selectedEmail;
|
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
|
||||||
|
? email
|
||||||
|
: selectedEmail;
|
||||||
|
let draft = draftCandidate;
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
|
|
||||||
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
|
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
|
||||||
@@ -967,6 +972,10 @@ export default function Home() {
|
|||||||
|
|
||||||
// Get current mailbox name for mobile header
|
// Get current mailbox name for mobile header
|
||||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
|
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||||
|
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
||||||
|
|
||||||
// Handle email selection with mobile view switching
|
// Handle email selection with mobile view switching
|
||||||
const handleEmailSelect = async (email: { id: string }) => {
|
const handleEmailSelect = async (email: { id: string }) => {
|
||||||
@@ -1017,6 +1026,9 @@ export default function Home() {
|
|||||||
setConversationEmails([]);
|
setConversationEmails([]);
|
||||||
}
|
}
|
||||||
selectEmail(null);
|
selectEmail(null);
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
setActiveView("list");
|
setActiveView("list");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1199,10 +1211,9 @@ export default function Home() {
|
|||||||
// Tablet/Desktop: fixed width with collapse animation
|
// Tablet/Desktop: fixed width with collapse animation
|
||||||
"md:flex-shrink-0 md:shadow-sm",
|
"md:flex-shrink-0 md:shadow-sm",
|
||||||
!isResizing && "transition-all duration-200 ease-out",
|
!isResizing && "transition-all duration-200 ease-out",
|
||||||
// Tablet: collapse when email selected
|
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||||
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
|
||||||
)}
|
)}
|
||||||
style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined}
|
style={!isMobile && !shouldCollapseListPane ? { width: emailListWidth } : undefined}
|
||||||
>
|
>
|
||||||
{/* Mobile Header for List View */}
|
{/* Mobile Header for List View */}
|
||||||
<MobileHeader
|
<MobileHeader
|
||||||
@@ -1487,24 +1498,27 @@ export default function Home() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Compose Button (mobile) */}
|
{/* Floating Compose Button */}
|
||||||
{isMobile && (
|
<Button
|
||||||
<Button
|
onClick={() => {
|
||||||
onClick={() => {
|
setComposerMode('compose');
|
||||||
setComposerMode('compose');
|
setShowComposer(true);
|
||||||
setShowComposer(true);
|
if (isMobile) setActiveView('viewer');
|
||||||
setActiveView('viewer');
|
}}
|
||||||
}}
|
className={cn(
|
||||||
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
|
"absolute z-40 rounded-full shadow-lg",
|
||||||
aria-label={t('sidebar.compose')}
|
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
|
||||||
>
|
)}
|
||||||
<PenSquare className="h-6 w-6" />
|
aria-label={t('sidebar.compose')}
|
||||||
</Button>
|
title={t('sidebar.compose_hint')}
|
||||||
)}
|
data-tour="compose-button"
|
||||||
|
>
|
||||||
|
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email list resize handle (desktop only) */}
|
{/* Email list resize handle (desktop only) */}
|
||||||
{!isMobile && !isTablet && (
|
{!isMobile && !isTablet && !isFocusedMailLayout && (
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||||
@@ -1521,7 +1535,8 @@ export default function Home() {
|
|||||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||||
// Tablet/Desktop: relative
|
// Tablet/Desktop: relative
|
||||||
"md:relative"
|
"md:relative",
|
||||||
|
shouldHideViewerPane && "md:hidden"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Inline Composer - shown in viewer pane */}
|
{/* Inline Composer - shown in viewer pane */}
|
||||||
@@ -1537,8 +1552,10 @@ export default function Home() {
|
|||||||
mode={pendingDraft?.mode ?? composerMode}
|
mode={pendingDraft?.mode ?? composerMode}
|
||||||
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
||||||
from: selectedEmail.from,
|
from: selectedEmail.from,
|
||||||
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
to: selectedEmail.to,
|
to: selectedEmail.to,
|
||||||
cc: selectedEmail.cc,
|
cc: selectedEmail.cc,
|
||||||
|
bcc: selectedEmail.bcc,
|
||||||
subject: selectedEmail.subject,
|
subject: selectedEmail.subject,
|
||||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||||
@@ -1648,14 +1665,15 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
onQuickReply={handleQuickReply}
|
onQuickReply={handleQuickReply}
|
||||||
onBack={() => {
|
onBack={handleMobileBack}
|
||||||
setTabletListVisible(true);
|
|
||||||
selectEmail(null);
|
|
||||||
}}
|
|
||||||
onNavigateNext={handleNavigateNext}
|
onNavigateNext={handleNavigateNext}
|
||||||
onNavigatePrev={handleNavigatePrev}
|
onNavigatePrev={handleNavigatePrev}
|
||||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||||
onEditDraft={handleEditDraft}
|
onEditDraft={handleEditDraft}
|
||||||
|
onCompose={() => {
|
||||||
|
setComposerMode('compose');
|
||||||
|
setShowComposer(true);
|
||||||
|
}}
|
||||||
currentUserEmail={client?.getUsername()}
|
currentUserEmail={client?.getUsername()}
|
||||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||||
@@ -1709,6 +1727,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
<TotpReauthDialog />
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
|
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
|
||||||
@@ -396,6 +397,13 @@ export default function AdminPluginsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/plugins/${plugin.id}`}
|
||||||
|
title="Configure"
|
||||||
|
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||||
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ const RESTRICTABLE_SETTINGS = [
|
|||||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||||
|
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
||||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||||
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||||
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
||||||
|
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
||||||
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
||||||
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||||
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ export default function AdminSettingsPage() {
|
|||||||
<SettingsSection title="General">
|
<SettingsSection title="General">
|
||||||
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||||
|
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||||
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||||
|
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||||
|
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
||||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,6 +157,9 @@ export async function POST(request: NextRequest) {
|
|||||||
permissions: (manifest.permissions as string[]) || [],
|
permissions: (manifest.permissions as string[]) || [],
|
||||||
entrypoint: manifest.entrypoint as string,
|
entrypoint: manifest.entrypoint as string,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,45 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
||||||
|
const { serverUrl, username } = credentials;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ serverUrl, username },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT — retrieve full credentials (including password) for session restoration.
|
||||||
|
* Protected by Sec-Fetch-Site to ensure only same-origin browser requests succeed.
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Block non-browser and cross-origin requests
|
||||||
|
const secFetchSite = request.headers.get('sec-fetch-site');
|
||||||
|
if (secFetchSite !== 'same-origin') {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = decryptSession(token);
|
||||||
|
if (!credentials) {
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(credentials, {
|
return NextResponse.json(credentials, {
|
||||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', ''),
|
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||||
demoMode: configManager.get<boolean>('demoMode', false),
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||||
|
|||||||
@@ -53,16 +53,14 @@ function isEnabled(): boolean {
|
|||||||
/**
|
/**
|
||||||
* Verify identity against session cookies across all account slots.
|
* Verify identity against session cookies across all account slots.
|
||||||
* With multi-account, the requesting account may be on any slot (0-4).
|
* With multi-account, the requesting account may be on any slot (0-4).
|
||||||
* Returns true if any slot matches OR if no session cookies exist at all.
|
* Returns true only if a matching session cookie is found.
|
||||||
*/
|
*/
|
||||||
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
let hasAnyCookie = false;
|
|
||||||
|
|
||||||
for (let slot = 0; slot <= 4; slot++) {
|
for (let slot = 0; slot <= 4; slot++) {
|
||||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||||
if (!token) continue;
|
if (!token) continue;
|
||||||
hasAnyCookie = true;
|
|
||||||
|
|
||||||
const session = decryptSession(token);
|
const session = decryptSession(token);
|
||||||
if (session && session.username === username && session.serverUrl === serverUrl) {
|
if (session && session.username === username && session.serverUrl === serverUrl) {
|
||||||
@@ -70,10 +68,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No cookies at all → can't verify, allow (same-origin protection applies)
|
// No matching session found (or no cookies at all) → reject
|
||||||
if (!hasAnyCookie) return true;
|
|
||||||
|
|
||||||
// Cookies exist but none matched → identity mismatch
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useMemo, useRef, useEffect, useCallback } from "react";
|
import { useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, parseISO, isToday, isTomorrow, startOfDay } from "date-fns";
|
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
||||||
import { MapPin, Users } from "lucide-react";
|
import { MapPin, Users } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export function CalendarAgendaView({
|
|||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const sorted = [...events].sort((a, b) =>
|
const sorted = [...events].sort((a, b) =>
|
||||||
parseISO(a.start).getTime() - parseISO(b.start).getTime()
|
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups: DayGroup[] = [];
|
const groups: DayGroup[] = [];
|
||||||
@@ -144,9 +144,9 @@ export function CalendarAgendaView({
|
|||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||||
const color = getEventColor(ev, calendar);
|
const color = getEventColor(ev, calendar);
|
||||||
const start = parseISO(ev.start);
|
const start = getEventStartDate(ev);
|
||||||
const durMin = parseDuration(ev.duration);
|
const durMin = parseDuration(ev.duration);
|
||||||
const end = new Date(start.getTime() + durMin * 60000);
|
const end = getEventEndDate(ev);
|
||||||
const locationName = ev.locations
|
const locationName = ev.locations
|
||||||
? Object.values(ev.locations)[0]?.name
|
? Object.values(ev.locations)[0]?.name
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
|
|||||||
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
import type { PendingEventPreview } from "./event-modal";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
@@ -66,7 +66,7 @@ export function CalendarDayView({
|
|||||||
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
||||||
if (!spansThisDay) return;
|
if (!spansThisDay) return;
|
||||||
|
|
||||||
if (ev.showWithoutTime) allDay.push(ev);
|
if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
|
||||||
else timed.push(ev);
|
else timed.push(ev);
|
||||||
} catch { /* skip invalid dates */ }
|
} catch { /* skip invalid dates */ }
|
||||||
});
|
});
|
||||||
@@ -127,10 +127,10 @@ export function CalendarDayView({
|
|||||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
<div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
||||||
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
||||||
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
||||||
{isMobile
|
{isMobile
|
||||||
@@ -200,7 +200,7 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
@@ -241,11 +241,9 @@ export function CalendarDayView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, ListTodo, RefreshCw, Share2, Trash2 } from "lucide-react";
|
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
@@ -18,6 +18,7 @@ interface CalendarSidebarPanelProps {
|
|||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
client?: IJMAPClient | null;
|
client?: IJMAPClient | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export function CalendarSidebarPanel({
|
|||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
@@ -173,6 +175,16 @@ export function CalendarSidebarPanel({
|
|||||||
ref={contextMenuRef}
|
ref={contextMenuRef}
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setContextMenuCalId(null);
|
||||||
|
onEditSubscription?.(sub.id);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
{tSub('edit')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRefreshSubscription(sub.id)}
|
onClick={() => handleRefreshSubscription(sub.id)}
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
import type { PendingEventPreview } from "./event-modal";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
@@ -77,7 +77,7 @@ export function CalendarWeekView({
|
|||||||
const cursor = new Date(startDay);
|
const cursor = new Date(startDay);
|
||||||
while (cursor <= endDay) {
|
while (cursor <= endDay) {
|
||||||
const key = format(cursor, "yyyy-MM-dd");
|
const key = format(cursor, "yyyy-MM-dd");
|
||||||
if (!ev.showWithoutTime) {
|
if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
|
||||||
const arr = timed.get(key) || [];
|
const arr = timed.get(key) || [];
|
||||||
arr.push(ev);
|
arr.push(ev);
|
||||||
timed.set(key, arr);
|
timed.set(key, arr);
|
||||||
@@ -89,10 +89,18 @@ export function CalendarWeekView({
|
|||||||
return timed;
|
return timed;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const allDaySegments = useMemo(() => buildWeekSegments(
|
const allDaySegments = useMemo(() => {
|
||||||
events.filter((event) => event.showWithoutTime),
|
const explicitAllDay = buildWeekSegmentsRaw(
|
||||||
weekDays,
|
events.filter((event) => event.showWithoutTime),
|
||||||
), [events, weekDays]);
|
weekDays,
|
||||||
|
);
|
||||||
|
const timedFullDay = buildTimedFullDayWeekSegments(
|
||||||
|
events.filter((event) => !event.showWithoutTime),
|
||||||
|
weekDays,
|
||||||
|
);
|
||||||
|
|
||||||
|
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
|
||||||
|
}, [events, weekDays]);
|
||||||
|
|
||||||
const allDayRowCount = useMemo(() => {
|
const allDayRowCount = useMemo(() => {
|
||||||
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||||
@@ -189,13 +197,13 @@ export function CalendarWeekView({
|
|||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col flex-1",
|
"flex min-h-0 min-w-0 flex-col flex-1",
|
||||||
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
|
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
|
||||||
)}
|
)}
|
||||||
role="grid"
|
role="grid"
|
||||||
aria-label={t("views.week")}
|
aria-label={t("views.week")}
|
||||||
>
|
>
|
||||||
<div className={cn("flex flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
<div
|
<div
|
||||||
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
||||||
@@ -321,7 +329,7 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
|
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
@@ -344,7 +352,7 @@ export function CalendarWeekView({
|
|||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = timedEvents.get(key) || [];
|
const dayEvents = timedEvents.get(key) || [];
|
||||||
const todayCol = isToday(day);
|
const todayCol = isToday(day);
|
||||||
const layouted = layoutOverlappingEvents(dayEvents);
|
const layouted = layoutOverlappingEvents(dayEvents, day);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -371,11 +379,9 @@ export function CalendarWeekView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
@@ -68,14 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
|
|
||||||
const calendarName = calendar?.name || "";
|
const calendarName = calendar?.name || "";
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
|
const endTime = getEventEndDate(event);
|
||||||
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
||||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -138,9 +139,9 @@ export function EventDetailPopover({
|
|||||||
const [isSavingNote, setIsSavingNote] = useState(false);
|
const [isSavingNote, setIsSavingNote] = useState(false);
|
||||||
|
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
const endDate = getEventEndDate(event);
|
||||||
|
|
||||||
const locationName = useMemo(() => {
|
const locationName = useMemo(() => {
|
||||||
if (!event.locations) return null;
|
if (!event.locations) return null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
|
|||||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput } from "./participant-input";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getStatusCounts,
|
getStatusCounts,
|
||||||
buildParticipantMap,
|
buildParticipantMap,
|
||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
@@ -159,7 +160,7 @@ export function EventModal({
|
|||||||
}, [event, existingParticipants]);
|
}, [event, existingParticipants]);
|
||||||
|
|
||||||
const getInitialStart = (): Date => {
|
const getInitialStart = (): Date => {
|
||||||
if (event?.start) return parseISO(event.start);
|
if (event?.start) return getEventStartDate(event);
|
||||||
if (defaultDate) {
|
if (defaultDate) {
|
||||||
const d = new Date(defaultDate);
|
const d = new Date(defaultDate);
|
||||||
if (defaultEndDate) return d;
|
if (defaultEndDate) return d;
|
||||||
@@ -177,9 +178,7 @@ export function EventModal({
|
|||||||
if (event.showWithoutTime) {
|
if (event.showWithoutTime) {
|
||||||
return getEventDisplayEndDate(event);
|
return getEventDisplayEndDate(event);
|
||||||
}
|
}
|
||||||
const s = parseISO(event.start);
|
return getEventEndDate(event);
|
||||||
const dur = parseDuration(event.duration);
|
|
||||||
return new Date(s.getTime() + dur * 60000);
|
|
||||||
}
|
}
|
||||||
if (defaultEndDate) return new Date(defaultEndDate);
|
if (defaultEndDate) return new Date(defaultEndDate);
|
||||||
return addHours(getInitialStart(), 1);
|
return addHours(getInitialStart(), 1);
|
||||||
@@ -190,6 +189,9 @@ export function EventModal({
|
|||||||
const [location, setLocation] = useState(
|
const [location, setLocation] = useState(
|
||||||
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
||||||
);
|
);
|
||||||
|
const [virtualLocation, setVirtualLocation] = useState(
|
||||||
|
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
|
||||||
|
);
|
||||||
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
||||||
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||||
@@ -315,6 +317,20 @@ export function EventModal({
|
|||||||
data.locations = null;
|
data.locations = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (virtualLocation.trim()) {
|
||||||
|
data.virtualLocations = {
|
||||||
|
vl1: {
|
||||||
|
"@type": "VirtualLocation",
|
||||||
|
name: null,
|
||||||
|
description: null,
|
||||||
|
uri: virtualLocation.trim(),
|
||||||
|
features: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (event && event.virtualLocations && Object.keys(event.virtualLocations).length > 0) {
|
||||||
|
data.virtualLocations = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (recurrence !== "none") {
|
if (recurrence !== "none") {
|
||||||
data.recurrenceRules = [{
|
data.recurrenceRules = [{
|
||||||
"@type": "RecurrenceRule",
|
"@type": "RecurrenceRule",
|
||||||
@@ -374,7 +390,7 @@ export function EventModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||||
|
|
||||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -384,7 +400,7 @@ export function EventModal({
|
|||||||
|
|
||||||
const handleDuplicate = useCallback(() => {
|
const handleDuplicate = useCallback(() => {
|
||||||
if (!event || !onDuplicate) return;
|
if (!event || !onDuplicate) return;
|
||||||
const start = parseISO(event.start);
|
const start = getEventStartDate(event);
|
||||||
const newStart = addDays(start, 1);
|
const newStart = addDays(start, 1);
|
||||||
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
@@ -403,6 +419,7 @@ export function EventModal({
|
|||||||
privacy: event.privacy,
|
privacy: event.privacy,
|
||||||
};
|
};
|
||||||
if (event.locations) data.locations = structuredClone(event.locations);
|
if (event.locations) data.locations = structuredClone(event.locations);
|
||||||
|
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
|
||||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||||
if (event.participants) data.participants = structuredClone(event.participants);
|
if (event.participants) data.participants = structuredClone(event.participants);
|
||||||
@@ -456,9 +473,9 @@ export function EventModal({
|
|||||||
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
||||||
|
|
||||||
if (isAttendeeMode && event) {
|
if (isAttendeeMode && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
||||||
const participants = getParticipantList(event);
|
const participants = getParticipantList(event);
|
||||||
|
|
||||||
@@ -567,9 +584,9 @@ export function EventModal({
|
|||||||
|
|
||||||
// View mode: read-only display of event details with Edit button
|
// View mode: read-only display of event details with Edit button
|
||||||
if (mode === "view" && event) {
|
if (mode === "view" && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
||||||
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||||
const viewParticipants = getParticipantList(event);
|
const viewParticipants = getParticipantList(event);
|
||||||
@@ -780,6 +797,39 @@ export function EventModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1 block">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Video className="w-4 h-4" />
|
||||||
|
{t("form.meeting_link")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={virtualLocation}
|
||||||
|
onChange={(e) => setVirtualLocation(e.target.value)}
|
||||||
|
placeholder="https://meet.example.com/..."
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<PluginSlot
|
||||||
|
name="calendar-event-actions"
|
||||||
|
className="mt-2 flex flex-wrap gap-2"
|
||||||
|
extraProps={{
|
||||||
|
eventData: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start: startDate + 'T' + startTime,
|
||||||
|
end: endDate + 'T' + endTime,
|
||||||
|
isAllDay: allDay,
|
||||||
|
location,
|
||||||
|
virtualLocation,
|
||||||
|
calendarId,
|
||||||
|
},
|
||||||
|
setVirtualLocation,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">
|
<label className="text-sm font-medium mb-1 block">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
|||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
@@ -17,7 +18,7 @@ interface ICalImportModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||||
|
|
||||||
type ImportStep = "select" | "preview" | "importing";
|
type ImportStep = "select" | "preview" | "importing";
|
||||||
@@ -195,7 +196,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
||||||
if (!event.start) return "";
|
if (!event.start) return "";
|
||||||
try {
|
try {
|
||||||
const date = parseISO(event.start);
|
const date = getEventStartDate(event as CalendarEvent);
|
||||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
return event.showWithoutTime
|
return event.showWithoutTime
|
||||||
? format(date, "MMM d, yyyy")
|
? format(date, "MMM d, yyyy")
|
||||||
|
|||||||
@@ -5,24 +5,28 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Loader2, Globe } from "lucide-react";
|
import { X, Loader2, Globe } from "lucide-react";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore, type ICalSubscription } from "@/stores/calendar-store";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface ICalSubscriptionModalProps {
|
interface ICalSubscriptionModalProps {
|
||||||
client: IJMAPClient;
|
client: IJMAPClient;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
editSubscription?: ICalSubscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
|
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
|
||||||
const t = useTranslations("calendar.subscription");
|
const t = useTranslations("calendar.subscription");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||||
|
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
|
||||||
|
|
||||||
const [url, setUrl] = useState("");
|
const isEdit = !!editSubscription;
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [color, setColor] = useState("#3b82f6");
|
const [url, setUrl] = useState(editSubscription?.url || "");
|
||||||
const [refreshInterval, setRefreshInterval] = useState(60);
|
const [name, setName] = useState(editSubscription?.name || "");
|
||||||
|
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
|
||||||
|
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
if (isEdit && editSubscription) {
|
||||||
if (subscription) {
|
const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
|
||||||
toast.success(t("success", { name: name.trim() }));
|
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();
|
onClose();
|
||||||
} else {
|
} 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 {
|
} catch {
|
||||||
setError(t("error"));
|
setError(isEdit ? t("update_error") : t("error"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -108,7 +123,7 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Globe className="w-5 h-5 text-primary" />
|
<Globe className="w-5 h-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
<h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
{t("subscribing")}
|
{isEdit ? t("saving") : t("subscribing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
t("subscribe")
|
isEdit ? t("save") : t("subscribe")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||||
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
||||||
isSameDay, isSameMonth, isToday, format, parseISO,
|
isSameDay, isSameMonth, isToday, format,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
type PickerView = "days" | "months" | "years";
|
type PickerView = "days" | "months" | "years";
|
||||||
@@ -54,7 +55,14 @@ export function MiniCalendar({
|
|||||||
const eventDates = useMemo(() => {
|
const eventDates = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
events.forEach(e => {
|
events.forEach(e => {
|
||||||
try { set.add(format(parseISO(e.start), "yyyy-MM-dd")); } catch { /* skip */ }
|
try {
|
||||||
|
const { startDay, endDay } = getEventDayBounds(e);
|
||||||
|
const cursor = new Date(startDay);
|
||||||
|
while (cursor <= endDay) {
|
||||||
|
set.add(format(cursor, "yyyy-MM-dd"));
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
});
|
});
|
||||||
return set;
|
return set;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
|
|||||||
useSettingsStore.setState({
|
useSettingsStore.setState({
|
||||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||||
showPreview: false,
|
showPreview: false,
|
||||||
|
mailLayout: 'split',
|
||||||
});
|
});
|
||||||
useEmailStore.setState({
|
useEmailStore.setState({
|
||||||
selectedEmailIds: new Set<string>(),
|
selectedEmailIds: new Set<string>(),
|
||||||
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
|
|||||||
render(<EmailListItem email={email} />);
|
render(<EmailListItem email={email} />);
|
||||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders inline preview text in focused mail layout', () => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
showPreview: true,
|
||||||
|
mailLayout: 'focus',
|
||||||
|
});
|
||||||
|
const email = makeEmail({ preview: 'Inline preview content' });
|
||||||
|
const { container } = render(<EmailListItem email={email} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('p')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
|||||||
import { TemplateForm } from "@/components/templates/template-form";
|
import { TemplateForm } from "@/components/templates/template-form";
|
||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
|
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
|
|
||||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||||
@@ -76,8 +77,10 @@ interface EmailComposerProps {
|
|||||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||||
replyTo?: {
|
replyTo?: {
|
||||||
from?: { email?: string; name?: string }[];
|
from?: { email?: string; name?: string }[];
|
||||||
|
replyToAddresses?: { email?: string; name?: string }[];
|
||||||
to?: { email?: string; name?: string }[];
|
to?: { email?: string; name?: string }[];
|
||||||
cc?: { email?: string; name?: string }[];
|
cc?: { email?: string; name?: string }[];
|
||||||
|
bcc?: { email?: string; name?: string }[];
|
||||||
subject?: string;
|
subject?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
htmlBody?: string;
|
htmlBody?: string;
|
||||||
@@ -100,17 +103,20 @@ export function EmailComposer({
|
|||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||||
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
|
|
||||||
// Initialize with reply/forward data if provided
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = () => {
|
const getInitialTo = () => {
|
||||||
if (!replyTo) return "";
|
if (!replyTo) return "";
|
||||||
|
// RFC 5322: use Reply-To header if present, otherwise fall back to From
|
||||||
|
const replyTarget = replyTo.replyToAddresses?.length
|
||||||
|
? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ")
|
||||||
|
: replyTo.from?.[0]?.email || "";
|
||||||
if (mode === 'reply') {
|
if (mode === 'reply') {
|
||||||
const email = replyTo.from?.[0]?.email || "";
|
return replyTarget ? replyTarget + ', ' : "";
|
||||||
return email ? email + ', ' : "";
|
|
||||||
} else if (mode === 'replyAll') {
|
} else if (mode === 'replyAll') {
|
||||||
const from = replyTo.from?.[0]?.email || "";
|
|
||||||
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
||||||
const combined = [from, originalTo].filter(Boolean).join(", ");
|
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
|
||||||
return combined ? combined + ', ' : "";
|
return combined ? combined + ', ' : "";
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
@@ -226,6 +232,31 @@ export function EmailComposer({
|
|||||||
const currentIdentity = selectedIdentityId
|
const currentIdentity = selectedIdentityId
|
||||||
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||||
: primaryIdentity;
|
: primaryIdentity;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSelectReplyIdentity) return;
|
||||||
|
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||||
|
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||||
|
|
||||||
|
const matchedIdentityId = findReplyIdentityId(identities, {
|
||||||
|
to: replyTo?.to,
|
||||||
|
cc: replyTo?.cc,
|
||||||
|
bcc: replyTo?.bcc,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchedIdentityId) {
|
||||||
|
setSelectedIdentityId(matchedIdentityId);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
autoSelectReplyIdentity,
|
||||||
|
identities,
|
||||||
|
initialData?.selectedIdentityId,
|
||||||
|
mode,
|
||||||
|
replyTo?.bcc,
|
||||||
|
replyTo?.cc,
|
||||||
|
replyTo?.to,
|
||||||
|
selectedIdentityId,
|
||||||
|
]);
|
||||||
|
|
||||||
const composerSignatureHtml = currentIdentity?.htmlSignature
|
const composerSignatureHtml = currentIdentity?.htmlSignature
|
||||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||||
: currentIdentity?.textSignature
|
: currentIdentity?.textSignature
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
interface EmailHoverActionsProps {
|
interface EmailHoverActionsProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
|
backgroundClassName?: string;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
@@ -63,6 +64,7 @@ const CORNER_CLASSES = {
|
|||||||
|
|
||||||
export function EmailHoverActions({
|
export function EmailHoverActions({
|
||||||
email,
|
email,
|
||||||
|
backgroundClassName = "bg-muted",
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -77,6 +79,7 @@ export function EmailHoverActions({
|
|||||||
|
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const hoverBackgroundClassName = backgroundClassName;
|
||||||
|
|
||||||
if (hoverActions.length === 0) return null;
|
if (hoverActions.length === 0) return null;
|
||||||
|
|
||||||
@@ -144,7 +147,10 @@ export function EmailHoverActions({
|
|||||||
CORNER_CLASSES[hoverActionsCorner],
|
CORNER_CLASSES[hoverActionsCorner],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-0.5 bg-muted rounded-lg px-1.5 py-0.5 shadow-md border border-border">
|
<div className={cn(
|
||||||
|
"flex items-center gap-0.5 rounded-lg px-1.5 py-0.5 shadow-md border border-border",
|
||||||
|
hoverBackgroundClassName,
|
||||||
|
)}>
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,8 +161,14 @@ export function EmailHoverActions({
|
|||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-full bg-gradient-to-r from-transparent to-muted" />
|
<div
|
||||||
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
|
className={cn("w-8 h-full", hoverBackgroundClassName)}
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
|
||||||
|
maskImage: "linear-gradient(to right, transparent, black)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
@@ -44,6 +45,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tag using keyword definitions from settings
|
// Resolve color tag using keyword definitions from settings
|
||||||
const colorTagId = getEmailColorTag(email.keywords);
|
const colorTagId = getEmailColorTag(email.keywords);
|
||||||
@@ -114,15 +117,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="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 */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -145,7 +152,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
@@ -156,85 +163,131 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* First Line: Sender and Date */}
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-sm",
|
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||||
isUnread
|
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
? "font-bold text-foreground"
|
)}>
|
||||||
: "font-medium text-muted-foreground"
|
{sender?.name || sender?.email || 'Unknown'}
|
||||||
)}>
|
</span>
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
</span>
|
<span className={cn(
|
||||||
<div className="flex items-center gap-1.5">
|
'shrink-0 truncate',
|
||||||
{isStarred && (
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
)}>
|
||||||
)}
|
{email.subject || t('no_subject')}
|
||||||
{isImportant && (
|
|
||||||
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
|
|
||||||
Important
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{inlinePreview && (
|
||||||
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
{isAnswered && !isForwarded && (
|
)}
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
</div>
|
||||||
)}
|
</div>
|
||||||
{isForwarded && !isAnswered && (
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
{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 && (
|
{isAnswered && isForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && (
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<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>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{keywordDef && (
|
<>
|
||||||
<span className={cn(
|
{/* First Line: Sender and Date */}
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
)}>
|
<span className={cn(
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
"truncate text-sm",
|
||||||
{keywordDef.label}
|
isUnread
|
||||||
</span>
|
? "font-bold text-foreground"
|
||||||
)}
|
: "font-medium text-muted-foreground"
|
||||||
<span className={cn(
|
)}>
|
||||||
"text-xs tabular-nums",
|
{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
|
isUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(email.receivedAt)}
|
{email.subject || t('no_subject')}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Second Line: Subject */}
|
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
isUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
isUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{email.subject || t('no_subject')}
|
)}>
|
||||||
</div>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,6 +295,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
|
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
|||||||
@@ -103,11 +103,16 @@ export function EmailList({
|
|||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
const estimateSize = useCallback(() => {
|
const estimateSize = useCallback(() => {
|
||||||
|
if (isFocusedMailLayout) {
|
||||||
|
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
|
||||||
|
}
|
||||||
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
||||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||||
}, [density, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: threadGroups.length,
|
count: threadGroups.length,
|
||||||
@@ -216,15 +221,26 @@ export function EmailList({
|
|||||||
}
|
}
|
||||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
||||||
|
|
||||||
// Range-based load more: trigger when last visible item is near the end
|
// Range-based load more: trigger when last visible item is near the end.
|
||||||
|
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||||
|
// count below the viewport size (e.g. 2400 emails → fewer thread groups).
|
||||||
const virtualItems = virtualizer.getVirtualItems();
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
||||||
|
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastVirtualItemIndex === undefined) return;
|
if (lastVirtualItemIndex === undefined) return;
|
||||||
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
||||||
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]);
|
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
||||||
|
|
||||||
// Scroll to the thread group containing the selected email
|
// Scroll to the thread group containing the selected email
|
||||||
@@ -244,7 +260,7 @@ export function EmailList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
virtualizer.measure();
|
virtualizer.measure();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [density, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col min-h-0", className)}>
|
<div className={cn("flex flex-col min-h-0", className)}>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
|
PenSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||||
@@ -121,6 +122,7 @@ interface EmailViewerProps {
|
|||||||
onNavigatePrev?: () => void;
|
onNavigatePrev?: () => void;
|
||||||
onShowShortcuts?: () => void;
|
onShowShortcuts?: () => void;
|
||||||
onEditDraft?: () => void;
|
onEditDraft?: () => void;
|
||||||
|
onCompose?: () => void;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
currentMailboxRole?: string;
|
currentMailboxRole?: string;
|
||||||
@@ -865,6 +867,7 @@ export function EmailViewer({
|
|||||||
onNavigatePrev,
|
onNavigatePrev,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onEditDraft,
|
onEditDraft,
|
||||||
|
onCompose,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
currentMailboxRole,
|
currentMailboxRole,
|
||||||
@@ -887,8 +890,10 @@ export function EmailViewer({
|
|||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
// Detect if current mailbox is Junk folder
|
// Detect if current mailbox is Junk folder
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
@@ -906,7 +911,7 @@ export function EmailViewer({
|
|||||||
// Tablet list visibility
|
// Tablet list visibility
|
||||||
const { isTablet, isMobile } = useDeviceDetection();
|
const { isTablet, isMobile } = useDeviceDetection();
|
||||||
const { tabletListVisible } = useUIStore();
|
const { tabletListVisible } = useUIStore();
|
||||||
const { identities, client, isDemoMode } = useAuthStore();
|
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const { startTour } = useTour();
|
const { startTour } = useTour();
|
||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
@@ -960,9 +965,9 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Ensure S/MIME key records are loaded from IndexedDB
|
// Ensure S/MIME key records are loaded from IndexedDB
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
smimeStore.load();
|
smimeStore.load(activeAccountId ?? undefined);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [activeAccountId]);
|
||||||
|
|
||||||
// Build mailbox tree for move-to dropdown
|
// Build mailbox tree for move-to dropdown
|
||||||
const moveTargetIds = useMemo(() => new Set(
|
const moveTargetIds = useMemo(() => new Set(
|
||||||
@@ -1540,8 +1545,25 @@ export function EmailViewer({
|
|||||||
|
|
||||||
if (detection.type === 'enveloped-data') {
|
if (detection.type === 'enveloped-data') {
|
||||||
// Encrypted message
|
// Encrypted message
|
||||||
const { keyRecords, unlockedDecryptionKeys } = smimeStore;
|
const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
|
||||||
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
|
smimeDebug('[S/MIME] decrypt attempt:', {
|
||||||
|
keyRecordCount: keyRecords.length,
|
||||||
|
unlockedKeyCount: unlockedDecryptionKeys.size,
|
||||||
|
legacyKeyCount: unlockedLegacyDecryptionKeys.size,
|
||||||
|
keyRecordIds: keyRecords.map(k => k.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Short-circuit: no keys imported at all
|
||||||
|
if (keyRecords.length === 0) {
|
||||||
|
smimeDebug('[S/MIME] no key records available, skipping decrypt');
|
||||||
|
setSmimeStatus({
|
||||||
|
isSigned: false,
|
||||||
|
isEncrypted: true,
|
||||||
|
decryptionError: 'no-key',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
@@ -1552,6 +1574,7 @@ export function EmailViewer({
|
|||||||
cmsBytes: candidate.normalized,
|
cmsBytes: candidate.normalized,
|
||||||
keyRecords,
|
keyRecords,
|
||||||
unlockedKeys: unlockedDecryptionKeys,
|
unlockedKeys: unlockedDecryptionKeys,
|
||||||
|
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
|
||||||
});
|
});
|
||||||
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
||||||
source: candidate.source,
|
source: candidate.source,
|
||||||
@@ -1633,8 +1656,15 @@ export function EmailViewer({
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
try {
|
try {
|
||||||
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
|
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
|
||||||
} catch { /* ignore import errors */ }
|
smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint });
|
||||||
|
} catch (importErr) {
|
||||||
|
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||||
}
|
}
|
||||||
|
} else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) {
|
||||||
|
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
smimeError('[S/MIME] nested signature verify failed:', {
|
smimeError('[S/MIME] nested signature verify failed:', {
|
||||||
@@ -1672,10 +1702,12 @@ export function EmailViewer({
|
|||||||
decryptionError: 'locked',
|
decryptionError: 'locked',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
|
||||||
|
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
|
||||||
setSmimeStatus({
|
setSmimeStatus({
|
||||||
isSigned: false,
|
isSigned: false,
|
||||||
isEncrypted: true,
|
isEncrypted: true,
|
||||||
decryptionError: err instanceof Error ? err.message : 'Decryption failed',
|
decryptionError: isNoKeyError ? 'no-key' : errMsg,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1737,8 +1769,15 @@ export function EmailViewer({
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
try {
|
try {
|
||||||
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
|
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
|
||||||
} catch { /* ignore import errors */ }
|
smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint });
|
||||||
|
} catch (importErr) {
|
||||||
|
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||||
}
|
}
|
||||||
|
} else if (result.status.signatureValid && result.status.signerCert) {
|
||||||
|
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -1771,6 +1810,7 @@ export function EmailViewer({
|
|||||||
smimeStore.autoImportSignerCerts,
|
smimeStore.autoImportSignerCerts,
|
||||||
smimeStore.keyRecords,
|
smimeStore.keyRecords,
|
||||||
smimeStore.unlockedDecryptionKeys,
|
smimeStore.unlockedDecryptionKeys,
|
||||||
|
smimeStore.unlockedLegacyDecryptionKeys,
|
||||||
smimeStore,
|
smimeStore,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -2526,14 +2566,21 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// If email has native dark mode, let it handle its own theming
|
// If email has native dark mode, let it handle its own theming
|
||||||
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
|
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
|
||||||
|
// Re-invert leaf media elements so they appear normal.
|
||||||
|
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
|
||||||
|
// double re-inverting images nested inside those containers.
|
||||||
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
||||||
html { background: #1a1a1a; }
|
html { background: #1a1a1a; }
|
||||||
body { filter: invert(1) hue-rotate(180deg); }
|
body { filter: invert(1) hue-rotate(180deg); }
|
||||||
img, video, picture, svg, canvas, object, embed,
|
img, video, svg, canvas, object, embed, input[type="image"] {
|
||||||
[style*="background-image"], [style*="background:"],
|
filter: invert(1) hue-rotate(180deg);
|
||||||
[background], [bgcolor],
|
}
|
||||||
td[background], table[background],
|
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
img[src], input[type="image"] {
|
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
[background]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
td[background]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
|
||||||
filter: invert(1) hue-rotate(180deg);
|
filter: invert(1) hue-rotate(180deg);
|
||||||
}
|
}
|
||||||
` : '';
|
` : '';
|
||||||
@@ -2572,11 +2619,35 @@ export function EmailViewer({
|
|||||||
a.setAttribute('target', '_blank');
|
a.setAttribute('target', '_blank');
|
||||||
a.setAttribute('rel', 'noopener noreferrer');
|
a.setAttribute('rel', 'noopener noreferrer');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dark mode: re-invert elements with stylesheet-defined background images
|
||||||
|
// (CSS attribute selectors only catch inline styles, not <style> block rules)
|
||||||
|
if (isDark && !emailHasNativeDarkMode) {
|
||||||
|
const win = doc.defaultView;
|
||||||
|
if (win) {
|
||||||
|
doc.body.querySelectorAll('*').forEach(el => {
|
||||||
|
const htmlEl = el as HTMLElement;
|
||||||
|
// Skip elements already handled by CSS attribute selectors
|
||||||
|
if (htmlEl.style.backgroundImage || htmlEl.style.background ||
|
||||||
|
htmlEl.hasAttribute('background') || htmlEl.hasAttribute('bgcolor')) return;
|
||||||
|
// Skip leaf media elements (already re-inverted by CSS)
|
||||||
|
const tag = htmlEl.tagName;
|
||||||
|
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
|
||||||
|
const computed = win.getComputedStyle(htmlEl);
|
||||||
|
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||||||
|
// Only re-invert if this container doesn't have media children
|
||||||
|
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) {
|
||||||
|
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restrictions - iframe will still display content
|
// Cross-origin restrictions - iframe will still display content
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isDark, emailHasNativeDarkMode]);
|
||||||
|
|
||||||
// Export email as .eml file
|
// Export email as .eml file
|
||||||
const handleExportEmail = async () => {
|
const handleExportEmail = async () => {
|
||||||
@@ -2775,6 +2846,12 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||||
|
{onCompose && (
|
||||||
|
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||||
|
<PenSquare className="w-4 h-4 mr-2" />
|
||||||
|
{t('compose')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2790,7 +2867,7 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
{/* Left: Reply actions */}
|
{/* Left: Reply actions */}
|
||||||
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
||||||
{showBackButton && isTablet && !tabletListVisible && onBack && (
|
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -2805,7 +2882,7 @@ export function EmailViewer({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||||
title={t('tooltips.edit_draft')}
|
title={t('tooltips.edit_draft')}
|
||||||
>
|
>
|
||||||
@@ -3524,7 +3601,7 @@ export function EmailViewer({
|
|||||||
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||||
{/* Back button (for below-subject mode on tablet) */}
|
{/* Back button (for below-subject mode on tablet) */}
|
||||||
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && (
|
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -4403,7 +4480,7 @@ export function EmailViewer({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
<EditIcon className="w-3.5 h-3.5" />
|
<EditIcon className="w-3.5 h-3.5" />
|
||||||
@@ -4672,7 +4749,7 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
{isDraft && onEditDraft ? (
|
{isDraft && onEditDraft ? (
|
||||||
<button
|
<button
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
||||||
aria-label={t('tooltips.edit_draft')}
|
aria-label={t('tooltips.edit_draft')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
text: t('unlock_key_desc'),
|
text: t('unlock_key_desc'),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
|
} else if (status.decryptionError === 'no-key') {
|
||||||
|
items.push({
|
||||||
|
icon: <Lock className="w-4 h-4" />,
|
||||||
|
text: t('status_encrypted_no_key'),
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <ShieldX className="w-4 h-4" />,
|
icon: <ShieldX className="w-4 h-4" />,
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color and keyword definition from keyword definitions if not passed directly
|
// Resolve color and keyword definition from keyword definitions if not passed directly
|
||||||
const tagId = getEmailColorTag(email.keywords);
|
const tagId = getEmailColorTag(email.keywords);
|
||||||
@@ -131,15 +134,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="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 */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -160,7 +167,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
@@ -170,76 +177,121 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className={cn(
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
"truncate text-sm",
|
<span className={cn(
|
||||||
isUnread
|
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||||
? "font-bold text-foreground"
|
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
: "font-medium text-muted-foreground"
|
)}>
|
||||||
)}>
|
{sender?.name || sender?.email || 'Unknown'}
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
</span>
|
||||||
</span>
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
<span className={cn(
|
||||||
{isStarred && (
|
'shrink-0 truncate',
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
)}
|
)}>
|
||||||
{isAnswered && !isForwarded && (
|
{email.subject || '(no subject)'}
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
</span>
|
||||||
)}
|
{inlinePreview && (
|
||||||
{isForwarded && !isAnswered && (
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
)}
|
||||||
)}
|
</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 && (
|
{isAnswered && isForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && (
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<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>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{resolvedKeywordDef && (
|
<>
|
||||||
<span className={cn(
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
<span className={cn(
|
||||||
)}>
|
"truncate text-sm",
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
isUnread
|
||||||
{resolvedKeywordDef.label}
|
? "font-bold text-foreground"
|
||||||
</span>
|
: "font-medium text-muted-foreground"
|
||||||
)}
|
)}>
|
||||||
<span className={cn(
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
"text-xs tabular-nums",
|
</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
|
isUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(email.receivedAt)}
|
{email.subject || "(no subject)"}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
isUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
isUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{email.subject || "(no subject)"}
|
)}>
|
||||||
</div>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,6 +299,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
|
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@@ -280,8 +333,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const t = useTranslations('threads');
|
const t = useTranslations('threads');
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||||
|
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
|
|
||||||
@@ -405,15 +461,19 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}
|
)}
|
||||||
onClick={handleHeaderClick}
|
onClick={handleHeaderClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="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 */}
|
{/* Checkbox for thread selection - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleThreadCheckboxClick}
|
onClick={handleThreadCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -428,7 +488,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && !isFocusedMailLayout && (
|
||||||
<button
|
<button
|
||||||
data-expand-toggle
|
data-expand-toggle
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -460,7 +520,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={latestEmail.from?.[0]?.name}
|
name={latestEmail.from?.[0]?.name}
|
||||||
email={latestEmail.from?.[0]?.email}
|
email={latestEmail.from?.[0]?.email}
|
||||||
@@ -470,88 +530,143 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className={cn(
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
"truncate text-sm",
|
<span className={cn(
|
||||||
hasUnread
|
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||||
? "font-bold text-foreground"
|
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
: "font-medium text-muted-foreground"
|
)}>
|
||||||
)}>
|
{participantNames.join(', ')}
|
||||||
{participantNames.join(", ")}
|
</span>
|
||||||
</span>
|
<span
|
||||||
<span
|
className={cn(
|
||||||
className={cn(
|
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
|
||||||
"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'
|
||||||
hasUnread
|
)}
|
||||||
? "bg-primary text-primary-foreground"
|
title={t('messages_tooltip', { count: emailCount })}
|
||||||
: "bg-muted text-muted-foreground"
|
>
|
||||||
)}
|
<MessageSquare className="w-3 h-3" />
|
||||||
title={t('messages_tooltip', { count: emailCount })}
|
{emailCount}
|
||||||
>
|
</span>
|
||||||
<MessageSquare className="w-3 h-3" />
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
{emailCount}
|
<span className={cn(
|
||||||
</span>
|
'shrink-0 truncate',
|
||||||
<div className="flex items-center gap-1.5">
|
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
{hasStarred && (
|
)}>
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
{latestEmail.subject || '(no subject)'}
|
||||||
)}
|
</span>
|
||||||
{hasAnswered && !hasForwarded && (
|
{inlinePreview && (
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
)}
|
)}
|
||||||
{hasForwarded && !hasAnswered && (
|
</div>
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
</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 && (
|
{hasAnswered && hasForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasAttachment && (
|
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<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>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{keywordDef && (
|
<>
|
||||||
<span className={cn(
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
<span className={cn(
|
||||||
)}>
|
"truncate text-sm",
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
hasUnread
|
||||||
{keywordDef.label}
|
? "font-bold text-foreground"
|
||||||
</span>
|
: "font-medium text-muted-foreground"
|
||||||
)}
|
)}>
|
||||||
<span className={cn(
|
{participantNames.join(", ")}
|
||||||
"text-xs tabular-nums",
|
</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
|
hasUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(latestEmail.receivedAt)}
|
{latestEmail.subject || "(no subject)"}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
hasUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
hasUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{latestEmail.subject || "(no subject)"}
|
)}>
|
||||||
</div>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,6 +674,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{/* Hover Quick Actions for thread header */}
|
{/* Hover Quick Actions for thread header */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={latestEmail}
|
email={latestEmail}
|
||||||
|
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
@@ -568,7 +684,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && !isMobile && (
|
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield } from "lucide-react";
|
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check } from "lucide-react";
|
||||||
import { AccountSwitcher } from "./account-switcher";
|
import { AccountSwitcher } from "./account-switcher";
|
||||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
@@ -16,8 +16,11 @@ import { useWebDAVStore } from "@/stores/webdav-store";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { getInitials } from "@/lib/account-utils";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -164,11 +167,55 @@ export function NavigationRail({
|
|||||||
const { mailboxes } = useEmailStore();
|
const { mailboxes } = useEmailStore();
|
||||||
const { supportsWebDAV } = useWebDAVStore();
|
const { supportsWebDAV } = useWebDAVStore();
|
||||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||||
|
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
|
|
||||||
|
// Account list for rail
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
||||||
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||||
|
const [logoutMenuOpen, setLogoutMenuOpen] = useState(false);
|
||||||
|
const logoutBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const logoutPopoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [logoutPopoverStyle, setLogoutPopoverStyle] = useState<React.CSSProperties>({});
|
||||||
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
|
|
||||||
|
const updateLogoutPosition = useCallback(() => {
|
||||||
|
if (!logoutBtnRef.current) return;
|
||||||
|
const rect = logoutBtnRef.current.getBoundingClientRect();
|
||||||
|
setLogoutPopoverStyle({
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.right + 8,
|
||||||
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logoutMenuOpen) return;
|
||||||
|
updateLogoutPosition();
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
logoutBtnRef.current?.contains(e.target as Node) ||
|
||||||
|
logoutPopoverRef.current?.contains(e.target as Node)
|
||||||
|
) return;
|
||||||
|
setLogoutMenuOpen(false);
|
||||||
|
};
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setLogoutMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [logoutMenuOpen, updateLogoutPosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const { client } = useAuthStore.getState();
|
const { client } = useAuthStore.getState();
|
||||||
@@ -504,6 +551,22 @@ export function NavigationRail({
|
|||||||
<Keyboard className="w-[18px] h-[18px]" />
|
<Keyboard className="w-[18px] h-[18px]" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!onShowShortcuts && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowShortcutsModal(true)}
|
||||||
|
data-tour="nav-shortcuts"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title={t("keyboard_shortcuts")}
|
||||||
|
>
|
||||||
|
<Keyboard className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
<KeyboardShortcutsModal
|
||||||
|
isOpen={showShortcutsModal}
|
||||||
|
onClose={() => setShowShortcutsModal(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{quota && quota.total > 0 && (
|
{quota && quota.total > 0 && (
|
||||||
<div data-tour="storage-quota">
|
<div data-tour="storage-quota">
|
||||||
@@ -511,21 +574,85 @@ export function NavigationRail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPushConnected != null && (
|
{onLogout && showRailAccountList && accounts.length > 0 && (
|
||||||
<span
|
<>
|
||||||
className="relative group"
|
<div className="w-8 border-t my-1" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
|
||||||
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
|
|
||||||
>
|
{/* Account circles */}
|
||||||
<span
|
<div className="flex flex-col items-center gap-3">
|
||||||
className={cn(
|
{accounts.map((account) => {
|
||||||
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
|
const isActive = account.id === activeAccountId;
|
||||||
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||||
)}
|
return (
|
||||||
/>
|
<button
|
||||||
</span>
|
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" />
|
<AccountSwitcher variant="rail" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+132
-37
@@ -12,7 +12,6 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
PenSquare,
|
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -309,7 +308,7 @@ function TagItem({
|
|||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="truncate">{kw.label}</span>
|
<span className="flex-1 truncate">{kw.label}</span>
|
||||||
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
|
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -428,9 +427,16 @@ export function Sidebar({
|
|||||||
onUnreadFilterClick,
|
onUnreadFilterClick,
|
||||||
className,
|
className,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
const router = useRouter();
|
||||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||||
const { primaryIdentity: _primaryIdentity } = useAuthStore();
|
const { primaryIdentity: _primaryIdentity } = useAuthStore();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||||
|
return stored !== null ? JSON.parse(stored) : true;
|
||||||
|
} catch { return true; }
|
||||||
|
});
|
||||||
const [tagsExpanded, setTagsExpanded] = useState(() => {
|
const [tagsExpanded, setTagsExpanded] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('sidebarTagsExpanded');
|
const stored = localStorage.getItem('sidebarTagsExpanded');
|
||||||
@@ -438,6 +444,7 @@ export function Sidebar({
|
|||||||
} catch { return true; }
|
} catch { return true; }
|
||||||
});
|
});
|
||||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||||
|
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher);
|
||||||
const tagCounts = useEmailStore(s => s.tagCounts);
|
const tagCounts = useEmailStore(s => s.tagCounts);
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
|
|
||||||
@@ -452,10 +459,17 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const tree = buildMailboxTree(mailboxes);
|
const tree = buildMailboxTree(mailboxes);
|
||||||
const defaultExpanded = tree
|
const collectExpandable = (nodes: MailboxNode[]): string[] => {
|
||||||
.filter(node => node.children.length > 0)
|
const ids: string[] = [];
|
||||||
.map(node => node.id);
|
for (const node of nodes) {
|
||||||
setExpandedFolders(new Set(defaultExpanded));
|
if (node.children.length > 0) {
|
||||||
|
ids.push(node.id);
|
||||||
|
ids.push(...collectExpandable(node.children));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
setExpandedFolders(new Set(collectExpandable(tree)));
|
||||||
}
|
}
|
||||||
}, [mailboxes]);
|
}, [mailboxes]);
|
||||||
|
|
||||||
@@ -539,7 +553,7 @@ export function Sidebar({
|
|||||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && !hideAccountSwitcher && (
|
||||||
<AccountSwitcher variant="expanded" className="flex-1" />
|
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -553,24 +567,103 @@ export function Sidebar({
|
|||||||
{/* Mailbox List */}
|
{/* Mailbox List */}
|
||||||
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{mailboxes.length === 0 ? (
|
{/* Folders Section Header */}
|
||||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
<div
|
||||||
{!isCollapsed && t("loading_mailboxes")}
|
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||||
</div>
|
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) => (
|
{mailboxes.length === 0 ? (
|
||||||
<MailboxTreeItem
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||||
key={node.id}
|
{!isCollapsed && t("loading_mailboxes")}
|
||||||
node={node}
|
</div>
|
||||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
) : (
|
||||||
expandedFolders={expandedFolders}
|
<>
|
||||||
onMailboxSelect={onMailboxSelect}
|
{mailboxTree.map((node) => (
|
||||||
onToggleExpand={handleToggleExpand}
|
<MailboxTreeItem
|
||||||
isCollapsed={isCollapsed}
|
key={node.id}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
node={node}
|
||||||
/>
|
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||||
))}
|
expandedFolders={expandedFolders}
|
||||||
|
onMailboxSelect={onMailboxSelect}
|
||||||
|
onToggleExpand={handleToggleExpand}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -635,6 +728,20 @@ export function Sidebar({
|
|||||||
<span className="flex-1 truncate">{t("tags")}</span>
|
<span className="flex-1 truncate">{t("tags")}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ }
|
||||||
|
router.push('/settings');
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
|
||||||
|
title={t('settings')}
|
||||||
|
>
|
||||||
|
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
@@ -661,19 +768,7 @@ export function Sidebar({
|
|||||||
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compose Button */}
|
|
||||||
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")} data-tour="compose-button">
|
|
||||||
<PenSquare className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={onCompose} className="w-full" title={t("compose_hint")} data-tour="compose-button">
|
|
||||||
<PenSquare className="w-4 h-4 mr-2" />
|
|
||||||
{t("compose")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
|||||||
const t = useTranslations('settings.appearance');
|
const t = useTranslations('settings.appearance');
|
||||||
const tTour = useTranslations('tour');
|
const tTour = useTranslations('tour');
|
||||||
const { theme, setTheme } = useThemeStore();
|
const { theme, setTheme } = useThemeStore();
|
||||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
|
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore();
|
||||||
const { startTour, resetTourCompletion } = useTour();
|
const { startTour, resetTourCompletion } = useTour();
|
||||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||||
|
|
||||||
@@ -145,6 +145,22 @@ export function AppearanceSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Hide Account Switcher */}
|
||||||
|
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={hideAccountSwitcher}
|
||||||
|
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Show Rail Account List */}
|
||||||
|
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={showRailAccountList}
|
||||||
|
onChange={(checked) => updateSetting('showRailAccountList', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Animations */}
|
{/* Animations */}
|
||||||
{!isSettingHidden('animationsEnabled') && (
|
{!isSettingHidden('animationsEnabled') && (
|
||||||
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export function CalendarManagementSettings() {
|
|||||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
|
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
||||||
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
|
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
|
||||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||||
const tImport = useTranslations('calendar.import');
|
const tImport = useTranslations('calendar.import');
|
||||||
@@ -629,6 +630,14 @@ export function CalendarManagementSettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingSubscription(sub)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={tSub('edit')}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRefreshSubscription(sub.id)}
|
onClick={() => handleRefreshSubscription(sub.id)}
|
||||||
@@ -667,6 +676,14 @@ export function CalendarManagementSettings() {
|
|||||||
onClose={() => setShowSubscriptionModal(false)}
|
onClose={() => setShowSubscriptionModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingSubscription && client && (
|
||||||
|
<ICalSubscriptionModal
|
||||||
|
client={client}
|
||||||
|
editSubscription={editingSubscription}
|
||||||
|
onClose={() => setEditingSubscription(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function CalendarSettings() {
|
|||||||
showWeekNumbers,
|
showWeekNumbers,
|
||||||
enableCalendarTasks,
|
enableCalendarTasks,
|
||||||
showTasksOnCalendar,
|
showTasksOnCalendar,
|
||||||
|
calendarHoverPreview,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
@@ -80,6 +81,23 @@ export function CalendarSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('hover_preview')}
|
||||||
|
description={t('hover_preview_desc')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={calendarHoverPreview}
|
||||||
|
onChange={(value) => updateSetting('calendarHoverPreview', value as 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s')}
|
||||||
|
options={[
|
||||||
|
{ value: 'instant', label: t('hover_preview_instant') },
|
||||||
|
{ value: 'delay-500ms', label: t('hover_preview_delay_500ms') },
|
||||||
|
{ value: 'delay-1s', label: t('hover_preview_delay_1s') },
|
||||||
|
{ value: 'delay-2s', label: t('hover_preview_delay_2s') },
|
||||||
|
{ value: 'off', label: t('hover_preview_off') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{isFeatureEnabled('calendarTasksEnabled') && (
|
{isFeatureEnabled('calendarTasksEnabled') && (
|
||||||
<>
|
<>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
|
|||||||
@@ -4,16 +4,92 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
|
import type { ArchiveMode, HoverAction, MailLayout } from '@/stores/settings-store';
|
||||||
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
|
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||||
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
|
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
|
||||||
|
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||||
|
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||||
|
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
|
||||||
|
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function MailLayoutPreview({
|
||||||
|
value,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
value: MailLayout;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}) {
|
||||||
|
const isSplit = value === 'split';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-xl border border-border bg-background p-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||||
|
<div className="flex h-28">
|
||||||
|
<div className="w-11 border-r border-border bg-muted/40" />
|
||||||
|
|
||||||
|
{isSplit ? (
|
||||||
|
<>
|
||||||
|
<div className="w-28 border-r border-border bg-background">
|
||||||
|
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.subject}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
|
||||||
|
row.selected && 'bg-primary/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="truncate font-medium text-foreground">{row.sender}</div>
|
||||||
|
<div className="truncate text-muted-foreground">{row.subject}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-background px-3 py-2">
|
||||||
|
<div className="h-2.5 w-20 rounded bg-foreground/10" />
|
||||||
|
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
|
||||||
|
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
|
||||||
|
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 bg-background px-2 py-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.subject}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-2 py-1 text-[10px]',
|
||||||
|
row.selected ? 'bg-primary/10' : 'bg-muted/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="truncate text-foreground">
|
||||||
|
<span className="font-medium">{row.sender}</span>
|
||||||
|
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
|
||||||
|
<span className="text-muted-foreground/80">{row.preview}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EmailSettings() {
|
export function EmailSettings() {
|
||||||
const t = useTranslations('settings.email_behavior');
|
const t = useTranslations('settings.email_behavior');
|
||||||
const { appName } = useConfig();
|
const { appName } = useConfig();
|
||||||
@@ -39,7 +115,9 @@ export function EmailSettings() {
|
|||||||
deleteAction,
|
deleteAction,
|
||||||
permanentlyDeleteJunk,
|
permanentlyDeleteJunk,
|
||||||
showPreview,
|
showPreview,
|
||||||
|
mailLayout,
|
||||||
disableThreading,
|
disableThreading,
|
||||||
|
autoSelectReplyIdentity,
|
||||||
plainTextMode,
|
plainTextMode,
|
||||||
emailsPerPage,
|
emailsPerPage,
|
||||||
externalContentPolicy,
|
externalContentPolicy,
|
||||||
@@ -62,6 +140,8 @@ export function EmailSettings() {
|
|||||||
return t('trusted_senders.count_other', { count });
|
return t('trusted_senders.count_other', { count });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isFocusedLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
const handleReorganizeArchive = async () => {
|
const handleReorganizeArchive = async () => {
|
||||||
const { client } = useAuthStore.getState();
|
const { client } = useAuthStore.getState();
|
||||||
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
|
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
|
||||||
@@ -207,9 +287,29 @@ export function EmailSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{!isSettingHidden('mailLayout') && (
|
||||||
|
<SettingItem label={t('mail_layout.label')} description={t('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
|
||||||
|
<div className="w-[22rem] max-w-full">
|
||||||
|
<RadioGroup
|
||||||
|
value={mailLayout}
|
||||||
|
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
|
||||||
|
options={[
|
||||||
|
{ value: 'split', label: t('mail_layout.split') },
|
||||||
|
{ value: 'focus', label: t('mail_layout.focus') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<MailLayoutPreview value={mailLayout} t={t} />
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show Preview */}
|
{/* Show Preview */}
|
||||||
{!isSettingHidden('showPreview') && (
|
{!isSettingHidden('showPreview') && (
|
||||||
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')} locked={isSettingLocked('showPreview')}>
|
<SettingItem
|
||||||
|
label={t('show_preview.label')}
|
||||||
|
description={isFocusedLayout ? t('show_preview.focus_description') : t('show_preview.description')}
|
||||||
|
locked={isSettingLocked('showPreview')}
|
||||||
|
>
|
||||||
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
|
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
)}
|
)}
|
||||||
@@ -230,6 +330,13 @@ export function EmailSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={autoSelectReplyIdentity}
|
||||||
|
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Quick Hover Actions */}
|
{/* Quick Hover Actions */}
|
||||||
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||||
<div className="py-3 border-b border-border space-y-3">
|
<div className="py-3 border-b border-border space-y-3">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-di
|
|||||||
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
|
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
|
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
|
||||||
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export function SmimeSettings() {
|
|||||||
} = useSmimeStore();
|
} = useSmimeStore();
|
||||||
|
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
|
|
||||||
// Local UI state
|
// Local UI state
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
@@ -75,8 +77,8 @@ export function SmimeSettings() {
|
|||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load(activeAccountId ?? undefined);
|
||||||
}, [load]);
|
}, [load, activeAccountId]);
|
||||||
|
|
||||||
// ── PKCS#12 import flow ────────────────────────────────────────
|
// ── PKCS#12 import flow ────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -174,64 +174,68 @@ interface ThemeCardProps {
|
|||||||
|
|
||||||
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
|
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={onActivate}
|
<button
|
||||||
disabled={disabled}
|
type="button"
|
||||||
className={cn(
|
onClick={onActivate}
|
||||||
'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',
|
disabled={disabled}
|
||||||
isActive
|
className={cn(
|
||||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
'flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
|
||||||
: 'border-border hover:border-primary/40 bg-card',
|
isActive
|
||||||
disabled && !isActive && 'hover:border-border'
|
? '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>
|
>
|
||||||
|
{/* Preview / Placeholder */}
|
||||||
{/* Info */}
|
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
|
||||||
<div className="w-full">
|
{preview ? (
|
||||||
<div className="flex items-center justify-between gap-1">
|
<img src={preview} alt={name} className="w-full h-full object-cover" />
|
||||||
<span className="text-sm font-medium text-foreground truncate">{name}</span>
|
) : (
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<Palette className="w-8 h-8 text-muted-foreground/40" />
|
||||||
{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 && (
|
{/* Info */}
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="w-full">
|
||||||
{variants.map(v => (
|
<div className="flex items-center justify-between gap-1">
|
||||||
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
<span className="text-sm font-medium text-foreground truncate">{name}</span>
|
||||||
{v}
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
</span>
|
{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>
|
||||||
</div>
|
{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 */}
|
{/* Remove button */}
|
||||||
{onRemove && !isActive && (
|
{onRemove && !isActive && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
title="Remove theme"
|
title="Remove theme"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/**",
|
".next/**",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"repos/**",
|
"repos/**",
|
||||||
|
"data/admin/plugins/**",
|
||||||
"*.config.js",
|
"*.config.js",
|
||||||
"*.config.mjs",
|
"*.config.mjs",
|
||||||
"e2e/**",
|
"e2e/**",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface ConfigData {
|
|||||||
loginWebsiteUrl: string;
|
loginWebsiteUrl: string;
|
||||||
demoMode: boolean;
|
demoMode: boolean;
|
||||||
autoSsoEnabled: boolean;
|
autoSsoEnabled: boolean;
|
||||||
|
allowCustomJmapEndpoint: boolean;
|
||||||
embeddedMode: boolean;
|
embeddedMode: boolean;
|
||||||
parentOrigin: string;
|
parentOrigin: string;
|
||||||
}
|
}
|
||||||
@@ -100,6 +101,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
||||||
demoMode: configCache?.demoMode || false,
|
demoMode: configCache?.demoMode || false,
|
||||||
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
||||||
|
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
|
||||||
embeddedMode: configCache?.embeddedMode || false,
|
embeddedMode: configCache?.embeddedMode || false,
|
||||||
parentOrigin: configCache?.parentOrigin || '',
|
parentOrigin: configCache?.parentOrigin || '',
|
||||||
isLoading: !configCache,
|
isLoading: !configCache,
|
||||||
@@ -131,6 +133,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
||||||
demoMode: configCache.demoMode,
|
demoMode: configCache.demoMode,
|
||||||
autoSsoEnabled: configCache.autoSsoEnabled,
|
autoSsoEnabled: configCache.autoSsoEnabled,
|
||||||
|
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
|
||||||
embeddedMode: configCache.embeddedMode,
|
embeddedMode: configCache.embeddedMode,
|
||||||
parentOrigin: configCache.parentOrigin,
|
parentOrigin: configCache.parentOrigin,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -163,6 +166,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginWebsiteUrl: data.loginWebsiteUrl,
|
loginWebsiteUrl: data.loginWebsiteUrl,
|
||||||
demoMode: data.demoMode,
|
demoMode: data.demoMode,
|
||||||
autoSsoEnabled: data.autoSsoEnabled,
|
autoSsoEnabled: data.autoSsoEnabled,
|
||||||
|
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
|
||||||
embeddedMode: data.embeddedMode,
|
embeddedMode: data.embeddedMode,
|
||||||
parentOrigin: data.parentOrigin,
|
parentOrigin: data.parentOrigin,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|||||||
@@ -45,4 +45,43 @@ describe('calendar event normalization', () => {
|
|||||||
showWithoutTime: true,
|
showWithoutTime: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('recurrenceRule normalization (JSCalendar 2.0 singular→plural)', () => {
|
||||||
|
it('wraps a single recurrenceRule object in an array', () => {
|
||||||
|
const raw = {
|
||||||
|
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
|
||||||
|
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' },
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
|
||||||
|
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]);
|
||||||
|
expect((result as Record<string, unknown>).recurrenceRule).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through recurrenceRule when already an array', () => {
|
||||||
|
const raw = {
|
||||||
|
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
|
||||||
|
recurrenceRule: [{ '@type': 'RecurrenceRule', frequency: 'daily' }],
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
|
||||||
|
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through null recurrenceRule as-is', () => {
|
||||||
|
const raw = {
|
||||||
|
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
|
||||||
|
recurrenceRule: null,
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
|
||||||
|
expect(result.recurrenceRules).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps a single excludedRecurrenceRule object in an array', () => {
|
||||||
|
const raw = {
|
||||||
|
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
|
||||||
|
excludedRecurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
|
||||||
|
expect(result.excludedRecurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||||
import {
|
import {
|
||||||
|
buildTimedFullDayWeekSegments,
|
||||||
buildWeekSegments,
|
buildWeekSegments,
|
||||||
buildAllDayDuration,
|
buildAllDayDuration,
|
||||||
getEventDayBounds,
|
getEventDayBounds,
|
||||||
getEventDisplayEndDate,
|
getEventDisplayEndDate,
|
||||||
getEventEndDate,
|
getEventEndDate,
|
||||||
|
getEventStartDate,
|
||||||
|
getTimedEventBoundsForDay,
|
||||||
|
isTimedEventFullDayOnDate,
|
||||||
|
layoutOverlappingEvents,
|
||||||
normalizeAllDayDuration,
|
normalizeAllDayDuration,
|
||||||
} from '../calendar-utils';
|
} from '../calendar-utils';
|
||||||
|
|
||||||
@@ -92,7 +97,110 @@ describe('calendar-utils all-day handling', () => {
|
|||||||
utcEnd: '2026-03-14T11:00:00Z',
|
utcEnd: '2026-03-14T11:00:00Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
expectLocalDateParts(getEventDisplayEndDate(event), 2026, 3, 14, 11);
|
expect(getEventDisplayEndDate(event).toISOString()).toBe('2026-03-14T11:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers utc timestamps for timed events with an event timezone', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2026-03-15T09:00:00',
|
||||||
|
duration: 'PT1H',
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
showWithoutTime: false,
|
||||||
|
utcStart: '2026-03-15T13:00:00Z',
|
||||||
|
utcEnd: '2026-03-15T14:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getEventStartDate(event).toISOString()).toBe('2026-03-15T13:00:00.000Z');
|
||||||
|
expect(getEventEndDate(event).toISOString()).toBe('2026-03-15T14:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clips timed multi-day events to the visible day bounds', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2026-03-14T22:00:00',
|
||||||
|
duration: 'PT4H',
|
||||||
|
showWithoutTime: false,
|
||||||
|
utcStart: '2026-03-14T22:00:00Z',
|
||||||
|
utcEnd: '2026-03-15T02:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getTimedEventBoundsForDay(event, new Date('2026-03-14T00:00:00Z'))).toMatchObject({
|
||||||
|
startMinutes: 1380,
|
||||||
|
endMinutes: 1440,
|
||||||
|
continuesBefore: false,
|
||||||
|
continuesAfter: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({
|
||||||
|
startMinutes: 0,
|
||||||
|
endMinutes: 180,
|
||||||
|
continuesBefore: true,
|
||||||
|
continuesAfter: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays out continued timed events using clipped bounds for the active day', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2026-03-14T22:00:00',
|
||||||
|
duration: 'PT4H',
|
||||||
|
showWithoutTime: false,
|
||||||
|
utcStart: '2026-03-14T22:00:00Z',
|
||||||
|
utcEnd: '2026-03-15T02:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const layout = layoutOverlappingEvents([event], new Date('2026-03-15T00:00:00Z'));
|
||||||
|
|
||||||
|
expect(layout).toHaveLength(1);
|
||||||
|
expect(layout[0]).toMatchObject({
|
||||||
|
startMinutes: 0,
|
||||||
|
endMinutes: 180,
|
||||||
|
column: 0,
|
||||||
|
totalColumns: 1,
|
||||||
|
continuesBefore: true,
|
||||||
|
continuesAfter: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects when a timed multi-day event fully occupies an intermediate day', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2026-03-14T12:00:00',
|
||||||
|
duration: 'PT48H',
|
||||||
|
showWithoutTime: false,
|
||||||
|
utcStart: '2026-03-14T12:00:00Z',
|
||||||
|
utcEnd: '2026-03-16T12:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-15T00:00:00Z'))).toBe(true);
|
||||||
|
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-14T00:00:00Z'))).toBe(false);
|
||||||
|
expect(isTimedEventFullDayOnDate(event, new Date('2026-03-16T00:00:00Z'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates week-bar segments for timed events that fully cover visible days', () => {
|
||||||
|
const week = [
|
||||||
|
new Date('2026-03-14T00:00:00Z'),
|
||||||
|
new Date('2026-03-15T00:00:00Z'),
|
||||||
|
new Date('2026-03-16T00:00:00Z'),
|
||||||
|
new Date('2026-03-17T00:00:00Z'),
|
||||||
|
new Date('2026-03-18T00:00:00Z'),
|
||||||
|
new Date('2026-03-19T00:00:00Z'),
|
||||||
|
new Date('2026-03-20T00:00:00Z'),
|
||||||
|
];
|
||||||
|
const event = makeEvent({
|
||||||
|
start: '2026-03-14T12:00:00',
|
||||||
|
duration: 'PT72H',
|
||||||
|
showWithoutTime: false,
|
||||||
|
utcStart: '2026-03-14T12:00:00Z',
|
||||||
|
utcEnd: '2026-03-17T12:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const segments = buildTimedFullDayWeekSegments([event], week);
|
||||||
|
|
||||||
|
expect(segments).toHaveLength(1);
|
||||||
|
expect(segments[0]).toMatchObject({
|
||||||
|
startIndex: 1,
|
||||||
|
span: 2,
|
||||||
|
continuesBefore: false,
|
||||||
|
continuesAfter: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes imported all-day durations to day units', () => {
|
it('normalizes imported all-day durations to day units', () => {
|
||||||
|
|||||||
@@ -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,395 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||||
|
import type { Mailbox } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
const makeMailbox = (overrides: Partial<Mailbox> = {}): Mailbox => ({
|
||||||
|
id: 'mb-1',
|
||||||
|
name: 'Test',
|
||||||
|
sortOrder: 0,
|
||||||
|
totalEmails: 0,
|
||||||
|
unreadEmails: 0,
|
||||||
|
totalThreads: 0,
|
||||||
|
unreadThreads: 0,
|
||||||
|
myRights: {
|
||||||
|
mayReadItems: true,
|
||||||
|
mayAddItems: true,
|
||||||
|
mayRemoveItems: true,
|
||||||
|
maySetSeen: true,
|
||||||
|
maySetKeywords: true,
|
||||||
|
mayCreateChild: true,
|
||||||
|
mayRename: true,
|
||||||
|
mayDelete: true,
|
||||||
|
maySubmit: true,
|
||||||
|
},
|
||||||
|
isSubscribed: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: walk the tree and collect { id, depth, parentId } for every node.
|
||||||
|
*/
|
||||||
|
function collectNodes(tree: MailboxNode[]): { id: string; depth: number; parentName?: string }[] {
|
||||||
|
const result: { id: string; depth: number; parentName?: string }[] = [];
|
||||||
|
const walk = (nodes: MailboxNode[], parentName?: string) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
result.push({ id: node.id, depth: node.depth, parentName });
|
||||||
|
if (node.children.length > 0) walk(node.children, node.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(tree);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mailbox deep nesting (depth 4+)', () => {
|
||||||
|
// Reproduce the exact scenario from the bug report:
|
||||||
|
// INBOX > PRIVAT > BOOKINGS > BOOKING1/BOOKING2/FLIGHTS/HOTEL1/HOTEL2/HOTEL3
|
||||||
|
// HOTEL2 > RESTAURANT (depth 4)
|
||||||
|
it('should correctly nest the reported folder structure (depth 4)', () => {
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
|
||||||
|
makeMailbox({ id: 'booking1', name: 'BOOKING1', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'booking2', name: 'BOOKING2', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'flights', name: 'FLIGHTS', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'hotel1', name: 'HOTEL1', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'hotel3', name: 'HOTEL3', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||||
|
|
||||||
|
// RESTAURANT should be at depth 4, NOT depth 0
|
||||||
|
expect(byId['restaurant'].depth).toBe(4);
|
||||||
|
// Verify the full chain of depths
|
||||||
|
expect(byId['inbox'].depth).toBe(0);
|
||||||
|
expect(byId['privat'].depth).toBe(1);
|
||||||
|
expect(byId['bookings'].depth).toBe(2);
|
||||||
|
expect(byId['hotel2'].depth).toBe(3);
|
||||||
|
expect(byId['restaurant'].depth).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not orphan depth-4 folders to root level', () => {
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
|
||||||
|
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
|
||||||
|
// RESTAURANT must NOT appear at root level
|
||||||
|
const rootNames = tree.map(n => n.name);
|
||||||
|
expect(rootNames).not.toContain('RESTAURANT');
|
||||||
|
|
||||||
|
// It must be nested under HOTEL2
|
||||||
|
const nodes = collectNodes(tree);
|
||||||
|
const restaurant = nodes.find(n => n.id === 'restaurant');
|
||||||
|
expect(restaurant).toBeDefined();
|
||||||
|
expect(restaurant!.depth).toBe(4);
|
||||||
|
expect(restaurant!.parentName).toBe('HOTEL2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nesting up to depth 6', () => {
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'l0', name: 'Level0', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l1', name: 'Level1', parentId: 'l0', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l2', name: 'Level2', parentId: 'l1', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l3', name: 'Level3', parentId: 'l2', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l4', name: 'Level4', parentId: 'l3', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l5', name: 'Level5', parentId: 'l4', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l6', name: 'Level6', parentId: 'l5', sortOrder: 0 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||||
|
|
||||||
|
// Only 1 root node
|
||||||
|
expect(tree).toHaveLength(1);
|
||||||
|
expect(tree[0].name).toBe('Level0');
|
||||||
|
|
||||||
|
// Verify all depths are correct
|
||||||
|
for (let i = 0; i <= 6; i++) {
|
||||||
|
expect(byId[`l${i}`].depth).toBe(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nesting up to depth 10 (Stalwart default max)', () => {
|
||||||
|
const mailboxes: Mailbox[] = [];
|
||||||
|
for (let i = 0; i <= 10; i++) {
|
||||||
|
mailboxes.push(
|
||||||
|
makeMailbox({
|
||||||
|
id: `level-${i}`,
|
||||||
|
name: `Folder${i}`,
|
||||||
|
parentId: i === 0 ? undefined : `level-${i - 1}`,
|
||||||
|
sortOrder: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
|
||||||
|
// Should have exactly 11 nodes total
|
||||||
|
expect(flat).toHaveLength(11);
|
||||||
|
// Only 1 root
|
||||||
|
expect(tree).toHaveLength(1);
|
||||||
|
|
||||||
|
// Each node at correct depth
|
||||||
|
for (let i = 0; i <= 10; i++) {
|
||||||
|
const node = flat.find(n => n.id === `level-${i}`);
|
||||||
|
expect(node).toBeDefined();
|
||||||
|
expect(node!.depth).toBe(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly count children at each level in deep trees', () => {
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'root', name: 'Root', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'a', name: 'A', parentId: 'root' }),
|
||||||
|
makeMailbox({ id: 'b', name: 'B', parentId: 'root' }),
|
||||||
|
makeMailbox({ id: 'a1', name: 'A1', parentId: 'a' }),
|
||||||
|
makeMailbox({ id: 'a2', name: 'A2', parentId: 'a' }),
|
||||||
|
makeMailbox({ id: 'a1x', name: 'A1X', parentId: 'a1' }),
|
||||||
|
makeMailbox({ id: 'a1y', name: 'A1Y', parentId: 'a1' }),
|
||||||
|
makeMailbox({ id: 'a1x_deep', name: 'A1X_DEEP', parentId: 'a1x' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||||
|
|
||||||
|
// Verify structure
|
||||||
|
expect(tree).toHaveLength(1); // just Root
|
||||||
|
expect(tree[0].children).toHaveLength(2); // A, B
|
||||||
|
|
||||||
|
// Check depths
|
||||||
|
expect(byId['root'].depth).toBe(0);
|
||||||
|
expect(byId['a'].depth).toBe(1);
|
||||||
|
expect(byId['b'].depth).toBe(1);
|
||||||
|
expect(byId['a1'].depth).toBe(2);
|
||||||
|
expect(byId['a2'].depth).toBe(2);
|
||||||
|
expect(byId['a1x'].depth).toBe(3);
|
||||||
|
expect(byId['a1y'].depth).toBe(3);
|
||||||
|
expect(byId['a1x_deep'].depth).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flatten deep trees in correct parent-before-child order', () => {
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'l0', name: 'L0', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l1', name: 'L1', parentId: 'l0', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l2', name: 'L2', parentId: 'l1', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l3', name: 'L3', parentId: 'l2', sortOrder: 0 }),
|
||||||
|
makeMailbox({ id: 'l4', name: 'L4', parentId: 'l3', sortOrder: 0 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const ids = flat.map(n => n.id);
|
||||||
|
|
||||||
|
// Parent must always come before child in the flat list
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const parentIdx = ids.indexOf(`l${i}`);
|
||||||
|
const childIdx = ids.indexOf(`l${i + 1}`);
|
||||||
|
expect(parentIdx).toBeLessThan(childIdx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mailboxes provided in reverse order (children before parents)', () => {
|
||||||
|
// JMAP doesn't guarantee order - children might arrive before parents
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||||
|
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
|
||||||
|
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const byId = Object.fromEntries(flat.map((n) => [n.id, n]));
|
||||||
|
|
||||||
|
// All nodes present
|
||||||
|
expect(flat).toHaveLength(5);
|
||||||
|
// Only 1 root
|
||||||
|
expect(tree).toHaveLength(1);
|
||||||
|
// Correct depths regardless of input order
|
||||||
|
expect(byId['inbox'].depth).toBe(0);
|
||||||
|
expect(byId['privat'].depth).toBe(1);
|
||||||
|
expect(byId['bookings'].depth).toBe(2);
|
||||||
|
expect(byId['hotel2'].depth).toBe(3);
|
||||||
|
expect(byId['restaurant'].depth).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle wide + deep trees without orphaning', () => {
|
||||||
|
// Mix of wide (many siblings) and deep nesting
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
|
// 5 children of inbox
|
||||||
|
...Array.from({ length: 5 }, (_, i) =>
|
||||||
|
makeMailbox({ id: `child-${i}`, name: `Child${i}`, parentId: 'inbox' })
|
||||||
|
),
|
||||||
|
// Each child has 2 sub-children
|
||||||
|
...Array.from({ length: 5 }, (_, i) => [
|
||||||
|
makeMailbox({ id: `gc-${i}-0`, name: `GC${i}-0`, parentId: `child-${i}` }),
|
||||||
|
makeMailbox({ id: `gc-${i}-1`, name: `GC${i}-1`, parentId: `child-${i}` }),
|
||||||
|
]).flat(),
|
||||||
|
// Some grandchildren have great-grandchildren (depth 3)
|
||||||
|
makeMailbox({ id: 'ggc-0', name: 'GGC0', parentId: 'gc-0-0' }),
|
||||||
|
makeMailbox({ id: 'ggc-1', name: 'GGC1', parentId: 'gc-2-1' }),
|
||||||
|
// depth 4
|
||||||
|
makeMailbox({ id: 'gggc-0', name: 'GGGC0', parentId: 'ggc-0' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const rootNames = tree.map(n => n.name);
|
||||||
|
|
||||||
|
// Only INBOX should be at root
|
||||||
|
expect(rootNames).toEqual(['INBOX']);
|
||||||
|
// Total nodes
|
||||||
|
expect(flat).toHaveLength(mailboxes.length);
|
||||||
|
|
||||||
|
// Verify depth-4 node
|
||||||
|
const gggc = flat.find(n => n.id === 'gggc-0');
|
||||||
|
expect(gggc).toBeDefined();
|
||||||
|
expect(gggc!.depth).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mailbox deduplication side effects on nesting', () => {
|
||||||
|
it('should not remove an intermediate parent whose name matches a role mailbox', () => {
|
||||||
|
// Scenario: A non-role folder named "Sent" is an intermediate parent.
|
||||||
|
// The deduplication logic might remove it because it matches the role "sent" mailbox name.
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
|
||||||
|
// A user-created folder also named "Sent" that's a child of Inbox
|
||||||
|
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
|
||||||
|
// Child of the custom "Sent" folder
|
||||||
|
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
|
||||||
|
// The custom "Sent" (sent-custom) might be filtered by deduplication.
|
||||||
|
// If it IS removed, then "sent-child" loses its parent and becomes root — BAD.
|
||||||
|
const sentChild = flat.find(n => n.id === 'sent-child');
|
||||||
|
expect(sentChild).toBeDefined();
|
||||||
|
|
||||||
|
// Check if sent-child is orphaned at root (the bug)
|
||||||
|
const rootIds = tree.map(n => n.id);
|
||||||
|
const isOrphaned = rootIds.includes('sent-child');
|
||||||
|
|
||||||
|
if (isOrphaned) {
|
||||||
|
// This demonstrates the deduplication bug: removing an intermediate parent
|
||||||
|
// causes its children to become orphaned at root level
|
||||||
|
console.warn(
|
||||||
|
'BUG DETECTED: Deduplication removed intermediate parent "sent-custom", ' +
|
||||||
|
'orphaning "sent-child" to root level.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document the current behavior (this test is diagnostic)
|
||||||
|
// Ideally: sent-child should be nested under sent-custom at depth 2
|
||||||
|
// If dedup removes sent-custom: sent-child ends up at root with depth 0
|
||||||
|
expect(sentChild!.depth).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not remove a parent folder whose name is a substring of a role name', () => {
|
||||||
|
// Folder named "Draft" (substring of "Drafts" role) used as intermediate parent
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
|
||||||
|
makeMailbox({ id: 'draft-folder', name: 'Draft', parentId: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'draft-child', name: 'Notes', parentId: 'draft-folder' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const rootIds = tree.map(n => n.id);
|
||||||
|
|
||||||
|
const draftChild = flat.find(n => n.id === 'draft-child');
|
||||||
|
expect(draftChild).toBeDefined();
|
||||||
|
|
||||||
|
const isOrphaned = rootIds.includes('draft-child');
|
||||||
|
if (isOrphaned) {
|
||||||
|
console.warn(
|
||||||
|
'BUG DETECTED: Deduplication removed "draft-folder" (substring match with "Drafts"), ' +
|
||||||
|
'orphaning "draft-child" to root level.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(draftChild!.depth).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mailbox orphan behavior (missing parent)', () => {
|
||||||
|
it('should expose orphan-to-root behavior when parent is missing', () => {
|
||||||
|
// Simulates what happens if JMAP response is incomplete (e.g., truncated at 500 objects)
|
||||||
|
// Middle parent "bookings" is missing from the response
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'inbox', name: 'INBOX', role: 'inbox' }),
|
||||||
|
makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
|
||||||
|
// 'bookings' is MISSING — simulating truncated JMAP response
|
||||||
|
makeMailbox({ id: 'hotel2', name: 'HOTEL2', parentId: 'bookings' }),
|
||||||
|
makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'hotel2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const rootIds = tree.map(n => n.id);
|
||||||
|
|
||||||
|
// hotel2's parent ("bookings") is missing from the response.
|
||||||
|
// Current behavior: hotel2 becomes a root node (orphaned).
|
||||||
|
// This means RESTAURANT also appears under hotel2 at root, but at depth 1 instead of depth 4.
|
||||||
|
const hotel2 = flat.find(n => n.id === 'hotel2');
|
||||||
|
expect(hotel2).toBeDefined();
|
||||||
|
|
||||||
|
// Diagnostic: document that orphaning DOES happen
|
||||||
|
if (rootIds.includes('hotel2')) {
|
||||||
|
expect(hotel2!.depth).toBe(0); // orphaned at root
|
||||||
|
const restaurant = flat.find(n => n.id === 'restaurant');
|
||||||
|
expect(restaurant!.depth).toBe(1); // child of orphaned root
|
||||||
|
console.warn(
|
||||||
|
'CONFIRMED: Missing intermediate parent causes orphan-to-root. ' +
|
||||||
|
'hotel2 (parentId: "bookings") is at root with depth 0 instead of depth 3.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expose orphan behavior with multiple missing parents in chain', () => {
|
||||||
|
// Deep chain where two intermediate parents are missing
|
||||||
|
const mailboxes = [
|
||||||
|
makeMailbox({ id: 'root', name: 'Root', sortOrder: 0 }),
|
||||||
|
// 'level1' is MISSING
|
||||||
|
// 'level2' is MISSING
|
||||||
|
makeMailbox({ id: 'level3', name: 'Level3', parentId: 'level2' }),
|
||||||
|
makeMailbox({ id: 'level4', name: 'Level4', parentId: 'level3' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const flat = flattenMailboxTree(tree);
|
||||||
|
const rootIds = tree.map(n => n.id);
|
||||||
|
|
||||||
|
// level3's parent (level2) is missing → level3 becomes root
|
||||||
|
expect(rootIds).toContain('level3');
|
||||||
|
|
||||||
|
// level4 is correctly nested under level3 (since level3 IS in the map)
|
||||||
|
const level4 = flat.find(n => n.id === 'level4');
|
||||||
|
expect(level4).toBeDefined();
|
||||||
|
expect(level4!.depth).toBe(1); // child of orphaned level3 (depth 0)
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
'CONFIRMED: Missing parents in chain cause subtree to float to root. ' +
|
||||||
|
'Level3 (expected depth 2) is at depth 0, Level4 (expected depth 3) is at depth 1.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,6 +52,8 @@ function resetStore() {
|
|||||||
'settings-section': [],
|
'settings-section': [],
|
||||||
'context-menu-email': [],
|
'context-menu-email': [],
|
||||||
'navigation-rail-bottom': [],
|
'navigation-rail-bottom': [],
|
||||||
|
'calendar-event-actions': [],
|
||||||
|
'admin-plugin-page': [],
|
||||||
},
|
},
|
||||||
initialized: false,
|
initialized: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||||
import { useIdentityStore } from '@/stores/identity-store';
|
import { useIdentityStore } from '@/stores/identity-store';
|
||||||
import { useVacationStore } from '@/stores/vacation-store';
|
import { useVacationStore } from '@/stores/vacation-store';
|
||||||
|
import { useSmimeStore } from '@/stores/smime-store';
|
||||||
|
|
||||||
// Minimal snapshot shapes — we only capture what we need
|
// Minimal snapshot shapes — we only capture what we need
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -117,6 +118,7 @@ export function clearAllStores(): void {
|
|||||||
useVacationStore.getState().clearState();
|
useVacationStore.getState().clearState();
|
||||||
useCalendarStore.getState().clearState();
|
useCalendarStore.getState().clearState();
|
||||||
useFilterStore.getState().clearState();
|
useFilterStore.getState().clearState();
|
||||||
|
useSmimeStore.getState().clearState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Evict cached state for one account */
|
/** Evict cached state for one account */
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PluginConfigField {
|
||||||
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerPlugin {
|
export interface ServerPlugin {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -28,6 +38,7 @@ export interface ServerPlugin {
|
|||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
forceEnabled?: boolean;
|
forceEnabled?: boolean;
|
||||||
|
configSchema?: Record<string, PluginConfigField>;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
|
|||||||
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
||||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
|
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
|
||||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||||
|
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||||||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
||||||
|
|||||||
@@ -74,15 +74,57 @@ export function isAllDayEventLike(event: Pick<Partial<CalendarEvent>, 'start' |
|
|||||||
&& end.getMilliseconds() === 0;
|
&& end.getMilliseconds() === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart returns "recurrenceRule" (singular) instead of RFC 8984 "recurrenceRules" (plural).
|
||||||
|
* Normalize server responses to match the client's internal type.
|
||||||
|
*/
|
||||||
|
function normalizeStalwartPropertyNames<T extends Partial<CalendarEvent>>(event: T): T {
|
||||||
|
const raw = event as Record<string, unknown>;
|
||||||
|
let patched = false;
|
||||||
|
const updates: Partial<CalendarEvent> = {};
|
||||||
|
|
||||||
|
if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) {
|
||||||
|
// JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object,
|
||||||
|
// but Stalwart may also return it as an array (for JMAP-created events).
|
||||||
|
// Normalize both forms to our internal array type.
|
||||||
|
const val = raw.recurrenceRule;
|
||||||
|
if (val != null && !Array.isArray(val) && typeof val === 'object') {
|
||||||
|
updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules'];
|
||||||
|
} else {
|
||||||
|
updates.recurrenceRules = val as CalendarEvent['recurrenceRules'];
|
||||||
|
}
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) {
|
||||||
|
const val = raw.excludedRecurrenceRule;
|
||||||
|
if (val != null && !Array.isArray(val) && typeof val === 'object') {
|
||||||
|
updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules'];
|
||||||
|
} else {
|
||||||
|
updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules'];
|
||||||
|
}
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!patched) return event;
|
||||||
|
|
||||||
|
const result = { ...event, ...updates } as T;
|
||||||
|
delete (result as Record<string, unknown>).recurrenceRule;
|
||||||
|
delete (result as Record<string, unknown>).excludedRecurrenceRule;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeCalendarEventLike<T extends Partial<CalendarEvent>>(event: T): T {
|
export function normalizeCalendarEventLike<T extends Partial<CalendarEvent>>(event: T): T {
|
||||||
if (!isAllDayEventLike(event)) {
|
// First normalize Stalwart's singular property names to RFC 8984 plural forms
|
||||||
return event;
|
const normalized = normalizeStalwartPropertyNames(event);
|
||||||
|
|
||||||
|
if (!isAllDayEventLike(normalized)) {
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...event,
|
...normalized,
|
||||||
showWithoutTime: true,
|
showWithoutTime: true,
|
||||||
duration: normalizeAllDayDurationValue(event.duration),
|
duration: normalizeAllDayDurationValue(normalized.duration),
|
||||||
} as T;
|
} as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+149
-38
@@ -1,4 +1,4 @@
|
|||||||
import { differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
|
import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
|
||||||
import { parseDuration } from "@/components/calendar/event-card";
|
import { parseDuration } from "@/components/calendar/event-card";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -11,15 +11,63 @@ export interface CalendarWeekSegment {
|
|||||||
continuesAfter: boolean;
|
continuesAfter: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TimedEventLayout {
|
||||||
|
event: CalendarEvent;
|
||||||
|
column: number;
|
||||||
|
totalColumns: number;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
continuesBefore: boolean;
|
||||||
|
continuesAfter: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventStartDate(
|
||||||
|
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
|
||||||
|
): Date {
|
||||||
|
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
|
||||||
|
return parseISO(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
|
||||||
|
rawSegments.sort((left, right) => {
|
||||||
|
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
|
||||||
|
if (left.span !== right.span) return right.span - left.span;
|
||||||
|
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
|
||||||
|
return left.event.showWithoutTime ? -1 : 1;
|
||||||
|
}
|
||||||
|
const timeDiff = getEventStartDate(left.event).getTime() - getEventStartDate(right.event).getTime();
|
||||||
|
if (timeDiff !== 0) return timeDiff;
|
||||||
|
return (left.event.title || "").localeCompare(right.event.title || "");
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowEndIndices: number[] = [];
|
||||||
|
return rawSegments.map((segment) => {
|
||||||
|
const segmentEndIndex = segment.startIndex + segment.span - 1;
|
||||||
|
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
|
||||||
|
if (row === -1) {
|
||||||
|
row = rowEndIndices.length;
|
||||||
|
rowEndIndices.push(segmentEndIndex);
|
||||||
|
} else {
|
||||||
|
rowEndIndices[row] = segmentEndIndex;
|
||||||
|
}
|
||||||
|
return { ...segment, row };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getEventEndDate(event: CalendarEvent): Date {
|
export function getEventEndDate(event: CalendarEvent): Date {
|
||||||
const start = parseISO(event.start);
|
if (!event.showWithoutTime && event.utcEnd) {
|
||||||
|
return parseISO(event.utcEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = getEventStartDate(event);
|
||||||
if (!event.duration) return start;
|
if (!event.duration) return start;
|
||||||
return new Date(start.getTime() + parseDuration(event.duration) * 60000);
|
return new Date(start.getTime() + parseDuration(event.duration) * 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
||||||
const end = getEventEndDate(event);
|
const end = getEventEndDate(event);
|
||||||
if (!event.showWithoutTime || end.getTime() <= parseISO(event.start).getTime()) {
|
const start = getEventStartDate(event);
|
||||||
|
if (!event.showWithoutTime || end.getTime() <= start.getTime()) {
|
||||||
return end;
|
return end;
|
||||||
}
|
}
|
||||||
return subMilliseconds(end, 1);
|
return subMilliseconds(end, 1);
|
||||||
@@ -27,11 +75,44 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
|||||||
|
|
||||||
export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } {
|
export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } {
|
||||||
return {
|
return {
|
||||||
startDay: startOfDay(parseISO(event.start)),
|
startDay: startOfDay(getEventStartDate(event)),
|
||||||
endDay: startOfDay(getEventDisplayEndDate(event)),
|
endDay: startOfDay(getEventDisplayEndDate(event)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTimedEventBoundsForDay(
|
||||||
|
event: CalendarEvent,
|
||||||
|
day: Date,
|
||||||
|
): { startMinutes: number; endMinutes: number; continuesBefore: boolean; continuesAfter: boolean } | null {
|
||||||
|
if (event.showWithoutTime) return null;
|
||||||
|
|
||||||
|
const eventStart = getEventStartDate(event);
|
||||||
|
const eventEnd = getEventEndDate(event);
|
||||||
|
const dayStart = startOfDay(day);
|
||||||
|
const nextDayStart = addDays(dayStart, 1);
|
||||||
|
|
||||||
|
if (eventEnd <= dayStart || eventStart >= nextDayStart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clippedStart = eventStart > dayStart ? eventStart : dayStart;
|
||||||
|
const clippedEnd = eventEnd < nextDayStart ? eventEnd : nextDayStart;
|
||||||
|
const startMinutes = Math.max(0, Math.floor((clippedStart.getTime() - dayStart.getTime()) / 60000));
|
||||||
|
const endMinutes = Math.min(1440, Math.ceil((clippedEnd.getTime() - dayStart.getTime()) / 60000));
|
||||||
|
|
||||||
|
return {
|
||||||
|
startMinutes,
|
||||||
|
endMinutes,
|
||||||
|
continuesBefore: eventStart < dayStart,
|
||||||
|
continuesAfter: eventEnd > nextDayStart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
|
||||||
|
const bounds = getTimedEventBoundsForDay(event, day);
|
||||||
|
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeAllDayDuration(duration: string | undefined): string | undefined {
|
export function normalizeAllDayDuration(duration: string | undefined): string | undefined {
|
||||||
if (!duration) return undefined;
|
if (!duration) return undefined;
|
||||||
const totalMinutes = parseDuration(duration);
|
const totalMinutes = parseDuration(duration);
|
||||||
@@ -44,7 +125,7 @@ export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
|
|||||||
return `P${dayCount}D`;
|
return `P${dayCount}D`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
export function buildWeekSegmentsRaw(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
||||||
if (weekDays.length === 0) return [];
|
if (weekDays.length === 0) return [];
|
||||||
|
|
||||||
const weekStart = startOfDay(weekDays[0]);
|
const weekStart = startOfDay(weekDays[0]);
|
||||||
@@ -71,59 +152,89 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca
|
|||||||
} satisfies CalendarWeekSegment];
|
} satisfies CalendarWeekSegment];
|
||||||
});
|
});
|
||||||
|
|
||||||
rawSegments.sort((left, right) => {
|
return rawSegments;
|
||||||
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
|
}
|
||||||
if (left.span !== right.span) return right.span - left.span;
|
|
||||||
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
|
export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
||||||
return left.event.showWithoutTime ? -1 : 1;
|
return packWeekSegments(buildWeekSegmentsRaw(events, weekDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimedFullDayWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
|
||||||
|
if (weekDays.length === 0) return [];
|
||||||
|
|
||||||
|
const rawSegments = events.flatMap((event) => {
|
||||||
|
const fullDayIndices = weekDays
|
||||||
|
.map((day, index) => (isTimedEventFullDayOnDate(event, day) ? index : -1))
|
||||||
|
.filter((index) => index >= 0);
|
||||||
|
|
||||||
|
if (fullDayIndices.length === 0) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
const timeDiff = parseISO(left.event.start).getTime() - parseISO(right.event.start).getTime();
|
|
||||||
if (timeDiff !== 0) return timeDiff;
|
const segments: CalendarWeekSegment[] = [];
|
||||||
return (left.event.title || "").localeCompare(right.event.title || "");
|
let rangeStart = fullDayIndices[0];
|
||||||
|
let previousIndex = fullDayIndices[0];
|
||||||
|
|
||||||
|
const pushSegment = (startIndex: number, endIndex: number) => {
|
||||||
|
const startDay = weekDays[startIndex];
|
||||||
|
const endDay = weekDays[endIndex];
|
||||||
|
segments.push({
|
||||||
|
event,
|
||||||
|
startIndex,
|
||||||
|
span: endIndex - startIndex + 1,
|
||||||
|
row: -1,
|
||||||
|
continuesBefore: isTimedEventFullDayOnDate(event, addDays(startDay, -1)),
|
||||||
|
continuesAfter: isTimedEventFullDayOnDate(event, addDays(endDay, 1)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 1; index < fullDayIndices.length; index++) {
|
||||||
|
const currentIndex = fullDayIndices[index];
|
||||||
|
if (currentIndex !== previousIndex + 1) {
|
||||||
|
pushSegment(rangeStart, previousIndex);
|
||||||
|
rangeStart = currentIndex;
|
||||||
|
}
|
||||||
|
previousIndex = currentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushSegment(rangeStart, previousIndex);
|
||||||
|
return segments;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowEndIndices: number[] = [];
|
return packWeekSegments(rawSegments);
|
||||||
return rawSegments.map((segment) => {
|
|
||||||
const segmentEndIndex = segment.startIndex + segment.span - 1;
|
|
||||||
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
|
|
||||||
if (row === -1) {
|
|
||||||
row = rowEndIndices.length;
|
|
||||||
rowEndIndices.push(segmentEndIndex);
|
|
||||||
} else {
|
|
||||||
rowEndIndices[row] = segmentEndIndex;
|
|
||||||
}
|
|
||||||
return { ...segment, row };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function layoutOverlappingEvents(
|
export function layoutOverlappingEvents(
|
||||||
events: CalendarEvent[],
|
events: CalendarEvent[],
|
||||||
): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
day: Date,
|
||||||
const sorted = [...events].sort((a, b) => {
|
): TimedEventLayout[] {
|
||||||
const diff = parseISO(a.start).getTime() - parseISO(b.start).getTime();
|
const layoutInputs = events.flatMap((event) => {
|
||||||
|
const bounds = getTimedEventBoundsForDay(event, day);
|
||||||
|
return bounds ? [{ event, ...bounds }] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = layoutInputs.sort((a, b) => {
|
||||||
|
const diff = a.startMinutes - b.startMinutes;
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
return parseDuration(b.duration) - parseDuration(a.duration);
|
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns: { event: CalendarEvent; end: number }[][] = [];
|
const columns: { event: CalendarEvent; end: number }[][] = [];
|
||||||
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
|
const result: TimedEventLayout[] = [];
|
||||||
|
|
||||||
for (const event of sorted) {
|
for (const event of sorted) {
|
||||||
const start = parseISO(event.start);
|
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
|
||||||
const endMin = Math.min(1440, startMin + Math.max(15, parseDuration(event.duration)));
|
|
||||||
let placed = false;
|
let placed = false;
|
||||||
for (let col = 0; col < columns.length; col++) {
|
for (let col = 0; col < columns.length; col++) {
|
||||||
if (columns[col].every(e => e.end <= startMin)) {
|
if (columns[col].every(e => e.end <= event.startMinutes)) {
|
||||||
columns[col].push({ event, end: endMin });
|
columns[col].push({ event: event.event, end: event.endMinutes });
|
||||||
result.push({ event, column: col, totalColumns: 0 });
|
result.push({ ...event, column: col, totalColumns: 0 });
|
||||||
placed = true;
|
placed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!placed) {
|
if (!placed) {
|
||||||
columns.push([{ event, end: endMin }]);
|
columns.push([{ event: event.event, end: event.endMinutes }]);
|
||||||
result.push({ event, column: columns.length - 1, totalColumns: 0 });
|
result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
getServerUrl(): string { return 'https://demo.example.com'; }
|
getServerUrl(): string { return 'https://demo.example.com'; }
|
||||||
getAuthHeader(): string { return 'Bearer demo-token'; }
|
getAuthHeader(): string { return 'Bearer demo-token'; }
|
||||||
updateAccessToken(): void { /* no-op */ }
|
updateAccessToken(): void { /* no-op */ }
|
||||||
|
upgradeToBearer(): void { /* no-op */ }
|
||||||
|
enableTotpReauth(): void { /* no-op */ }
|
||||||
|
updateBasicAuth(): void { /* no-op */ }
|
||||||
getAccountId(): string { return 'demo-account'; }
|
getAccountId(): string { return 'demo-account'; }
|
||||||
getUsername(): string { return 'demo@example.com'; }
|
getUsername(): string { return 'demo@example.com'; }
|
||||||
|
|
||||||
@@ -597,6 +600,15 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
return full;
|
return full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchCreateCalendarEvents(events: Partial<CalendarEvent>[]): Promise<{ created: CalendarEvent[]; failed: string[] }> {
|
||||||
|
const created: CalendarEvent[] = [];
|
||||||
|
for (const event of events) {
|
||||||
|
const full = await this.createCalendarEvent(event);
|
||||||
|
created.push(full);
|
||||||
|
}
|
||||||
|
return { created, failed: [] };
|
||||||
|
}
|
||||||
|
|
||||||
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
|
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
|
||||||
const event = this.data.calendarEvents.find(e => e.id === eventId);
|
const event = this.data.calendarEvents.find(e => e.id === eventId);
|
||||||
if (!event) throw new Error('Event not found');
|
if (!event) throw new Error('Event not found');
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export interface IJMAPClient {
|
|||||||
getServerUrl(): string;
|
getServerUrl(): string;
|
||||||
getAuthHeader(): string;
|
getAuthHeader(): string;
|
||||||
updateAccessToken(token: string): void;
|
updateAccessToken(token: string): void;
|
||||||
|
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void;
|
||||||
|
enableTotpReauth(basePassword: string, callback: () => Promise<string | null>): void;
|
||||||
|
updateBasicAuth(newPassword: string): void;
|
||||||
getAccountId(): string;
|
getAccountId(): string;
|
||||||
getUsername(): string;
|
getUsername(): string;
|
||||||
|
|
||||||
@@ -193,6 +196,7 @@ export interface IJMAPClient {
|
|||||||
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
|
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
|
||||||
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
|
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
|
||||||
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
|
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
|
||||||
|
batchCreateCalendarEvents(events: Partial<CalendarEvent>[], targetAccountId?: string): Promise<{ created: CalendarEvent[]; failed: string[] }>;
|
||||||
updateCalendarEvent(
|
updateCalendarEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
updates: Partial<CalendarEvent>,
|
updates: Partial<CalendarEvent>,
|
||||||
|
|||||||
+617
-44
@@ -95,6 +95,24 @@ const EMAIL_LIST_PROPERTIES = [
|
|||||||
"hasAttachment",
|
"hasAttachment",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether a calendar object returned by the server is actually a
|
||||||
|
* task (VTODO) rather than an event (VEVENT). CalDAV clients like
|
||||||
|
* Thunderbird create VTODOs that Stalwart exposes through the
|
||||||
|
* CalendarEvent endpoints without a reliable `@type` discriminator.
|
||||||
|
*/
|
||||||
|
function isTaskObject(obj: { '@type'?: string; progress?: unknown; due?: unknown; percentComplete?: unknown }): boolean {
|
||||||
|
const type = obj['@type'];
|
||||||
|
if (typeof type === 'string' && type.toLowerCase() === 'task') return true;
|
||||||
|
// CalDAV-created tasks may lack @type='Task' — detect by task-specific fields
|
||||||
|
if (type !== 'Event' && (
|
||||||
|
('progress' in obj && typeof obj.progress === 'string') ||
|
||||||
|
('due' in obj && obj.due != null) ||
|
||||||
|
('percentComplete' in obj)
|
||||||
|
)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const CALENDAR_EVENT_PROPERTIES = [
|
const CALENDAR_EVENT_PROPERTIES = [
|
||||||
'id',
|
'id',
|
||||||
'@type',
|
'@type',
|
||||||
@@ -127,9 +145,9 @@ const CALENDAR_EVENT_PROPERTIES = [
|
|||||||
'hideAttendees',
|
'hideAttendees',
|
||||||
'recurrenceId',
|
'recurrenceId',
|
||||||
'recurrenceIdTimeZone',
|
'recurrenceIdTimeZone',
|
||||||
'recurrenceRules',
|
'recurrenceRule',
|
||||||
'recurrenceOverrides',
|
'recurrenceOverrides',
|
||||||
'excludedRecurrenceRules',
|
'excludedRecurrenceRule',
|
||||||
'useDefaultAlerts',
|
'useDefaultAlerts',
|
||||||
'alerts',
|
'alerts',
|
||||||
'locations',
|
'locations',
|
||||||
@@ -140,6 +158,74 @@ const CALENDAR_EVENT_PROPERTIES = [
|
|||||||
'isOrigin',
|
'isOrigin',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// Task-specific properties for CalendarEvent/get when fetching Task objects
|
||||||
|
const CALENDAR_TASK_PROPERTIES = [
|
||||||
|
'id',
|
||||||
|
'@type',
|
||||||
|
'uid',
|
||||||
|
'calendarIds',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'descriptionContentType',
|
||||||
|
'created',
|
||||||
|
'updated',
|
||||||
|
'start',
|
||||||
|
'due',
|
||||||
|
'duration',
|
||||||
|
'timeZone',
|
||||||
|
'showWithoutTime',
|
||||||
|
'utcStart',
|
||||||
|
'utcEnd',
|
||||||
|
'progress',
|
||||||
|
'progressUpdated',
|
||||||
|
'priority',
|
||||||
|
'privacy',
|
||||||
|
'color',
|
||||||
|
'keywords',
|
||||||
|
'categories',
|
||||||
|
'recurrenceRule',
|
||||||
|
'recurrenceOverrides',
|
||||||
|
'excludedRecurrenceRule',
|
||||||
|
'useDefaultAlerts',
|
||||||
|
'alerts',
|
||||||
|
'relatedTo',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart's calcard crate uses singular property names ("recurrenceRule")
|
||||||
|
* instead of the RFC 8984 plural forms ("recurrenceRules").
|
||||||
|
* JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object,
|
||||||
|
* not an array. This function converts our internal array form to a single
|
||||||
|
* object, cleans null values, and renames the properties.
|
||||||
|
*/
|
||||||
|
function cleanRecurrenceRules(event: Record<string, unknown>): void {
|
||||||
|
const keyMap: Record<string, string> = {
|
||||||
|
recurrenceRules: 'recurrenceRule',
|
||||||
|
excludedRecurrenceRules: 'excludedRecurrenceRule',
|
||||||
|
};
|
||||||
|
for (const [pluralKey, singularKey] of Object.entries(keyMap)) {
|
||||||
|
const rules = event[pluralKey];
|
||||||
|
if (rules === undefined) continue;
|
||||||
|
delete event[pluralKey];
|
||||||
|
if (!Array.isArray(rules)) {
|
||||||
|
// null means "remove recurrence" — pass through with the correct key
|
||||||
|
event[singularKey] = rules;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rules.length === 0) {
|
||||||
|
event[singularKey] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// JSCalendar 2.0: recurrenceRule is a single object, use first rule
|
||||||
|
const rule = rules[0] as Record<string, unknown>;
|
||||||
|
const cleaned: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(rule)) {
|
||||||
|
if (v !== null) cleaned[k] = v;
|
||||||
|
}
|
||||||
|
event[singularKey] = cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getCalendarEventDebugSnapshot(event: Partial<CalendarEvent> | null | undefined): Record<string, unknown> | null {
|
function getCalendarEventDebugSnapshot(event: Partial<CalendarEvent> | null | undefined): Record<string, unknown> | null {
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return null;
|
return null;
|
||||||
@@ -194,9 +280,11 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
private serverUrl: string;
|
private serverUrl: string;
|
||||||
private username: string;
|
private username: string;
|
||||||
private password: string;
|
private password: string;
|
||||||
|
private basePassword: string = '';
|
||||||
private authHeader: string;
|
private authHeader: string;
|
||||||
private authMode: 'basic' | 'bearer' = 'basic';
|
private authMode: 'basic' | 'bearer' = 'basic';
|
||||||
private onTokenRefresh?: () => Promise<string | null>;
|
private onTokenRefresh?: () => Promise<string | null>;
|
||||||
|
private onTotpRequired?: () => Promise<string | null>;
|
||||||
private apiUrl: string = "";
|
private apiUrl: string = "";
|
||||||
private accountId: string = "";
|
private accountId: string = "";
|
||||||
private downloadUrl: string = "";
|
private downloadUrl: string = "";
|
||||||
@@ -239,6 +327,29 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
this.authHeader = `Bearer ${token}`;
|
this.authHeader = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Upgrade an existing basic-auth client to bearer-token auth (e.g. after TOTP token exchange). */
|
||||||
|
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void {
|
||||||
|
this.authMode = 'bearer';
|
||||||
|
this.authHeader = `Bearer ${accessToken}`;
|
||||||
|
this.onTokenRefresh = onRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable TOTP re-authentication for basic-auth sessions.
|
||||||
|
* When a 401 is received, the callback is invoked to get a fresh TOTP code.
|
||||||
|
* The base password (without TOTP) is stored so we can construct new credentials.
|
||||||
|
*/
|
||||||
|
enableTotpReauth(basePassword: string, callback: () => Promise<string | null>): void {
|
||||||
|
this.basePassword = basePassword;
|
||||||
|
this.onTotpRequired = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update basic-auth credentials with a new password (e.g. password$newTotp). */
|
||||||
|
updateBasicAuth(newPassword: string): void {
|
||||||
|
this.password = newPassword;
|
||||||
|
this.authHeader = `Basic ${btoa(`${this.username}:${newPassword}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
getAuthHeader(): string {
|
getAuthHeader(): string {
|
||||||
return this.authHeader;
|
return this.authHeader;
|
||||||
}
|
}
|
||||||
@@ -291,7 +402,21 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||||||
response = await fetch(url, { ...init, headers: retryHeaders });
|
response = await fetch(url, { ...init, headers: retryHeaders });
|
||||||
} catch {
|
} catch {
|
||||||
// Session refresh failed — return original 401 response
|
// Session refresh failed — if TOTP was used, try re-auth with fresh TOTP
|
||||||
|
if (this.onTotpRequired && this.basePassword) {
|
||||||
|
try {
|
||||||
|
const newTotp = await this.onTotpRequired();
|
||||||
|
if (newTotp) {
|
||||||
|
this.updateBasicAuth(`${this.basePassword}$${newTotp}`);
|
||||||
|
await this.refreshSession();
|
||||||
|
this.connectionChangeCallback?.(true);
|
||||||
|
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||||||
|
response = await fetch(url, { ...init, headers: retryHeaders });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// TOTP re-auth also failed — return original 401
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.reconnecting = false;
|
this.reconnecting = false;
|
||||||
}
|
}
|
||||||
@@ -335,6 +460,16 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
? 'Authentication failed - token may be expired'
|
? 'Authentication failed - token may be expired'
|
||||||
: 'Invalid username or password');
|
: 'Invalid username or password');
|
||||||
}
|
}
|
||||||
|
if (sessionResponse.status === 402) {
|
||||||
|
try {
|
||||||
|
const body = await sessionResponse.json();
|
||||||
|
if (body?.title?.toLowerCase().includes('totp')) {
|
||||||
|
throw new Error('TOTP_REQUIRED');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === 'TOTP_REQUIRED') throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
throw new Error(`Failed to get session: ${sessionResponse.status}`);
|
throw new Error(`Failed to get session: ${sessionResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,6 +670,27 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
||||||
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
||||||
|
|
||||||
|
debug.log(`[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`);
|
||||||
|
|
||||||
|
// Warn if response might be truncated
|
||||||
|
const maxObjects = this.getMaxObjectsInGet();
|
||||||
|
if (rawMailboxes.length >= maxObjects) {
|
||||||
|
debug.warn(
|
||||||
|
`[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
|
||||||
|
`Some mailboxes may be missing — nested folders could appear orphaned at root level.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log parentId references to detect potential orphans
|
||||||
|
const returnedIds = new Set(rawMailboxes.map(mb => mb.id));
|
||||||
|
const missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId));
|
||||||
|
if (missingParents.length > 0) {
|
||||||
|
debug.warn(
|
||||||
|
`[JMAP Mailbox] ${missingParents.length} mailbox(es) reference parentId not in response (will be orphaned):`,
|
||||||
|
missingParents.map(mb => ({ id: mb.id, name: mb.name, parentId: mb.parentId }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return rawMailboxes.map((mb) => ({
|
return rawMailboxes.map((mb) => ({
|
||||||
id: mb.id,
|
id: mb.id,
|
||||||
originalId: undefined,
|
originalId: undefined,
|
||||||
@@ -599,6 +755,17 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
||||||
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
||||||
|
|
||||||
|
debug.log(`[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`);
|
||||||
|
|
||||||
|
// Warn if response might be truncated
|
||||||
|
const maxObjects = this.getMaxObjectsInGet();
|
||||||
|
if (rawMailboxes.length >= maxObjects) {
|
||||||
|
debug.warn(
|
||||||
|
`[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
|
||||||
|
`Some mailboxes may be missing.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const mailboxes = rawMailboxes.map((mb) => ({
|
const mailboxes = rawMailboxes.map((mb) => ({
|
||||||
id: isPrimary ? mb.id : `${accountId}:${mb.id}`,
|
id: isPrimary ? mb.id : `${accountId}:${mb.id}`,
|
||||||
originalId: mb.id,
|
originalId: mb.id,
|
||||||
@@ -649,6 +816,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
sort: [{ property: "receivedAt", isAscending: false }],
|
sort: [{ property: "receivedAt", isAscending: false }],
|
||||||
limit,
|
limit,
|
||||||
position,
|
position,
|
||||||
|
calculateTotal: true,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["Email/get", {
|
["Email/get", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
@@ -661,7 +829,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const getResponse = response.methodResponses?.[1]?.[1];
|
const getResponse = response.methodResponses?.[1]?.[1];
|
||||||
|
|
||||||
if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) {
|
if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) {
|
||||||
const emails = getResponse.list || [];
|
const emails = (getResponse.list || []) as Email[];
|
||||||
|
// Sort client-side as safety net — some servers may not honour
|
||||||
|
// the query sort for large mailboxes without additional filters.
|
||||||
|
emails.sort((a: Email, b: Email) =>
|
||||||
|
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||||
|
);
|
||||||
const total = queryResponse?.total || 0;
|
const total = queryResponse?.total || 0;
|
||||||
const hasMore = computeHasMore(position, emails.length, total, limit);
|
const hasMore = computeHasMore(position, emails.length, total, limit);
|
||||||
|
|
||||||
@@ -1181,6 +1354,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
sort: [{ property: "receivedAt", isAscending: false }],
|
sort: [{ property: "receivedAt", isAscending: false }],
|
||||||
limit,
|
limit,
|
||||||
position,
|
position,
|
||||||
|
calculateTotal: true,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["Email/get", {
|
["Email/get", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
@@ -1190,7 +1364,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const queryResponse = response.methodResponses?.[0]?.[1];
|
const queryResponse = response.methodResponses?.[0]?.[1];
|
||||||
const emails = response.methodResponses?.[1]?.[1]?.list || [];
|
const emails = (response.methodResponses?.[1]?.[1]?.list || []) as Email[];
|
||||||
|
emails.sort((a: Email, b: Email) =>
|
||||||
|
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||||
|
);
|
||||||
const total = queryResponse?.total || 0;
|
const total = queryResponse?.total || 0;
|
||||||
const hasMore = computeHasMore(position, emails.length, total, limit);
|
const hasMore = computeHasMore(position, emails.length, total, limit);
|
||||||
|
|
||||||
@@ -1217,6 +1394,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
sort: [{ property: "receivedAt", isAscending: false }],
|
sort: [{ property: "receivedAt", isAscending: false }],
|
||||||
limit,
|
limit,
|
||||||
position,
|
position,
|
||||||
|
calculateTotal: true,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["Email/get", {
|
["Email/get", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
@@ -1226,7 +1404,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const queryResponse = response.methodResponses?.[0]?.[1];
|
const queryResponse = response.methodResponses?.[0]?.[1];
|
||||||
const emails = response.methodResponses?.[1]?.[1]?.list || [];
|
const emails = (response.methodResponses?.[1]?.[1]?.list || []) as Email[];
|
||||||
|
emails.sort((a: Email, b: Email) =>
|
||||||
|
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||||
|
);
|
||||||
const total = queryResponse?.total || 0;
|
const total = queryResponse?.total || 0;
|
||||||
const hasMore = computeHasMore(position, emails.length, total, limit);
|
const hasMore = computeHasMore(position, emails.length, total, limit);
|
||||||
|
|
||||||
@@ -1601,19 +1782,28 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let finalIdentityId = identityId;
|
let finalIdentityId = identityId;
|
||||||
if (!finalIdentityId) {
|
let identityReplyTo: EmailAddress[] | undefined;
|
||||||
|
{
|
||||||
const identityResponse = await this.request([
|
const identityResponse = await this.request([
|
||||||
["Identity/get", { accountId: this.accountId }, "0"]
|
["Identity/get", { accountId: this.accountId }, "0"]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
finalIdentityId = this.accountId;
|
if (!finalIdentityId) {
|
||||||
|
finalIdentityId = this.accountId;
|
||||||
|
}
|
||||||
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||||
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
const identities = (identityResponse.methodResponses[0][1].list || []) as Identity[];
|
||||||
if (identities.length > 0) {
|
if (identities.length > 0) {
|
||||||
const target = fromEmail || this.username;
|
if (!identityId) {
|
||||||
const matchingIdentity = identities.find((id) => id.email === target)
|
const target = fromEmail || this.username;
|
||||||
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
|
const matchingIdentity = identities.find((id) => id.email === target)
|
||||||
finalIdentityId = matchingIdentity?.id || identities[0].id;
|
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
|
||||||
|
finalIdentityId = matchingIdentity?.id || identities[0].id;
|
||||||
|
identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo;
|
||||||
|
} else {
|
||||||
|
const matchedIdentity = identities.find((id) => id.id === identityId);
|
||||||
|
identityReplyTo = matchedIdentity?.replyTo;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1621,6 +1811,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
// Always create a new email with the final body content
|
// Always create a new email with the final body content
|
||||||
const emailCreate: Record<string, unknown> = {
|
const emailCreate: Record<string, unknown> = {
|
||||||
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
||||||
|
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
|
||||||
to: to.map(email => ({ email })),
|
to: to.map(email => ({ email })),
|
||||||
cc: cc?.map(email => ({ email })),
|
cc: cc?.map(email => ({ email })),
|
||||||
bcc: bcc?.map(email => ({ email })),
|
bcc: bcc?.map(email => ({ email })),
|
||||||
@@ -3052,6 +3243,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
||||||
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
|
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
|
||||||
|
.filter((event) => !isTaskObject(event))
|
||||||
.map((event) => normalizeCalendarEventLike(event));
|
.map((event) => normalizeCalendarEventLike(event));
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@@ -3112,10 +3304,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
filter,
|
filter,
|
||||||
limit: limit || 1000,
|
limit: limit || 1000,
|
||||||
};
|
};
|
||||||
// Expand recurring events into individual occurrences when a date range is provided
|
// NOTE: We do NOT use expandRecurrences because Stalwart returns synthetic
|
||||||
if (filter.after || filter.before) {
|
// IDs that cannot be used for CalendarEvent/set (update/destroy).
|
||||||
queryArgs.expandRecurrences = true;
|
// Recurrence expansion is done client-side instead.
|
||||||
}
|
|
||||||
if (sort) {
|
if (sort) {
|
||||||
queryArgs.sort = sort;
|
queryArgs.sort = sort;
|
||||||
}
|
}
|
||||||
@@ -3131,6 +3322,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
||||||
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
|
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
|
||||||
|
.filter((event) => !isTaskObject(event))
|
||||||
.map((event) => normalizeCalendarEventLike(event));
|
.map((event) => normalizeCalendarEventLike(event));
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@@ -3167,12 +3359,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
// Strip client-only shared fields before sending to JMAP
|
// Strip client-only shared fields before sending to JMAP
|
||||||
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
|
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
|
||||||
|
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
|
||||||
|
|
||||||
debug.group('CalendarEvent/create');
|
debug.group('CalendarEvent/create');
|
||||||
debug.log('CalendarEvent/create outgoing payload', {
|
debug.log('CalendarEvent/create outgoing payload', {
|
||||||
accountId,
|
accountId,
|
||||||
sendSchedulingMessages,
|
sendSchedulingMessages,
|
||||||
event: getCalendarEventDebugSnapshot(cleanEvent),
|
eventKeys: Object.keys(cleanEvent),
|
||||||
});
|
});
|
||||||
|
|
||||||
const setArgs: Record<string, unknown> = {
|
const setArgs: Record<string, unknown> = {
|
||||||
@@ -3197,6 +3390,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (result.notCreated?.["new-event"]) {
|
if (result.notCreated?.["new-event"]) {
|
||||||
const error = result.notCreated["new-event"];
|
const error = result.notCreated["new-event"];
|
||||||
debug.warn('CalendarEvent/create notCreated', error);
|
debug.warn('CalendarEvent/create notCreated', error);
|
||||||
|
debug.warn('CalendarEvent/create invalid properties', error.properties);
|
||||||
|
debug.warn('CalendarEvent/create sent keys', Object.keys(cleanEvent));
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
throw new Error(error.description || "Failed to create calendar event");
|
throw new Error(error.description || "Failed to create calendar event");
|
||||||
}
|
}
|
||||||
@@ -3241,6 +3436,75 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error("Failed to create calendar event");
|
throw new Error("Failed to create calendar event");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-create multiple calendar events in a single JMAP request.
|
||||||
|
* Returns arrays of successfully created events and failed creation keys.
|
||||||
|
*/
|
||||||
|
async batchCreateCalendarEvents(
|
||||||
|
events: Partial<CalendarEvent>[],
|
||||||
|
targetAccountId?: string,
|
||||||
|
): Promise<{ created: CalendarEvent[]; failed: string[] }> {
|
||||||
|
if (events.length === 0) return { created: [], failed: [] };
|
||||||
|
|
||||||
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
|
// Build the create map: { "new-0": event0, "new-1": event1, ... }
|
||||||
|
const createMap: Record<string, Partial<CalendarEvent>> = {};
|
||||||
|
for (let i = 0; i < events.length; i++) {
|
||||||
|
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent;
|
||||||
|
createMap[`new-${i}`] = clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.log('CalendarEvent/batchCreate', { count: events.length, accountId });
|
||||||
|
|
||||||
|
const response = await this.request([
|
||||||
|
["CalendarEvent/set", { accountId, create: createMap }, "0"]
|
||||||
|
], this.calendarUsing());
|
||||||
|
|
||||||
|
const createdIds: string[] = [];
|
||||||
|
const failed: string[] = [];
|
||||||
|
|
||||||
|
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||||
|
const result = response.methodResponses[0][1];
|
||||||
|
for (let i = 0; i < events.length; i++) {
|
||||||
|
const key = `new-${i}`;
|
||||||
|
if (result.created?.[key]?.id) {
|
||||||
|
createdIds.push(result.created[key].id);
|
||||||
|
} else if (result.notCreated?.[key]) {
|
||||||
|
debug.warn(`CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
|
||||||
|
failed.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdIds.length === 0) {
|
||||||
|
return { created: [], failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all created events in a single CalendarEvent/get
|
||||||
|
const getResponse = await this.request([
|
||||||
|
["CalendarEvent/get", {
|
||||||
|
accountId,
|
||||||
|
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||||
|
ids: createdIds,
|
||||||
|
}, "0"]
|
||||||
|
], this.calendarUsing());
|
||||||
|
|
||||||
|
let createdEvents: CalendarEvent[] = [];
|
||||||
|
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||||
|
const list = getResponse.methodResponses[0][1].list || [];
|
||||||
|
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.log('CalendarEvent/batchCreate result', {
|
||||||
|
requested: events.length,
|
||||||
|
created: createdEvents.length,
|
||||||
|
failed: failed.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { created: createdEvents, failed };
|
||||||
|
}
|
||||||
|
|
||||||
async updateCalendarEvent(
|
async updateCalendarEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
updates: Partial<CalendarEvent>,
|
updates: Partial<CalendarEvent>,
|
||||||
@@ -3251,6 +3515,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
// Strip client-only shared fields before sending to JMAP
|
// Strip client-only shared fields before sending to JMAP
|
||||||
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
|
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
|
||||||
|
cleanRecurrenceRules(cleanUpdates as unknown as Record<string, unknown>);
|
||||||
|
|
||||||
const setArgs: Record<string, unknown> = {
|
const setArgs: Record<string, unknown> = {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -3262,20 +3527,33 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug.log('CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages });
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["CalendarEvent/set", setArgs, "0"]
|
["CalendarEvent/set", setArgs, "0"]
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
const methodName = response.methodResponses?.[0]?.[0];
|
||||||
const result = response.methodResponses[0][1];
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
|
||||||
|
if (methodName === "error") {
|
||||||
|
const errorType = result?.type || 'unknown';
|
||||||
|
const errorDesc = result?.description || '';
|
||||||
|
debug.error('CalendarEvent/set update returned JMAP error', { type: errorType, description: errorDesc });
|
||||||
|
throw new Error(`JMAP error (${errorType}): ${errorDesc}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (methodName === "CalendarEvent/set") {
|
||||||
if (result.notUpdated?.[eventId]) {
|
if (result.notUpdated?.[eventId]) {
|
||||||
const error = result.notUpdated[eventId];
|
const error = result.notUpdated[eventId];
|
||||||
|
debug.error('CalendarEvent/set notUpdated', { eventId, error });
|
||||||
throw new Error(error.description || "Failed to update calendar event");
|
throw new Error(error.description || "Failed to update calendar event");
|
||||||
}
|
}
|
||||||
|
debug.log('CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug.error('CalendarEvent/set update unexpected response', { methodName, result });
|
||||||
throw new Error("Failed to update calendar event");
|
throw new Error("Failed to update calendar event");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3322,20 +3600,33 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug.log('CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages });
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["CalendarEvent/set", setArgs, "0"]
|
["CalendarEvent/set", setArgs, "0"]
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
const methodName = response.methodResponses?.[0]?.[0];
|
||||||
const result = response.methodResponses[0][1];
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
|
||||||
|
if (methodName === "error") {
|
||||||
|
const errorType = result?.type || 'unknown';
|
||||||
|
const errorDesc = result?.description || '';
|
||||||
|
debug.error('CalendarEvent/set destroy returned JMAP error', { type: errorType, description: errorDesc });
|
||||||
|
throw new Error(`JMAP error (${errorType}): ${errorDesc}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (methodName === "CalendarEvent/set") {
|
||||||
if (result.notDestroyed?.[eventId]) {
|
if (result.notDestroyed?.[eventId]) {
|
||||||
const error = result.notDestroyed[eventId];
|
const error = result.notDestroyed[eventId];
|
||||||
|
debug.error('CalendarEvent/set notDestroyed', { eventId, error });
|
||||||
throw new Error(error.description || "Failed to delete calendar event");
|
throw new Error(error.description || "Failed to delete calendar event");
|
||||||
}
|
}
|
||||||
|
debug.log('CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug.error('CalendarEvent/set destroy unexpected response', { methodName, result });
|
||||||
throw new Error("Failed to delete calendar event");
|
throw new Error("Failed to delete calendar event");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3362,36 +3653,245 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
// ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ───
|
// ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ───
|
||||||
|
|
||||||
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
||||||
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
debug.group('CalendarTask/fetch');
|
||||||
|
debug.log('CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const events = await this.getCalendarEvents(calendarIds, targetAccountId);
|
// Strategy 1: query with types filter (JMAP spec compliant)
|
||||||
return events.filter((e) => {
|
const filter: Record<string, unknown> = { types: ['Task'] };
|
||||||
const obj = e as unknown as Record<string, unknown>;
|
if (calendarIds && calendarIds.length > 0) {
|
||||||
const type = obj['@type'];
|
filter.inCalendars = calendarIds;
|
||||||
// Explicit @type check (case-insensitive to handle server variations)
|
}
|
||||||
if (typeof type === 'string' && type.toLowerCase() === 'task') return true;
|
|
||||||
// Fallback: detect tasks created via CalDAV (e.g. Thunderbird) where @type
|
debug.log('CalendarTask/fetch query filter', filter);
|
||||||
// may be missing. The "progress" property is exclusive to JSCalendar Task
|
|
||||||
// objects and never appears on Event objects.
|
const response = await this.request([
|
||||||
if (type !== 'Event' && 'progress' in obj && typeof obj.progress === 'string') return true;
|
["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"],
|
||||||
return false;
|
["CalendarEvent/get", {
|
||||||
}).map((e) => {
|
accountId,
|
||||||
const task = { ...e } as unknown as CalendarTask;
|
properties: [...CALENDAR_TASK_PROPERTIES],
|
||||||
// Normalize @type for tasks detected by fallback heuristic
|
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
|
||||||
if (task['@type'] !== 'Task') {
|
}, "1"]
|
||||||
(task as unknown as Record<string, unknown>)['@type'] = 'Task';
|
], this.calendarUsing());
|
||||||
|
|
||||||
|
const queryResponse = response.methodResponses?.[0];
|
||||||
|
const getResponse = response.methodResponses?.[1];
|
||||||
|
|
||||||
|
debug.log('CalendarTask/fetch query method', queryResponse?.[0]);
|
||||||
|
debug.log('CalendarTask/fetch query result', queryResponse?.[1]);
|
||||||
|
|
||||||
|
if (queryResponse?.[0] === "error") {
|
||||||
|
debug.warn('CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]);
|
||||||
|
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
|
||||||
|
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks');
|
||||||
|
debug.groupEnd();
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getResponse?.[0] === "CalendarEvent/get") {
|
||||||
|
const list = (getResponse[1].list || []) as CalendarTask[];
|
||||||
|
const queryIds = queryResponse?.[1]?.ids || [];
|
||||||
|
debug.log('CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds);
|
||||||
|
debug.log('CalendarTask/fetch get returned', list.length, 'objects');
|
||||||
|
|
||||||
|
// If the types filter returned 0 results, the server may have silently
|
||||||
|
// ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to
|
||||||
|
// a full scan so we can detect tasks by their properties.
|
||||||
|
if (queryIds.length === 0) {
|
||||||
|
debug.warn('CalendarTask/fetch types filter returned 0 results, falling back to full scan');
|
||||||
|
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
|
||||||
|
debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks');
|
||||||
|
debug.groupEnd();
|
||||||
|
return tasks;
|
||||||
}
|
}
|
||||||
return task;
|
|
||||||
});
|
list.forEach((task, i) => {
|
||||||
|
debug.log(`CalendarTask/fetch [${i}]`, {
|
||||||
|
id: task.id,
|
||||||
|
uid: task.uid,
|
||||||
|
'@type': task['@type'],
|
||||||
|
title: task.title,
|
||||||
|
due: task.due,
|
||||||
|
start: task.start,
|
||||||
|
progress: task.progress,
|
||||||
|
showWithoutTime: task.showWithoutTime,
|
||||||
|
calendarIds: task.calendarIds,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = list.map((task) => ({
|
||||||
|
...task,
|
||||||
|
'@type': 'Task' as const,
|
||||||
|
}));
|
||||||
|
debug.log('CalendarTask/fetch complete,', results.length, 'tasks');
|
||||||
|
debug.groupEnd();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.warn('CalendarTask/fetch unexpected response shape', response.methodResponses);
|
||||||
|
debug.groupEnd();
|
||||||
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get calendar tasks:', error);
|
debug.error('CalendarTask/fetch failed', error);
|
||||||
|
debug.groupEnd();
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for servers that don't support the `types` filter in CalendarEvent/query.
|
||||||
|
* Uses CalendarEvent/get with ids:null to fetch ALL calendar objects (JMAP spec),
|
||||||
|
* since CalendarEvent/query may only return Event-type objects on some servers.
|
||||||
|
*/
|
||||||
|
private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
||||||
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
debug.log('CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects');
|
||||||
|
|
||||||
|
// CalendarEvent/get with ids:null returns ALL calendar objects regardless of @type
|
||||||
|
const response = await this.request([
|
||||||
|
["CalendarEvent/get", {
|
||||||
|
accountId,
|
||||||
|
ids: null,
|
||||||
|
properties: [...CALENDAR_TASK_PROPERTIES],
|
||||||
|
}, "0"]
|
||||||
|
], this.calendarUsing());
|
||||||
|
|
||||||
|
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") {
|
||||||
|
debug.warn('CalendarTask/fallback unexpected response', response.methodResponses?.[0]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allObjects = (response.methodResponses[0][1].list || []) as Record<string, unknown>[];
|
||||||
|
debug.log('CalendarTask/fallback total calendar objects returned:', allObjects.length);
|
||||||
|
|
||||||
|
const tasks: CalendarTask[] = [];
|
||||||
|
const calendarIdSet = calendarIds ? new Set(calendarIds) : null;
|
||||||
|
|
||||||
|
allObjects.forEach((obj) => {
|
||||||
|
const type = obj['@type'];
|
||||||
|
const isExplicitTask = typeof type === 'string' && type.toLowerCase() === 'task';
|
||||||
|
// CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type
|
||||||
|
// set to something other than 'Event'. Detect them by the presence of
|
||||||
|
// task-specific fields: progress, due, or percentComplete.
|
||||||
|
const hasTaskFields = ('progress' in obj && typeof obj.progress === 'string')
|
||||||
|
|| ('due' in obj && obj.due != null)
|
||||||
|
|| ('percentComplete' in obj);
|
||||||
|
const isCalDavTask = type !== 'Event' && hasTaskFields;
|
||||||
|
|
||||||
|
debug.log('CalendarTask/fallback scan', {
|
||||||
|
id: obj.id,
|
||||||
|
'@type': type,
|
||||||
|
title: obj.title,
|
||||||
|
hasProgress: 'progress' in obj,
|
||||||
|
progress: obj.progress,
|
||||||
|
due: obj.due,
|
||||||
|
isExplicitTask,
|
||||||
|
isCalDavTask,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isExplicitTask && !isCalDavTask) return;
|
||||||
|
|
||||||
|
// Filter by calendar if requested
|
||||||
|
if (calendarIdSet) {
|
||||||
|
const objCalendarIds = obj.calendarIds as Record<string, boolean> | undefined;
|
||||||
|
if (objCalendarIds && !Object.keys(objCalendarIds).some(id => calendarIdSet.has(id))) {
|
||||||
|
debug.log('CalendarTask/fallback skipping task (not in requested calendars)', obj.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask);
|
||||||
|
});
|
||||||
|
|
||||||
|
debug.log('CalendarTask/fallback detected', tasks.length, 'tasks');
|
||||||
|
tasks.forEach((t, i) => {
|
||||||
|
debug.log(`CalendarTask/fallback [${i}]`, {
|
||||||
|
id: t.id,
|
||||||
|
uid: t.uid,
|
||||||
|
title: t.title,
|
||||||
|
due: t.due,
|
||||||
|
progress: t.progress,
|
||||||
|
showWithoutTime: t.showWithoutTime,
|
||||||
|
calendarIds: t.calendarIds,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
async createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
|
async createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
|
||||||
const event = { ...task, '@type': 'Task' } as unknown as Partial<CalendarEvent>;
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
const created = await this.createCalendarEvent(event, false, targetAccountId);
|
const { '@type': _type, ...taskData } = task;
|
||||||
return created as unknown as CalendarTask;
|
const cleanTask = { ...taskData, '@type': 'Task' };
|
||||||
|
|
||||||
|
debug.group('CalendarTask/create');
|
||||||
|
debug.log('CalendarTask/create accountId', accountId);
|
||||||
|
debug.log('CalendarTask/create outgoing payload', cleanTask);
|
||||||
|
|
||||||
|
const response = await this.request([
|
||||||
|
["CalendarEvent/set", {
|
||||||
|
accountId,
|
||||||
|
sendSchedulingMessages: false,
|
||||||
|
create: { "new-task": cleanTask },
|
||||||
|
}, "0"]
|
||||||
|
], this.calendarUsing());
|
||||||
|
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
debug.log('CalendarTask/create raw set response', result);
|
||||||
|
|
||||||
|
if (result?.notCreated?.["new-task"]) {
|
||||||
|
const error = result.notCreated["new-task"];
|
||||||
|
debug.warn('CalendarTask/create REJECTED by server', error);
|
||||||
|
debug.groupEnd();
|
||||||
|
throw new Error(error.description || "Failed to create task");
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdId = result?.created?.["new-task"]?.id;
|
||||||
|
const serverCreated = result?.created?.["new-task"];
|
||||||
|
debug.log('CalendarTask/create server acknowledged', { createdId, serverCreated });
|
||||||
|
|
||||||
|
if (!createdId) {
|
||||||
|
debug.warn('CalendarTask/create no id in server response');
|
||||||
|
debug.groupEnd();
|
||||||
|
throw new Error("Failed to create task — no id returned");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch back with task-specific properties
|
||||||
|
debug.log('CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] });
|
||||||
|
const getResponse = await this.request([
|
||||||
|
["CalendarEvent/get", {
|
||||||
|
accountId,
|
||||||
|
properties: [...CALENDAR_TASK_PROPERTIES],
|
||||||
|
ids: [createdId],
|
||||||
|
}, "0"]
|
||||||
|
], this.calendarUsing());
|
||||||
|
|
||||||
|
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||||
|
const list = getResponse.methodResponses[0][1].list || [];
|
||||||
|
const notFound = getResponse.methodResponses[0][1].notFound || [];
|
||||||
|
debug.log('CalendarTask/create get response', { found: list.length, notFound });
|
||||||
|
if (list[0]) {
|
||||||
|
const created = { ...list[0], '@type': 'Task' as const } as CalendarTask;
|
||||||
|
debug.log('CalendarTask/create final task object', {
|
||||||
|
id: created.id,
|
||||||
|
uid: created.uid,
|
||||||
|
'@type': created['@type'],
|
||||||
|
title: created.title,
|
||||||
|
due: created.due,
|
||||||
|
start: created.start,
|
||||||
|
progress: created.progress,
|
||||||
|
showWithoutTime: created.showWithoutTime,
|
||||||
|
calendarIds: created.calendarIds,
|
||||||
|
});
|
||||||
|
debug.groupEnd();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.warn('CalendarTask/create re-fetch returned nothing for id', createdId);
|
||||||
|
debug.groupEnd();
|
||||||
|
throw new Error("Failed to fetch created task");
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void> {
|
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void> {
|
||||||
@@ -3682,6 +4182,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
private pollingStates: { [key: string]: string } = {};
|
private pollingStates: { [key: string]: string } = {};
|
||||||
private sseAbortController: AbortController | null = null;
|
private sseAbortController: AbortController | null = null;
|
||||||
private sseReconnectTimeout: NodeJS.Timeout | null = null;
|
private sseReconnectTimeout: NodeJS.Timeout | null = null;
|
||||||
|
private ssePingTimer: NodeJS.Timeout | null = null;
|
||||||
|
private lastSSEActivity: number = 0;
|
||||||
|
private visibilityHandler: (() => void) | null = null;
|
||||||
|
private onlineHandler: (() => void) | null = null;
|
||||||
|
|
||||||
private static readonly STATE_TYPE_MAP: Record<string, string> = {
|
private static readonly STATE_TYPE_MAP: Record<string, string> = {
|
||||||
'Mailbox/get': 'Mailbox',
|
'Mailbox/get': 'Mailbox',
|
||||||
@@ -3693,6 +4197,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
private static readonly POLLING_INTERVAL = 3_000;
|
private static readonly POLLING_INTERVAL = 3_000;
|
||||||
private static readonly SSE_RECONNECT_DELAY = 3_000;
|
private static readonly SSE_RECONNECT_DELAY = 3_000;
|
||||||
|
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
|
||||||
|
|
||||||
setupPushNotifications(): boolean {
|
setupPushNotifications(): boolean {
|
||||||
const eventSourceUrl = this.getEventSourceUrl();
|
const eventSourceUrl = this.getEventSourceUrl();
|
||||||
@@ -3701,6 +4206,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
} else {
|
} else {
|
||||||
this.startPollingFallback();
|
this.startPollingFallback();
|
||||||
}
|
}
|
||||||
|
this.setupBrowserEventListeners();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3741,11 +4247,16 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
|
||||||
|
this.lastSSEActivity = Date.now();
|
||||||
|
this.startSSEPingMonitor();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
|
this.lastSSEActivity = Date.now();
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
const parts = buffer.split('\n\n');
|
const parts = buffer.split('\n\n');
|
||||||
buffer = parts.pop() || '';
|
buffer = parts.pop() || '';
|
||||||
@@ -3758,6 +4269,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.stopSSEPingMonitor();
|
||||||
|
|
||||||
// Stream ended — reconnect unless we were intentionally closed
|
// Stream ended — reconnect unless we were intentionally closed
|
||||||
if (this.sseAbortController && !this.sseAbortController.signal.aborted) {
|
if (this.sseAbortController && !this.sseAbortController.signal.aborted) {
|
||||||
this.scheduleSSEReconnect();
|
this.scheduleSSEReconnect();
|
||||||
@@ -3930,10 +4443,70 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
this.eventSource.close();
|
this.eventSource.close();
|
||||||
this.eventSource = null;
|
this.eventSource = null;
|
||||||
}
|
}
|
||||||
|
this.stopSSEPingMonitor();
|
||||||
|
this.cleanupBrowserEventListeners();
|
||||||
this.stateChangeCallback = null;
|
this.stateChangeCallback = null;
|
||||||
this.pollingStates = {};
|
this.pollingStates = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startSSEPingMonitor(): void {
|
||||||
|
this.stopSSEPingMonitor();
|
||||||
|
this.ssePingTimer = setInterval(() => {
|
||||||
|
if (Date.now() - this.lastSSEActivity > JMAPClient.SSE_PING_TIMEOUT) {
|
||||||
|
// SSE connection is stale — abort and reconnect
|
||||||
|
this.stopSSEPingMonitor();
|
||||||
|
if (this.sseAbortController) {
|
||||||
|
this.sseAbortController.abort();
|
||||||
|
this.sseAbortController = null;
|
||||||
|
}
|
||||||
|
this.scheduleSSEReconnect();
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopSSEPingMonitor(): void {
|
||||||
|
if (this.ssePingTimer) {
|
||||||
|
clearInterval(this.ssePingTimer);
|
||||||
|
this.ssePingTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupBrowserEventListeners(): void {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
this.visibilityHandler = () => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
// Tab became visible — immediately check for state changes
|
||||||
|
this.checkForStateChanges();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', this.visibilityHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
this.onlineHandler = () => {
|
||||||
|
// Network reconnected — reconnect SSE or force a poll
|
||||||
|
const eventSourceUrl = this.getEventSourceUrl();
|
||||||
|
if (eventSourceUrl && !this.sseAbortController) {
|
||||||
|
this.connectSSE(eventSourceUrl);
|
||||||
|
} else {
|
||||||
|
this.checkForStateChanges();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('online', this.onlineHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupBrowserEventListeners(): void {
|
||||||
|
if (this.visibilityHandler && typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
||||||
|
this.visibilityHandler = null;
|
||||||
|
}
|
||||||
|
if (this.onlineHandler && typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('online', this.onlineHandler);
|
||||||
|
this.onlineHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onConnectionChange(callback: (connected: boolean) => void): void {
|
onConnectionChange(callback: (connected: boolean) => void): void {
|
||||||
this.connectionChangeCallback = callback;
|
this.connectionChangeCallback = callback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -660,6 +660,7 @@ export interface CalendarEventFilter {
|
|||||||
attendee?: string;
|
attendee?: string;
|
||||||
participationStatus?: string;
|
participationStatus?: string;
|
||||||
uid?: string;
|
uid?: string;
|
||||||
|
types?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// JMAP Push Notification Types (RFC 8620 Section 7)
|
// JMAP Push Notification Types (RFC 8620 Section 7)
|
||||||
|
|||||||
+81
-1
@@ -11,11 +11,13 @@ import type {
|
|||||||
SidebarWidget,
|
SidebarWidget,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
KeyboardShortcut,
|
KeyboardShortcut,
|
||||||
|
AdminPageSection,
|
||||||
|
CalendarEventAction,
|
||||||
SlotName,
|
SlotName,
|
||||||
} from './plugin-types';
|
} from './plugin-types';
|
||||||
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
|
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
|
||||||
import {
|
import {
|
||||||
emailHooks, calendarHooks, contactHooks, fileHooks,
|
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
|
||||||
authHooks, settingsHooks, identityHooks, filterHooks,
|
authHooks, settingsHooks, identityHooks, filterHooks,
|
||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
@@ -116,6 +118,8 @@ export interface PluginAPI {
|
|||||||
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
|
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
|
||||||
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
|
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
|
||||||
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
|
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
|
||||||
|
registerCalendarEventAction: (action: CalendarEventAction) => Disposable;
|
||||||
|
registerAdminPage: (page: AdminPageSection) => Disposable;
|
||||||
};
|
};
|
||||||
hooks: PluginHooksAPI;
|
hooks: PluginHooksAPI;
|
||||||
toast: {
|
toast: {
|
||||||
@@ -126,6 +130,12 @@ export interface PluginAPI {
|
|||||||
};
|
};
|
||||||
storage: ReturnType<typeof createPluginStorage>;
|
storage: ReturnType<typeof createPluginStorage>;
|
||||||
log: ReturnType<typeof createPluginLogger>;
|
log: ReturnType<typeof createPluginLogger>;
|
||||||
|
admin: {
|
||||||
|
getConfig: (key: string) => Promise<unknown>;
|
||||||
|
getAllConfig: () => Promise<Record<string, unknown>>;
|
||||||
|
setConfig: (key: string, value: unknown) => Promise<void>;
|
||||||
|
deleteConfig: (key: string) => Promise<void>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simplified hooks API type (all hooks return Disposable)
|
// Simplified hooks API type (all hooks return Disposable)
|
||||||
@@ -176,6 +186,9 @@ export interface PluginHooksAPI {
|
|||||||
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
|
// Calendar Form
|
||||||
|
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
|
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
// Contacts
|
// Contacts
|
||||||
onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -321,6 +334,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
|
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
|
||||||
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
|
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
|
||||||
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
|
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
|
||||||
|
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
|
||||||
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
|
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
|
||||||
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
|
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
|
||||||
onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write',
|
onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write',
|
||||||
@@ -407,6 +421,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
|
|||||||
...Object.fromEntries(Object.entries(emailHooks)),
|
...Object.fromEntries(Object.entries(emailHooks)),
|
||||||
// Calendar
|
// Calendar
|
||||||
...Object.fromEntries(Object.entries(calendarHooks)),
|
...Object.fromEntries(Object.entries(calendarHooks)),
|
||||||
|
// Calendar Form
|
||||||
|
...Object.fromEntries(Object.entries(calendarFormHooks)),
|
||||||
// Contacts
|
// Contacts
|
||||||
...Object.fromEntries(Object.entries(contactHooks)),
|
...Object.fromEntries(Object.entries(contactHooks)),
|
||||||
// Files
|
// Files
|
||||||
@@ -581,6 +597,38 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
|||||||
requirePermission(plugin, 'ui:navigation-rail');
|
requirePermission(plugin, 'ui:navigation-rail');
|
||||||
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100);
|
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
registerCalendarEventAction: (action: CalendarEventAction) => {
|
||||||
|
requirePermission(plugin, 'ui:calendar-action');
|
||||||
|
const Component = (props: Record<string, unknown>) => {
|
||||||
|
const externals = getPluginExternals();
|
||||||
|
const React = externals?.React;
|
||||||
|
if (!React) return null;
|
||||||
|
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
|
||||||
|
const iconSpan = createElement('span', {
|
||||||
|
'aria-hidden': 'true',
|
||||||
|
style: { display: 'contents' },
|
||||||
|
dangerouslySetInnerHTML: {
|
||||||
|
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="8" width="14" height="12" rx="2"/></svg>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return createElement('button', {
|
||||||
|
onClick: () => action.onClick(
|
||||||
|
props.eventData as import('./plugin-types').CalendarEventFormView,
|
||||||
|
{ setVirtualLocation: props.setVirtualLocation as (url: string) => void },
|
||||||
|
),
|
||||||
|
className: 'inline-flex items-center gap-1.5 h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background cursor-pointer',
|
||||||
|
title: action.label,
|
||||||
|
type: 'button',
|
||||||
|
}, iconSpan, action.label);
|
||||||
|
};
|
||||||
|
return registerSlot(plugin.id, 'calendar-event-actions', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
registerAdminPage: (page: AdminPageSection) => {
|
||||||
|
requirePermission(plugin, 'ui:admin-page');
|
||||||
|
return registerSlot(plugin.id, 'admin-plugin-page', page.render as React.ComponentType<Record<string, unknown>>, 100);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
hooks,
|
hooks,
|
||||||
@@ -594,5 +642,37 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
|||||||
|
|
||||||
storage: createPluginStorage(plugin.id),
|
storage: createPluginStorage(plugin.id),
|
||||||
log: createPluginLogger(plugin.id),
|
log: createPluginLogger(plugin.id),
|
||||||
|
|
||||||
|
admin: {
|
||||||
|
getConfig: async (key: string) => {
|
||||||
|
requirePermission(plugin, 'admin:config');
|
||||||
|
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return data[key] ?? null;
|
||||||
|
},
|
||||||
|
getAllConfig: async () => {
|
||||||
|
requirePermission(plugin, 'admin:config');
|
||||||
|
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
|
||||||
|
if (!res.ok) return {};
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
setConfig: async (key: string, value: unknown) => {
|
||||||
|
requirePermission(plugin, 'admin:config');
|
||||||
|
await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteConfig: async (key: string) => {
|
||||||
|
requirePermission(plugin, 'admin:config');
|
||||||
|
await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -218,6 +218,12 @@ export const calendarHooks = {
|
|||||||
onCalendarAlertAcknowledge: new HookBus(),
|
onCalendarAlertAcknowledge: new HookBus(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// §7.2b Calendar Form Hooks (UI integration)
|
||||||
|
export const calendarFormHooks = {
|
||||||
|
onCalendarEventFormOpen: new HookBus(),
|
||||||
|
onCalendarEventFormSave: new HookBus(),
|
||||||
|
};
|
||||||
|
|
||||||
// §7.3 Contact Hooks
|
// §7.3 Contact Hooks
|
||||||
export const contactHooks = {
|
export const contactHooks = {
|
||||||
onContactOpen: new HookBus(),
|
onContactOpen: new HookBus(),
|
||||||
@@ -396,7 +402,7 @@ export const sidebarAppHooks = {
|
|||||||
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
||||||
|
|
||||||
const allHookGroups = [
|
const allHookGroups = [
|
||||||
emailHooks, calendarHooks, contactHooks, fileHooks,
|
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
|
||||||
authHooks, settingsHooks, identityHooks, filterHooks,
|
authHooks, settingsHooks, identityHooks, filterHooks,
|
||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
|
|||||||
+31
-1
@@ -94,7 +94,9 @@ export type SlotName =
|
|||||||
| 'email-detail-sidebar'
|
| 'email-detail-sidebar'
|
||||||
| 'settings-section'
|
| 'settings-section'
|
||||||
| 'context-menu-email'
|
| 'context-menu-email'
|
||||||
| 'navigation-rail-bottom';
|
| 'navigation-rail-bottom'
|
||||||
|
| 'calendar-event-actions'
|
||||||
|
| 'admin-plugin-page';
|
||||||
|
|
||||||
export interface SlotRegistration {
|
export interface SlotRegistration {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@@ -147,6 +149,32 @@ export interface ContextMenuItem {
|
|||||||
order?: number;
|
order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminPageSection {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
render: React.ComponentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEventAction {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
onClick: (eventData: CalendarEventFormView, helpers: { setVirtualLocation: (url: string) => void }) => void;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEventFormView {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
isAllDay: boolean;
|
||||||
|
location: string;
|
||||||
|
virtualLocation: string;
|
||||||
|
calendarId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KeyboardShortcut {
|
export interface KeyboardShortcut {
|
||||||
id: string;
|
id: string;
|
||||||
keys: string;
|
keys: string;
|
||||||
@@ -377,6 +405,8 @@ export const ALL_PERMISSIONS = [
|
|||||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
||||||
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
||||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||||
|
'ui:calendar-action', 'ui:admin-page',
|
||||||
|
'admin:config',
|
||||||
'app:lifecycle',
|
'app:lifecycle',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -148,4 +148,50 @@ describe('key-storage', () => {
|
|||||||
expect(certs.find(c => c.id === id)).toBeUndefined();
|
expect(certs.find(c => c.id === id)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('accountId filtering', () => {
|
||||||
|
it('listKeyRecords filters by accountId', async () => {
|
||||||
|
const id1 = uid();
|
||||||
|
const id2 = uid();
|
||||||
|
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
|
||||||
|
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
|
||||||
|
|
||||||
|
const acct1Records = await listKeyRecords('acct-1');
|
||||||
|
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
|
||||||
|
expect(acct1Records.find(r => r.id === id2)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listKeyRecords includes records without accountId when filtering', async () => {
|
||||||
|
const id1 = uid();
|
||||||
|
const id2 = uid();
|
||||||
|
await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com` }));
|
||||||
|
await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
|
||||||
|
|
||||||
|
const acct1Records = await listKeyRecords('acct-1');
|
||||||
|
expect(acct1Records.find(r => r.id === id1)).toBeDefined();
|
||||||
|
expect(acct1Records.find(r => r.id === id2)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listPublicCerts filters by accountId', async () => {
|
||||||
|
const id1 = uid();
|
||||||
|
const id2 = uid();
|
||||||
|
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' }));
|
||||||
|
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' }));
|
||||||
|
|
||||||
|
const acct1Certs = await listPublicCerts('acct-1');
|
||||||
|
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
|
||||||
|
expect(acct1Certs.find(c => c.id === id2)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listPublicCerts includes certs without accountId when filtering', async () => {
|
||||||
|
const id1 = uid();
|
||||||
|
const id2 = uid();
|
||||||
|
await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com` }));
|
||||||
|
await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' }));
|
||||||
|
|
||||||
|
const acct1Certs = await listPublicCerts('acct-1');
|
||||||
|
expect(acct1Certs.find(c => c.id === id1)).toBeDefined();
|
||||||
|
expect(acct1Certs.find(c => c.id === id2)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ beforeEach(() => {
|
|||||||
defaultSignIdentity: {},
|
defaultSignIdentity: {},
|
||||||
defaultEncrypt: false,
|
defaultEncrypt: false,
|
||||||
rememberUnlockedKeys: false,
|
rememberUnlockedKeys: false,
|
||||||
autoImportSignerCerts: false,
|
autoImportSignerCerts: true,
|
||||||
|
accountPreferences: {},
|
||||||
|
currentAccountId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -274,6 +274,12 @@ export function getLinerCryptoEngine(): pkijs.CryptoEngine {
|
|||||||
return linerEngine!;
|
return linerEngine!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the webcrypto-liner Crypto instance (for importKey with legacy algorithms). */
|
||||||
|
export function getLinerCrypto(): Crypto {
|
||||||
|
ensureLiner();
|
||||||
|
return linerCryptoInstance!;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run an async operation with the global PKI.js engine set to webcrypto-liner,
|
* Run an async operation with the global PKI.js engine set to webcrypto-liner,
|
||||||
* then restore the previous engine afterwards.
|
* then restore the previous engine afterwards.
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
import type { SmimeKeyRecord, SmimePublicCert } from './types';
|
import type { SmimeKeyRecord, SmimePublicCert } from './types';
|
||||||
|
|
||||||
const DB_NAME = 'smime-store';
|
const DB_NAME = 'smime-store';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 2;
|
||||||
const KEY_RECORDS_STORE = 'key-records';
|
const KEY_RECORDS_STORE = 'key-records';
|
||||||
const PUBLIC_CERTS_STORE = 'public-certs';
|
const PUBLIC_CERTS_STORE = 'public-certs';
|
||||||
|
|
||||||
function openDB(): Promise<IDBDatabase> {
|
function openDB(): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = (event) => {
|
||||||
const db = request.result;
|
const db = request.result;
|
||||||
if (!db.objectStoreNames.contains(KEY_RECORDS_STORE)) {
|
const oldVersion = event.oldVersion;
|
||||||
|
if (oldVersion < 1) {
|
||||||
const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
|
const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
|
||||||
keyStore.createIndex('email', 'email', { unique: false });
|
keyStore.createIndex('email', 'email', { unique: false });
|
||||||
}
|
keyStore.createIndex('accountId', 'accountId', { unique: false });
|
||||||
if (!db.objectStoreNames.contains(PUBLIC_CERTS_STORE)) {
|
|
||||||
const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
|
const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
|
||||||
certStore.createIndex('email', 'email', { unique: false });
|
certStore.createIndex('email', 'email', { unique: false });
|
||||||
|
certStore.createIndex('accountId', 'accountId', { unique: false });
|
||||||
|
}
|
||||||
|
if (oldVersion >= 1 && oldVersion < 2) {
|
||||||
|
// Add accountId index to existing stores
|
||||||
|
const tx = request.transaction!;
|
||||||
|
const keyStore = tx.objectStore(KEY_RECORDS_STORE);
|
||||||
|
if (!keyStore.indexNames.contains('accountId')) {
|
||||||
|
keyStore.createIndex('accountId', 'accountId', { unique: false });
|
||||||
|
}
|
||||||
|
const certStore = tx.objectStore(PUBLIC_CERTS_STORE);
|
||||||
|
if (!certStore.indexNames.contains('accountId')) {
|
||||||
|
certStore.createIndex('accountId', 'accountId', { unique: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
@@ -62,9 +75,11 @@ export async function getKeyRecordForEmail(email: string): Promise<SmimeKeyRecor
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listKeyRecords(): Promise<SmimeKeyRecord[]> {
|
export async function listKeyRecords(accountId?: string): Promise<SmimeKeyRecord[]> {
|
||||||
const db = await openDB();
|
const db = await openDB();
|
||||||
return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
|
const all = await txPromise<SmimeKeyRecord[]>(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
|
||||||
|
if (!accountId) return all;
|
||||||
|
return all.filter((r) => r.accountId === accountId || !r.accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteKeyRecord(id: string): Promise<void> {
|
export async function deleteKeyRecord(id: string): Promise<void> {
|
||||||
@@ -90,9 +105,11 @@ export async function getPublicCertForEmail(email: string): Promise<SmimePublicC
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPublicCerts(): Promise<SmimePublicCert[]> {
|
export async function listPublicCerts(accountId?: string): Promise<SmimePublicCert[]> {
|
||||||
const db = await openDB();
|
const db = await openDB();
|
||||||
return txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
|
const all = await txPromise<SmimePublicCert[]>(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
|
||||||
|
if (!accountId) return all;
|
||||||
|
return all.filter((c) => c.accountId === accountId || !c.accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePublicCert(id: string): Promise<void> {
|
export async function deletePublicCert(id: string): Promise<void> {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
classifyCapabilities,
|
classifyCapabilities,
|
||||||
} from './certificate-utils';
|
} from './certificate-utils';
|
||||||
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
|
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
|
||||||
import { withLinerEngine } from './crypto-engine';
|
import { withLinerEngine, getLinerCrypto } from './crypto-engine';
|
||||||
|
|
||||||
const KDF_ITERATIONS = 600_000;
|
const KDF_ITERATIONS = 600_000;
|
||||||
const AES_KEY_LENGTH = 256;
|
const AES_KEY_LENGTH = 256;
|
||||||
@@ -217,6 +217,8 @@ async function encryptPrivateKey(
|
|||||||
export interface UnlockedKeyPair {
|
export interface UnlockedKeyPair {
|
||||||
signingKey: CryptoKey;
|
signingKey: CryptoKey;
|
||||||
decryptionKey?: CryptoKey;
|
decryptionKey?: CryptoKey;
|
||||||
|
/** Key imported via webcrypto-liner as RSAES-PKCS1-v1_5 for legacy S/MIME (3DES) messages */
|
||||||
|
legacyDecryptionKey?: CryptoKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys for signing and decryption. */
|
/** Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys for signing and decryption. */
|
||||||
@@ -257,7 +259,22 @@ export async function unlockPrivateKey(
|
|||||||
} catch {
|
} catch {
|
||||||
// Key may only support decryption (key-encipherment-only cert)
|
// Key may only support decryption (key-encipherment-only cert)
|
||||||
const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
|
const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
|
||||||
return { signingKey: decryptionKey, decryptionKey };
|
let legacyDecryptionKey: CryptoKey | undefined;
|
||||||
|
if (!isEcdsa) {
|
||||||
|
try {
|
||||||
|
const linerCrypto = getLinerCrypto();
|
||||||
|
legacyDecryptionKey = await linerCrypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
pkcs8Bytes,
|
||||||
|
{ name: 'RSAES-PKCS1-v1_5' },
|
||||||
|
false,
|
||||||
|
['decrypt'],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// webcrypto-liner may not be available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { signingKey: decryptionKey, decryptionKey, legacyDecryptionKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also import for decryption (separate CryptoKey handle required by Web Crypto)
|
// Also import for decryption (separate CryptoKey handle required by Web Crypto)
|
||||||
@@ -268,7 +285,29 @@ export async function unlockPrivateKey(
|
|||||||
// Key may only support signing (digitalSignature-only cert)
|
// Key may only support signing (digitalSignature-only cert)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { signingKey, decryptionKey };
|
// Import a legacy decryption key via webcrypto-liner for RSAES-PKCS1-v1_5 key transport
|
||||||
|
// (used by older S/MIME messages encrypted with 3DES, RC2, etc.)
|
||||||
|
let legacyDecryptionKey: CryptoKey | undefined;
|
||||||
|
if (!isEcdsa) {
|
||||||
|
try {
|
||||||
|
const linerCrypto = getLinerCrypto();
|
||||||
|
legacyDecryptionKey = await linerCrypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
pkcs8Bytes,
|
||||||
|
{ name: 'RSAES-PKCS1-v1_5' },
|
||||||
|
false,
|
||||||
|
['decrypt'],
|
||||||
|
);
|
||||||
|
console.debug('[S/MIME] legacy RSAES-PKCS1-v1_5 key imported successfully:', {
|
||||||
|
algorithm: legacyDecryptionKey.algorithm,
|
||||||
|
usages: legacyDecryptionKey.usages,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[S/MIME] legacy RSAES-PKCS1-v1_5 key import failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { signingKey, decryptionKey, legacyDecryptionKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get decrypted PKCS#8 bytes (for export flow). */
|
/** Get decrypted PKCS#8 bytes (for export flow). */
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ export interface DecryptionInput {
|
|||||||
cmsBytes: ArrayBuffer;
|
cmsBytes: ArrayBuffer;
|
||||||
/** All imported key records to try matching against */
|
/** All imported key records to try matching against */
|
||||||
keyRecords: SmimeKeyRecord[];
|
keyRecords: SmimeKeyRecord[];
|
||||||
/** Unlocked CryptoKey map: keyRecordId → CryptoKey */
|
/** Unlocked CryptoKey map: keyRecordId → CryptoKey (RSA-OAEP) */
|
||||||
unlockedKeys: Map<string, CryptoKey>;
|
unlockedKeys: Map<string, CryptoKey>;
|
||||||
|
/** Unlocked legacy CryptoKey map: keyRecordId → CryptoKey (RSAES-PKCS1-v1_5 via webcrypto-liner) */
|
||||||
|
legacyUnlockedKeys?: Map<string, CryptoKey>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecryptionResult {
|
export interface DecryptionResult {
|
||||||
@@ -34,12 +36,24 @@ export interface DecryptionResult {
|
|||||||
* @throws Error if no matching key is found, key is locked, or decryption fails
|
* @throws Error if no matching key is found, key is locked, or decryption fails
|
||||||
*/
|
*/
|
||||||
export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> {
|
export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> {
|
||||||
const { cmsBytes, keyRecords, unlockedKeys } = input;
|
const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input;
|
||||||
|
|
||||||
// Parse the CMS ContentInfo wrapper
|
// Parse the CMS ContentInfo wrapper
|
||||||
const contentInfo = parseContentInfo(cmsBytes);
|
const contentInfo = parseContentInfo(cmsBytes);
|
||||||
const envelopedData = extractEnvelopedData(contentInfo);
|
const envelopedData = extractEnvelopedData(contentInfo);
|
||||||
|
|
||||||
|
// Log CMS algorithm details for diagnostics
|
||||||
|
const contentEncOid = envelopedData.encryptedContentInfo?.contentEncryptionAlgorithm?.algorithmId;
|
||||||
|
const recipientAlgs = envelopedData.recipientInfos?.map((ri) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(ri as any).value?.keyEncryptionAlgorithm?.algorithmId as string | undefined,
|
||||||
|
);
|
||||||
|
console.debug('[S/MIME] CMS algorithms:', {
|
||||||
|
contentEncryption: contentEncOid,
|
||||||
|
keyTransport: recipientAlgs,
|
||||||
|
legacyKeysAvailable: legacyUnlockedKeys?.size ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
// Find matching key records
|
// Find matching key records
|
||||||
const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords);
|
const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords);
|
||||||
|
|
||||||
@@ -51,6 +65,19 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
|
|||||||
for (const { keyRecord, recipientIndex } of matchedRecords) {
|
for (const { keyRecord, recipientIndex } of matchedRecords) {
|
||||||
const privateKey = unlockedKeys.get(keyRecord.id);
|
const privateKey = unlockedKeys.get(keyRecord.id);
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
|
// Try legacy key (RSAES-PKCS1-v1_5) if no RSA-OAEP key
|
||||||
|
const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
|
||||||
|
if (legacyKey) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
|
||||||
|
return {
|
||||||
|
mimeBytes: new Uint8Array(decrypted),
|
||||||
|
keyRecordId: keyRecord.id,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
continue; // Key exists but isn't unlocked — skip, caller should unlock first
|
continue; // Key exists but isn't unlocked — skip, caller should unlock first
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,16 +87,32 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
|
|||||||
mimeBytes: new Uint8Array(decrypted),
|
mimeBytes: new Uint8Array(decrypted),
|
||||||
keyRecordId: keyRecord.id,
|
keyRecordId: keyRecord.id,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (oaepError) {
|
||||||
// This key didn't work, try the next one
|
// RSA-OAEP key didn't work, try legacy RSAES-PKCS1-v1_5 key
|
||||||
|
console.debug('[S/MIME] RSA-OAEP decrypt failed:', oaepError instanceof Error ? oaepError.message : oaepError);
|
||||||
|
const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
|
||||||
|
console.debug('[S/MIME] legacy key available:', !!legacyKey, legacyKey ? { algorithm: (legacyKey as CryptoKey).algorithm } : undefined);
|
||||||
|
if (legacyKey) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
|
||||||
|
return {
|
||||||
|
mimeBytes: new Uint8Array(decrypted),
|
||||||
|
keyRecordId: keyRecord.id,
|
||||||
|
};
|
||||||
|
} catch (legacyError) {
|
||||||
|
// Legacy key also didn't work, try the next record
|
||||||
|
console.debug('[S/MIME] RSAES-PKCS1-v1_5 decrypt also failed:', legacyError instanceof Error ? legacyError.message : legacyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we had matching records but none were unlocked
|
// Check if we had matching records but none were unlocked
|
||||||
const hasLockedMatch = matchedRecords.some(m => !unlockedKeys.has(m.keyRecord.id));
|
const isUnlocked = (id: string) => unlockedKeys.has(id) || (legacyUnlockedKeys?.has(id) ?? false);
|
||||||
|
const hasLockedMatch = matchedRecords.some(m => !isUnlocked(m.keyRecord.id));
|
||||||
if (hasLockedMatch) {
|
if (hasLockedMatch) {
|
||||||
const lockedRecord = matchedRecords.find(m => !unlockedKeys.has(m.keyRecord.id))!;
|
const lockedRecord = matchedRecords.find(m => !isUnlocked(m.keyRecord.id))!;
|
||||||
throw new SmimeKeyLockedError(
|
throw new SmimeKeyLockedError(
|
||||||
'S/MIME key is locked. Unlock it to decrypt this message.',
|
'S/MIME key is locked. Unlock it to decrypt this message.',
|
||||||
lockedRecord.keyRecord.id,
|
lockedRecord.keyRecord.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/** Stored record for an imported S/MIME private key + certificate. */
|
/** Stored record for an imported S/MIME private key + certificate. */
|
||||||
export interface SmimeKeyRecord {
|
export interface SmimeKeyRecord {
|
||||||
id: string;
|
id: string;
|
||||||
|
accountId?: string;
|
||||||
email: string;
|
email: string;
|
||||||
certificate: ArrayBuffer; // DER-encoded X.509 leaf cert
|
certificate: ArrayBuffer; // DER-encoded X.509 leaf cert
|
||||||
certificateChain: ArrayBuffer[]; // DER-encoded intermediates
|
certificateChain: ArrayBuffer[]; // DER-encoded intermediates
|
||||||
@@ -34,6 +35,7 @@ export interface SmimeUnlockedKey {
|
|||||||
/** A recipient or contact public certificate. */
|
/** A recipient or contact public certificate. */
|
||||||
export interface SmimePublicCert {
|
export interface SmimePublicCert {
|
||||||
id: string;
|
id: string;
|
||||||
|
accountId?: string;
|
||||||
email: string;
|
email: string;
|
||||||
certificate: ArrayBuffer; // DER-encoded X.509
|
certificate: ArrayBuffer; // DER-encoded X.509
|
||||||
issuer: string;
|
issuer: string;
|
||||||
|
|||||||
+56
-1
@@ -1,6 +1,7 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { Mailbox } from "./jmap/types";
|
import { Mailbox } from "./jmap/types";
|
||||||
|
import { debug } from "./debug";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
@@ -98,6 +99,7 @@ const ROLE_PRIORITY: Record<string, number> = {
|
|||||||
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
|
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
|
||||||
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
||||||
const result: Mailbox[] = [];
|
const result: Mailbox[] = [];
|
||||||
|
const removed: { id: string; name: string; matchedRole: string; parentId?: string }[] = [];
|
||||||
|
|
||||||
// Group role mailboxes by account so deduplication is scoped per-account
|
// Group role mailboxes by account so deduplication is scoped per-account
|
||||||
const rolesByAccount = new Map<string, Mailbox[]>();
|
const rolesByAccount = new Map<string, Mailbox[]>();
|
||||||
@@ -109,6 +111,12 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build a set of IDs that are referenced as parents
|
||||||
|
const referencedParentIds = new Set<string>();
|
||||||
|
mailboxes.forEach(mb => {
|
||||||
|
if (mb.parentId) referencedParentIds.add(mb.parentId);
|
||||||
|
});
|
||||||
|
|
||||||
// Filter out duplicates scoped to the same account
|
// Filter out duplicates scoped to the same account
|
||||||
mailboxes.forEach(mb => {
|
mailboxes.forEach(mb => {
|
||||||
// If this mailbox has a role, always keep it
|
// If this mailbox has a role, always keep it
|
||||||
@@ -121,26 +129,47 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
|||||||
const accountKey = mb.accountId || '';
|
const accountKey = mb.accountId || '';
|
||||||
const accountRoles = rolesByAccount.get(accountKey) || [];
|
const accountRoles = rolesByAccount.get(accountKey) || [];
|
||||||
const lowerName = mb.name.toLowerCase();
|
const lowerName = mb.name.toLowerCase();
|
||||||
const isDuplicate = accountRoles.some(roleMb => {
|
const matchedRole = accountRoles.find(roleMb => {
|
||||||
const roleLowerName = roleMb.name.toLowerCase();
|
const roleLowerName = roleMb.name.toLowerCase();
|
||||||
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
|
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
|
||||||
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
|
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
|
||||||
});
|
});
|
||||||
|
const isDuplicate = !!matchedRole;
|
||||||
|
|
||||||
// Only keep if not a duplicate
|
// Only keep if not a duplicate
|
||||||
if (!isDuplicate) {
|
if (!isDuplicate) {
|
||||||
result.push(mb);
|
result.push(mb);
|
||||||
|
} else {
|
||||||
|
removed.push({ id: mb.id, name: mb.name, matchedRole: matchedRole!.name, parentId: mb.parentId });
|
||||||
|
// Warn if this removed mailbox is a parent of other mailboxes (orphan risk)
|
||||||
|
if (referencedParentIds.has(mb.id)) {
|
||||||
|
debug.warn(
|
||||||
|
`[Mailbox Tree] Deduplication removed mailbox "${mb.name}" (id: ${mb.id}) which is a parent of other mailboxes. ` +
|
||||||
|
`Matched role mailbox: "${matchedRole!.name}" (role: ${matchedRole!.role}). ` +
|
||||||
|
`Children referencing parentId "${mb.id}" will be orphaned to root level.`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (removed.length > 0) {
|
||||||
|
debug.log(`[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a hierarchical tree structure from flat mailbox array
|
// Build a hierarchical tree structure from flat mailbox array
|
||||||
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||||
|
debug.log(`[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`);
|
||||||
|
|
||||||
// Deduplicate mailboxes first
|
// Deduplicate mailboxes first
|
||||||
const deduplicated = deduplicateMailboxes(mailboxes);
|
const deduplicated = deduplicateMailboxes(mailboxes);
|
||||||
|
|
||||||
|
if (deduplicated.length !== mailboxes.length) {
|
||||||
|
debug.log(`[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
// Separate own and shared mailboxes
|
// Separate own and shared mailboxes
|
||||||
const ownMailboxes = deduplicated.filter(mb => !mb.isShared);
|
const ownMailboxes = deduplicated.filter(mb => !mb.isShared);
|
||||||
const sharedMailboxes = deduplicated.filter(mb => mb.isShared);
|
const sharedMailboxes = deduplicated.filter(mb => mb.isShared);
|
||||||
@@ -168,6 +197,7 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Second pass: build tree structure for own mailboxes
|
// Second pass: build tree structure for own mailboxes
|
||||||
|
const orphanedMailboxes: { id: string; name: string; parentId: string }[] = [];
|
||||||
ownMailboxes.forEach(mailbox => {
|
ownMailboxes.forEach(mailbox => {
|
||||||
const node = mailboxMap.get(mailbox.id)!;
|
const node = mailboxMap.get(mailbox.id)!;
|
||||||
|
|
||||||
@@ -176,13 +206,38 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
|||||||
parent.children.push(node);
|
parent.children.push(node);
|
||||||
} else {
|
} else {
|
||||||
// Root level mailbox or orphaned mailbox
|
// Root level mailbox or orphaned mailbox
|
||||||
|
if (mailbox.parentId) {
|
||||||
|
orphanedMailboxes.push({ id: mailbox.id, name: mailbox.name, parentId: mailbox.parentId });
|
||||||
|
}
|
||||||
rootMailboxes.push(node);
|
rootMailboxes.push(node);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (orphanedMailboxes.length > 0) {
|
||||||
|
debug.warn(
|
||||||
|
`[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`,
|
||||||
|
orphanedMailboxes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Third pass: correctly calculate depths from the root down
|
// Third pass: correctly calculate depths from the root down
|
||||||
recalculateDepths(rootMailboxes, 0);
|
recalculateDepths(rootMailboxes, 0);
|
||||||
|
|
||||||
|
// Log tree depth statistics
|
||||||
|
const maxDepth = (nodes: MailboxNode[]): number => {
|
||||||
|
let max = 0;
|
||||||
|
for (const node of nodes) {
|
||||||
|
max = Math.max(max, node.depth);
|
||||||
|
if (node.children.length > 0) max = Math.max(max, maxDepth(node.children));
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
};
|
||||||
|
debug.log(
|
||||||
|
`[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` +
|
||||||
|
`max depth: ${maxDepth(rootMailboxes)}, ` +
|
||||||
|
`total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}`
|
||||||
|
);
|
||||||
|
|
||||||
// If we have shared mailboxes, create a virtual "Shared Folders" parent
|
// If we have shared mailboxes, create a virtual "Shared Folders" parent
|
||||||
if (sharedMailboxes.length > 0) {
|
if (sharedMailboxes.length > 0) {
|
||||||
// Group shared mailboxes by account
|
// Group shared mailboxes by account
|
||||||
|
|||||||
+47
-4
@@ -5,6 +5,9 @@
|
|||||||
"username_placeholder": "benutzer@beispiel.de",
|
"username_placeholder": "benutzer@beispiel.de",
|
||||||
"password_label": "Passwort",
|
"password_label": "Passwort",
|
||||||
"password_placeholder": "Geben Sie Ihr Passwort ein",
|
"password_placeholder": "Geben Sie Ihr Passwort ein",
|
||||||
|
"jmap_endpoint_label": "JMAP-Server",
|
||||||
|
"jmap_endpoint_placeholder": "https://mail.example.com",
|
||||||
|
"jmap_endpoint_cors_hint": "Der Server muss CORS-Anfragen von dieser Domain zulassen.",
|
||||||
"sign_in": "Anmelden",
|
"sign_in": "Anmelden",
|
||||||
"signing_in": "Wird angemeldet...",
|
"signing_in": "Wird angemeldet...",
|
||||||
"loading": "Lädt...",
|
"loading": "Lädt...",
|
||||||
@@ -115,6 +118,7 @@
|
|||||||
"demo_reset": "Zurücksetzen",
|
"demo_reset": "Zurücksetzen",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
|
"folders": "Ordner",
|
||||||
"mail": "E-Mail",
|
"mail": "E-Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
"add_app": "Apps"
|
"add_app": "Apps"
|
||||||
@@ -196,6 +200,8 @@
|
|||||||
"no_email_description": "Wählen Sie eine E-Mail aus der Liste aus, um sie hier anzuzeigen",
|
"no_email_description": "Wählen Sie eine E-Mail aus der Liste aus, um sie hier anzuzeigen",
|
||||||
"no_conversation_selected": "Keine Unterhaltung ausgewählt",
|
"no_conversation_selected": "Keine Unterhaltung ausgewählt",
|
||||||
"no_conversation_description": "Wählen Sie eine Unterhaltung aus der Liste aus, um sie hier zu lesen",
|
"no_conversation_description": "Wählen Sie eine Unterhaltung aus der Liste aus, um sie hier zu lesen",
|
||||||
|
"compose": "Verfassen",
|
||||||
|
"compose_hint": "Neue Nachricht verfassen",
|
||||||
"no_subject": "(Kein Betreff)",
|
"no_subject": "(Kein Betreff)",
|
||||||
"loading_email": "E-Mail wird geladen...",
|
"loading_email": "E-Mail wird geladen...",
|
||||||
"loading": "Lädt...",
|
"loading": "Lädt...",
|
||||||
@@ -477,7 +483,7 @@
|
|||||||
"smime_sign_off": "S/MIME-Signatur deaktiviert",
|
"smime_sign_off": "S/MIME-Signatur deaktiviert",
|
||||||
"smime_encrypt_on": "S/MIME-Verschlüsselung aktiviert",
|
"smime_encrypt_on": "S/MIME-Verschlüsselung aktiviert",
|
||||||
"smime_encrypt_off": "S/MIME-Verschlüsselung deaktiviert",
|
"smime_encrypt_off": "S/MIME-Verschlüsselung deaktiviert",
|
||||||
"smime_encrypt_unavailable": "S/MIME-Verschlüsselung nicht möglich: {reason}",
|
"smime_encrypt_unavailable": "S/MIME-Verschlüsselung nicht möglich – fehlende Empfängerzertifikate",
|
||||||
"smime_unlock_title": "S/MIME-Schlüssel entsperren",
|
"smime_unlock_title": "S/MIME-Schlüssel entsperren",
|
||||||
"smime_unlock_message": "Geben Sie die Passphrase ein, um Ihren S/MIME-Schlüssel zu entsperren.",
|
"smime_unlock_message": "Geben Sie die Passphrase ein, um Ihren S/MIME-Schlüssel zu entsperren.",
|
||||||
"smime_unlock_button": "Entsperren",
|
"smime_unlock_button": "Entsperren",
|
||||||
@@ -683,6 +689,14 @@
|
|||||||
"toolbar_labels": {
|
"toolbar_labels": {
|
||||||
"label": "Show Toolbar Labels",
|
"label": "Show Toolbar Labels",
|
||||||
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
||||||
|
},
|
||||||
|
"hide_account_switcher": {
|
||||||
|
"label": "Kontoumschalter in der Seitenleiste ausblenden",
|
||||||
|
"description": "Den Kontowähler oben in der Ordner-Seitenleiste ausblenden. Sie können weiterhin über die untere Navigationsleiste zwischen Konten wechseln."
|
||||||
|
},
|
||||||
|
"show_rail_account_list": {
|
||||||
|
"label": "Konto-Avatare in der Navigationsleiste anzeigen",
|
||||||
|
"description": "Individuelle Kontokreise am unteren Rand der Navigationsleiste für schnelles Umschalten anzeigen, mit einer Abmelde-Schaltfläche darunter."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
@@ -799,9 +813,18 @@
|
|||||||
"label": "Spam dauerhaft löschen",
|
"label": "Spam dauerhaft löschen",
|
||||||
"description": "E-Mails aus dem Spam-Ordner dauerhaft löschen, anstatt sie in den Papierkorb zu verschieben"
|
"description": "E-Mails aus dem Spam-Ordner dauerhaft löschen, anstatt sie in den Papierkorb zu verschieben"
|
||||||
},
|
},
|
||||||
|
"mail_layout": {
|
||||||
|
"label": "Mail-Layout",
|
||||||
|
"description": "Wählen Sie zwischen dem klassischen geteilten Lesebereich und einem fokussierten Lesefluss im Gmail-Stil.",
|
||||||
|
"split": "Geteilter Bereich",
|
||||||
|
"split_description": "Nachrichtenliste und Lesebereich nebeneinander sichtbar halten.",
|
||||||
|
"focus": "Fokussierte Liste",
|
||||||
|
"focus_description": "Eine Zeile pro Nachricht anzeigen und E-Mails in voller Breite öffnen, während die Ordner-Seitenleiste sichtbar bleibt."
|
||||||
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Vorschautext anzeigen",
|
"label": "Vorschautext anzeigen",
|
||||||
"description": "E-Mail-Vorschau in der Liste anzeigen"
|
"description": "E-Mail-Vorschau in der Liste anzeigen",
|
||||||
|
"focus_description": "Inline-Vorschautext in der fokussierten einzeiligen Nachrichtenliste anzeigen"
|
||||||
},
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Konversationsgruppierung deaktivieren",
|
"label": "Konversationsgruppierung deaktivieren",
|
||||||
@@ -811,6 +834,10 @@
|
|||||||
"label": "Nur Klartext",
|
"label": "Nur Klartext",
|
||||||
"description": "Rich-Text-Editor deaktivieren und alle E-Mails nur als Klartext senden, einschließlich Antworten und Weiterleitungen"
|
"description": "Rich-Text-Editor deaktivieren und alle E-Mails nur als Klartext senden, einschließlich Antworten und Weiterleitungen"
|
||||||
},
|
},
|
||||||
|
"auto_select_reply_identity": {
|
||||||
|
"label": "Antwortadresse automatisch wählen",
|
||||||
|
"description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat"
|
||||||
|
},
|
||||||
"attachment_click_action": {
|
"attachment_click_action": {
|
||||||
"label": "Aktion beim Klick auf Anhänge",
|
"label": "Aktion beim Klick auf Anhänge",
|
||||||
"description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird",
|
"description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird",
|
||||||
@@ -1023,6 +1050,14 @@
|
|||||||
"enabled": "Verschlüsselung im Ruhezustand aktiviert",
|
"enabled": "Verschlüsselung im Ruhezustand aktiviert",
|
||||||
"disabled_success": "Verschlüsselung im Ruhezustand deaktiviert",
|
"disabled_success": "Verschlüsselung im Ruhezustand deaktiviert",
|
||||||
"error": "Verschlüsselungseinstellungen konnten nicht aktualisiert werden"
|
"error": "Verschlüsselungseinstellungen konnten nicht aktualisiert werden"
|
||||||
|
},
|
||||||
|
"email_client": {
|
||||||
|
"title": "E-Mail-Client-Einrichtung",
|
||||||
|
"description": "Verwenden Sie diese Anmeldedaten, um Ihren Desktop- oder Mobil-E-Mail-Client zu konfigurieren (Thunderbird, Apple Mail, Outlook usw.)",
|
||||||
|
"jmap_username_label": "JMAP-Benutzername",
|
||||||
|
"copy": "Kopieren",
|
||||||
|
"copied": "Kopiert",
|
||||||
|
"password_instructions": "Verwenden Sie Ihren oben angezeigten JMAP-Benutzernamen zusammen mit einem App-Passwort, um sich bei Ihrem E-Mail-Client anzumelden. Erstellen Sie im obigen Abschnitt ein App-Passwort, falls Sie noch keines haben."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"identities": {
|
"identities": {
|
||||||
@@ -1859,6 +1894,7 @@
|
|||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
"description": "Beschreibung",
|
"description": "Beschreibung",
|
||||||
"location": "Ort",
|
"location": "Ort",
|
||||||
|
"meeting_link": "Meeting-Link",
|
||||||
"start_date": "Startdatum",
|
"start_date": "Startdatum",
|
||||||
"end_date": "Enddatum",
|
"end_date": "Enddatum",
|
||||||
"start_time": "Startzeit",
|
"start_time": "Startzeit",
|
||||||
@@ -1946,7 +1982,14 @@
|
|||||||
"enable_tasks": "Aufgaben aktivieren",
|
"enable_tasks": "Aufgaben aktivieren",
|
||||||
"enable_tasks_desc": "Eine Aufgabenansicht im Kalender zum Verwalten von To-dos anzeigen",
|
"enable_tasks_desc": "Eine Aufgabenansicht im Kalender zum Verwalten von To-dos anzeigen",
|
||||||
"show_tasks_on_calendar": "Aufgaben im Kalender anzeigen",
|
"show_tasks_on_calendar": "Aufgaben im Kalender anzeigen",
|
||||||
"show_tasks_on_calendar_desc": "Aufgabenmarkierungen in der Tages- und Wochenansicht anzeigen"
|
"show_tasks_on_calendar_desc": "Aufgabenmarkierungen in der Tages- und Wochenansicht anzeigen",
|
||||||
|
"hover_preview": "Ereignis-Hover-Vorschau",
|
||||||
|
"hover_preview_desc": "Ein Detail-Popover anzeigen, wenn über Kalender-Ereignisse gehovert wird",
|
||||||
|
"hover_preview_instant": "Sofort",
|
||||||
|
"hover_preview_delay_500ms": "0,5 Sekunden Verzögerung",
|
||||||
|
"hover_preview_delay_1s": "1 Sekunde Verzögerung",
|
||||||
|
"hover_preview_delay_2s": "2 Sekunden Verzögerung",
|
||||||
|
"hover_preview_off": "Deaktiviert"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Montag",
|
"monday": "Montag",
|
||||||
@@ -2015,7 +2058,7 @@
|
|||||||
"importing": "Termine werden importiert...",
|
"importing": "Termine werden importiert...",
|
||||||
"success": "{count} Termine erfolgreich importiert",
|
"success": "{count} Termine erfolgreich importiert",
|
||||||
"error": "Kalender konnte nicht importiert werden",
|
"error": "Kalender konnte nicht importiert werden",
|
||||||
"file_too_large": "Datei überschreitet das 5-MB-Limit",
|
"file_too_large": "Datei überschreitet das 10-MB-Limit",
|
||||||
"invalid_format": "Ungültiges Kalenderdateiformat"
|
"invalid_format": "Ungültiges Kalenderdateiformat"
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
+45
-3
@@ -5,6 +5,9 @@
|
|||||||
"username_placeholder": "user@example.com",
|
"username_placeholder": "user@example.com",
|
||||||
"password_label": "Password",
|
"password_label": "Password",
|
||||||
"password_placeholder": "Enter your password",
|
"password_placeholder": "Enter your password",
|
||||||
|
"jmap_endpoint_label": "JMAP Server",
|
||||||
|
"jmap_endpoint_placeholder": "https://mail.example.com",
|
||||||
|
"jmap_endpoint_cors_hint": "The server must allow CORS requests from this domain.",
|
||||||
"sign_in": "Sign in",
|
"sign_in": "Sign in",
|
||||||
"signing_in": "Signing in...",
|
"signing_in": "Signing in...",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -15,6 +18,7 @@
|
|||||||
"cors_blocked": "The server is reachable but is blocking cross-origin requests. Check your JMAP server's CORS settings and allow this domain.",
|
"cors_blocked": "The server is reachable but is blocking cross-origin requests. Check your JMAP server's CORS settings and allow this domain.",
|
||||||
"server_error": "The server is temporarily unavailable. Please try again later.",
|
"server_error": "The server is temporarily unavailable. Please try again later.",
|
||||||
"generic": "An unexpected error occurred. If this persists, contact your administrator.",
|
"generic": "An unexpected error occurred. If this persists, contact your administrator.",
|
||||||
|
"totp_required": "A two-factor authentication code is required. Please enter your code below.",
|
||||||
"totp_invalid": "Invalid authentication code. Please check your authenticator app and try again.",
|
"totp_invalid": "Invalid authentication code. Please check your authenticator app and try again.",
|
||||||
"oauth_discovery_failed": "SSO is enabled but the identity provider could not be reached. Check your OAuth configuration."
|
"oauth_discovery_failed": "SSO is enabled but the identity provider could not be reached. Check your OAuth configuration."
|
||||||
},
|
},
|
||||||
@@ -115,6 +119,7 @@
|
|||||||
"demo_reset": "Reset",
|
"demo_reset": "Reset",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
|
"folders": "Folders",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
"add_app": "Apps"
|
"add_app": "Apps"
|
||||||
@@ -196,6 +201,8 @@
|
|||||||
"no_email_description": "Select an email from the list to view it here",
|
"no_email_description": "Select an email from the list to view it here",
|
||||||
"no_conversation_selected": "No conversation selected",
|
"no_conversation_selected": "No conversation selected",
|
||||||
"no_conversation_description": "Choose a conversation from the list to read it here",
|
"no_conversation_description": "Choose a conversation from the list to read it here",
|
||||||
|
"compose": "Compose",
|
||||||
|
"compose_hint": "Compose new message",
|
||||||
"no_subject": "(No Subject)",
|
"no_subject": "(No Subject)",
|
||||||
"loading_email": "Loading email...",
|
"loading_email": "Loading email...",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -683,6 +690,14 @@
|
|||||||
"toolbar_labels": {
|
"toolbar_labels": {
|
||||||
"label": "Show Toolbar Labels",
|
"label": "Show Toolbar Labels",
|
||||||
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
||||||
|
},
|
||||||
|
"hide_account_switcher": {
|
||||||
|
"label": "Hide Sidebar Account Switcher",
|
||||||
|
"description": "Hide the account selector at the top of the folder sidebar. You can still switch accounts from the bottom navigation rail."
|
||||||
|
},
|
||||||
|
"show_rail_account_list": {
|
||||||
|
"label": "Show Account Avatars on Navigation Rail",
|
||||||
|
"description": "Display individual account circles at the bottom of the navigation rail for quick switching, with a sign-out button below."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
@@ -799,9 +814,18 @@
|
|||||||
"label": "Permanently Delete Junk",
|
"label": "Permanently Delete Junk",
|
||||||
"description": "Permanently delete emails from the Junk/Spam folder instead of moving them to Trash"
|
"description": "Permanently delete emails from the Junk/Spam folder instead of moving them to Trash"
|
||||||
},
|
},
|
||||||
|
"mail_layout": {
|
||||||
|
"label": "Mail Layout",
|
||||||
|
"description": "Choose between the classic split reading pane and a Gmail-style focused reading flow.",
|
||||||
|
"split": "Split pane",
|
||||||
|
"split_description": "Keep the message list and reading pane visible side by side.",
|
||||||
|
"focus": "Focused list",
|
||||||
|
"focus_description": "Show one line per message and open mail full-width while keeping the folder sidebar visible."
|
||||||
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Show Preview Text",
|
"label": "Show Preview Text",
|
||||||
"description": "Display email preview in the list"
|
"description": "Display email preview in the list",
|
||||||
|
"focus_description": "Display inline preview text inside the focused one-line message list"
|
||||||
},
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Disable Conversation Grouping",
|
"label": "Disable Conversation Grouping",
|
||||||
@@ -811,6 +835,10 @@
|
|||||||
"label": "Plain Text Only",
|
"label": "Plain Text Only",
|
||||||
"description": "Disable the rich text editor and send all emails as plain text only, including replies and forwards"
|
"description": "Disable the rich text editor and send all emails as plain text only, including replies and forwards"
|
||||||
},
|
},
|
||||||
|
"auto_select_reply_identity": {
|
||||||
|
"label": "Auto-select Reply Address",
|
||||||
|
"description": "When replying, automatically switch the From address to the identity that originally received the message"
|
||||||
|
},
|
||||||
"attachment_click_action": {
|
"attachment_click_action": {
|
||||||
"label": "Attachment Click Action",
|
"label": "Attachment Click Action",
|
||||||
"description": "Choose whether clicking a file attachment previews it or downloads it immediately",
|
"description": "Choose whether clicking a file attachment previews it or downloads it immediately",
|
||||||
@@ -1867,6 +1895,7 @@
|
|||||||
"title": "Title",
|
"title": "Title",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"location": "Location",
|
"location": "Location",
|
||||||
|
"meeting_link": "Meeting link",
|
||||||
"start_date": "Start date",
|
"start_date": "Start date",
|
||||||
"end_date": "End date",
|
"end_date": "End date",
|
||||||
"start_time": "Start time",
|
"start_time": "Start time",
|
||||||
@@ -1954,7 +1983,14 @@
|
|||||||
"enable_tasks": "Enable tasks",
|
"enable_tasks": "Enable tasks",
|
||||||
"enable_tasks_desc": "Show a tasks view in the calendar for managing to-dos",
|
"enable_tasks_desc": "Show a tasks view in the calendar for managing to-dos",
|
||||||
"show_tasks_on_calendar": "Show tasks on calendar",
|
"show_tasks_on_calendar": "Show tasks on calendar",
|
||||||
"show_tasks_on_calendar_desc": "Display task chips on the day and week calendar views"
|
"show_tasks_on_calendar_desc": "Display task chips on the day and week calendar views",
|
||||||
|
"hover_preview": "Event hover preview",
|
||||||
|
"hover_preview_desc": "Show a detail popover when hovering over calendar events",
|
||||||
|
"hover_preview_instant": "Instant",
|
||||||
|
"hover_preview_delay_500ms": "0.5-second delay",
|
||||||
|
"hover_preview_delay_1s": "1-second delay",
|
||||||
|
"hover_preview_delay_2s": "2-second delay",
|
||||||
|
"hover_preview_off": "Disabled"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Monday",
|
"monday": "Monday",
|
||||||
@@ -2023,7 +2059,7 @@
|
|||||||
"importing": "Importing events...",
|
"importing": "Importing events...",
|
||||||
"success": "{count} events imported successfully",
|
"success": "{count} events imported successfully",
|
||||||
"error": "Failed to import calendar",
|
"error": "Failed to import calendar",
|
||||||
"file_too_large": "File exceeds 5MB limit",
|
"file_too_large": "File exceeds 10MB limit",
|
||||||
"invalid_format": "Invalid calendar file format"
|
"invalid_format": "Invalid calendar file format"
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
@@ -2073,6 +2109,12 @@
|
|||||||
"interval_1440": "Every day",
|
"interval_1440": "Every day",
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
"subscribing": "Subscribing...",
|
"subscribing": "Subscribing...",
|
||||||
|
"save": "Save changes",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"edit": "Edit",
|
||||||
|
"edit_title": "Edit Subscription",
|
||||||
|
"updated": "Updated \"{name}\"",
|
||||||
|
"update_error": "Failed to update subscription",
|
||||||
"invalid_url": "Please enter a valid URL",
|
"invalid_url": "Please enter a valid URL",
|
||||||
"success": "Subscribed to \"{name}\"",
|
"success": "Subscribed to \"{name}\"",
|
||||||
"error": "Failed to add subscription",
|
"error": "Failed to add subscription",
|
||||||
|
|||||||
+47
-4
@@ -5,6 +5,9 @@
|
|||||||
"username_placeholder": "usuario@ejemplo.com",
|
"username_placeholder": "usuario@ejemplo.com",
|
||||||
"password_label": "Contraseña",
|
"password_label": "Contraseña",
|
||||||
"password_placeholder": "Ingrese su contraseña",
|
"password_placeholder": "Ingrese su contraseña",
|
||||||
|
"jmap_endpoint_label": "Servidor JMAP",
|
||||||
|
"jmap_endpoint_placeholder": "https://mail.example.com",
|
||||||
|
"jmap_endpoint_cors_hint": "El servidor debe permitir solicitudes CORS desde este dominio.",
|
||||||
"sign_in": "Iniciar sesión",
|
"sign_in": "Iniciar sesión",
|
||||||
"signing_in": "Iniciando sesión...",
|
"signing_in": "Iniciando sesión...",
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
@@ -115,6 +118,7 @@
|
|||||||
"demo_reset": "Restablecer",
|
"demo_reset": "Restablecer",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
|
"folders": "Carpetas",
|
||||||
"mail": "Correo",
|
"mail": "Correo",
|
||||||
"nav_label": "Navegación",
|
"nav_label": "Navegación",
|
||||||
"add_app": "Apps"
|
"add_app": "Apps"
|
||||||
@@ -196,6 +200,8 @@
|
|||||||
"no_email_description": "Seleccione un correo de la lista para verlo aquí",
|
"no_email_description": "Seleccione un correo de la lista para verlo aquí",
|
||||||
"no_conversation_selected": "Ninguna conversación seleccionada",
|
"no_conversation_selected": "Ninguna conversación seleccionada",
|
||||||
"no_conversation_description": "Elija una conversación de la lista para leerla aquí",
|
"no_conversation_description": "Elija una conversación de la lista para leerla aquí",
|
||||||
|
"compose": "Redactar",
|
||||||
|
"compose_hint": "Redactar nuevo mensaje",
|
||||||
"no_subject": "(Sin Asunto)",
|
"no_subject": "(Sin Asunto)",
|
||||||
"loading_email": "Cargando correo...",
|
"loading_email": "Cargando correo...",
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
@@ -477,7 +483,7 @@
|
|||||||
"smime_sign_off": "Firma S/MIME desactivada",
|
"smime_sign_off": "Firma S/MIME desactivada",
|
||||||
"smime_encrypt_on": "Cifrado S/MIME activado",
|
"smime_encrypt_on": "Cifrado S/MIME activado",
|
||||||
"smime_encrypt_off": "Cifrado S/MIME desactivado",
|
"smime_encrypt_off": "Cifrado S/MIME desactivado",
|
||||||
"smime_encrypt_unavailable": "El cifrado S/MIME no está disponible: {reason}",
|
"smime_encrypt_unavailable": "Cifrado S/MIME no disponible – faltan certificados de destinatario",
|
||||||
"smime_unlock_title": "Desbloquear clave S/MIME",
|
"smime_unlock_title": "Desbloquear clave S/MIME",
|
||||||
"smime_unlock_message": "Introduce la contraseña para desbloquear tu clave S/MIME.",
|
"smime_unlock_message": "Introduce la contraseña para desbloquear tu clave S/MIME.",
|
||||||
"smime_unlock_button": "Desbloquear",
|
"smime_unlock_button": "Desbloquear",
|
||||||
@@ -683,6 +689,14 @@
|
|||||||
"toolbar_labels": {
|
"toolbar_labels": {
|
||||||
"label": "Show Toolbar Labels",
|
"label": "Show Toolbar Labels",
|
||||||
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
||||||
|
},
|
||||||
|
"hide_account_switcher": {
|
||||||
|
"label": "Ocultar selector de cuentas de la barra lateral",
|
||||||
|
"description": "Ocultar el selector de cuentas en la parte superior de la barra lateral de carpetas. Aún puede cambiar de cuenta desde la barra de navegación inferior."
|
||||||
|
},
|
||||||
|
"show_rail_account_list": {
|
||||||
|
"label": "Mostrar avatares de cuentas en la barra de navegación",
|
||||||
|
"description": "Mostrar círculos de cuentas individuales en la parte inferior de la barra de navegación para un cambio rápido, con un botón de cerrar sesión debajo."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
@@ -799,6 +813,14 @@
|
|||||||
"label": "Eliminar spam permanentemente",
|
"label": "Eliminar spam permanentemente",
|
||||||
"description": "Eliminar permanentemente los correos de la carpeta Spam en lugar de moverlos a la Papelera"
|
"description": "Eliminar permanentemente los correos de la carpeta Spam en lugar de moverlos a la Papelera"
|
||||||
},
|
},
|
||||||
|
"mail_layout": {
|
||||||
|
"label": "Diseño del correo",
|
||||||
|
"description": "Elija entre el panel de lectura dividido clásico y un flujo de lectura enfocado estilo Gmail.",
|
||||||
|
"split": "Panel dividido",
|
||||||
|
"split_description": "Mantener la lista de mensajes y el panel de lectura visibles uno al lado del otro.",
|
||||||
|
"focus": "Lista enfocada",
|
||||||
|
"focus_description": "Mostrar una línea por mensaje y abrir el correo a ancho completo manteniendo visible la barra lateral de carpetas."
|
||||||
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Desactivar agrupación de conversaciones",
|
"label": "Desactivar agrupación de conversaciones",
|
||||||
"description": "Mostrar correos como mensajes individuales en lugar de agruparlos por conversación"
|
"description": "Mostrar correos como mensajes individuales en lugar de agruparlos por conversación"
|
||||||
@@ -807,9 +829,14 @@
|
|||||||
"label": "Solo texto sin formato",
|
"label": "Solo texto sin formato",
|
||||||
"description": "Desactivar el editor de texto enriquecido y enviar todos los correos solo como texto sin formato, incluyendo respuestas y reenvíos"
|
"description": "Desactivar el editor de texto enriquecido y enviar todos los correos solo como texto sin formato, incluyendo respuestas y reenvíos"
|
||||||
},
|
},
|
||||||
|
"auto_select_reply_identity": {
|
||||||
|
"label": "Seleccionar dirección de respuesta automáticamente",
|
||||||
|
"description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original"
|
||||||
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Mostrar Vista Previa",
|
"label": "Mostrar Vista Previa",
|
||||||
"description": "Mostrar vista previa del correo en la lista"
|
"description": "Mostrar vista previa del correo en la lista",
|
||||||
|
"focus_description": "Mostrar texto de vista previa en línea dentro de la lista de mensajes enfocada de una línea"
|
||||||
},
|
},
|
||||||
"attachment_click_action": {
|
"attachment_click_action": {
|
||||||
"label": "Acción al hacer clic en adjuntos",
|
"label": "Acción al hacer clic en adjuntos",
|
||||||
@@ -1023,6 +1050,14 @@
|
|||||||
"enabled": "Cifrado en reposo habilitado",
|
"enabled": "Cifrado en reposo habilitado",
|
||||||
"disabled_success": "Cifrado en reposo deshabilitado",
|
"disabled_success": "Cifrado en reposo deshabilitado",
|
||||||
"error": "No se pudieron actualizar las configuraciones de cifrado"
|
"error": "No se pudieron actualizar las configuraciones de cifrado"
|
||||||
|
},
|
||||||
|
"email_client": {
|
||||||
|
"title": "Configuración del cliente de correo",
|
||||||
|
"description": "Use estas credenciales para configurar su cliente de correo de escritorio o móvil (Thunderbird, Apple Mail, Outlook, etc.)",
|
||||||
|
"jmap_username_label": "Nombre de usuario JMAP",
|
||||||
|
"copy": "Copiar",
|
||||||
|
"copied": "Copiado",
|
||||||
|
"password_instructions": "Use su nombre de usuario JMAP indicado arriba junto con una contraseña de aplicación para iniciar sesión en su cliente de correo. Cree una contraseña de aplicación en la sección anterior si aún no lo ha hecho."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"identities": {
|
"identities": {
|
||||||
@@ -1859,6 +1894,7 @@
|
|||||||
"title": "Título",
|
"title": "Título",
|
||||||
"description": "Descripción",
|
"description": "Descripción",
|
||||||
"location": "Ubicación",
|
"location": "Ubicación",
|
||||||
|
"meeting_link": "Enlace de reunión",
|
||||||
"start_date": "Fecha de inicio",
|
"start_date": "Fecha de inicio",
|
||||||
"end_date": "Fecha de fin",
|
"end_date": "Fecha de fin",
|
||||||
"start_time": "Hora de inicio",
|
"start_time": "Hora de inicio",
|
||||||
@@ -1946,7 +1982,14 @@
|
|||||||
"enable_tasks": "Activar tareas",
|
"enable_tasks": "Activar tareas",
|
||||||
"enable_tasks_desc": "Mostrar una vista de tareas en el calendario para gestionar tareas pendientes",
|
"enable_tasks_desc": "Mostrar una vista de tareas en el calendario para gestionar tareas pendientes",
|
||||||
"show_tasks_on_calendar": "Mostrar tareas en el calendario",
|
"show_tasks_on_calendar": "Mostrar tareas en el calendario",
|
||||||
"show_tasks_on_calendar_desc": "Mostrar etiquetas de tareas en las vistas diarias y semanales del calendario"
|
"show_tasks_on_calendar_desc": "Mostrar etiquetas de tareas en las vistas diarias y semanales del calendario",
|
||||||
|
"hover_preview": "Vista previa al pasar el ratón",
|
||||||
|
"hover_preview_desc": "Mostrar un popover de detalles al pasar el ratón sobre eventos del calendario",
|
||||||
|
"hover_preview_instant": "Instantáneo",
|
||||||
|
"hover_preview_delay_500ms": "Retraso de 0,5 segundos",
|
||||||
|
"hover_preview_delay_1s": "Retraso de 1 segundo",
|
||||||
|
"hover_preview_delay_2s": "Retraso de 2 segundos",
|
||||||
|
"hover_preview_off": "Desactivado"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Lunes",
|
"monday": "Lunes",
|
||||||
@@ -2015,7 +2058,7 @@
|
|||||||
"importing": "Importando eventos...",
|
"importing": "Importando eventos...",
|
||||||
"success": "{count} eventos importados correctamente",
|
"success": "{count} eventos importados correctamente",
|
||||||
"error": "Error al importar el calendario",
|
"error": "Error al importar el calendario",
|
||||||
"file_too_large": "El archivo supera el límite de 5 MB",
|
"file_too_large": "El archivo supera el límite de 10 MB",
|
||||||
"invalid_format": "Formato de archivo de calendario no válido"
|
"invalid_format": "Formato de archivo de calendario no válido"
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
+47
-4
@@ -5,6 +5,9 @@
|
|||||||
"username_placeholder": "utilisateur@exemple.com",
|
"username_placeholder": "utilisateur@exemple.com",
|
||||||
"password_label": "Mot de passe",
|
"password_label": "Mot de passe",
|
||||||
"password_placeholder": "Entrez votre mot de passe",
|
"password_placeholder": "Entrez votre mot de passe",
|
||||||
|
"jmap_endpoint_label": "Serveur JMAP",
|
||||||
|
"jmap_endpoint_placeholder": "https://mail.example.com",
|
||||||
|
"jmap_endpoint_cors_hint": "Le serveur doit autoriser les requêtes CORS depuis ce domaine.",
|
||||||
"sign_in": "Se connecter",
|
"sign_in": "Se connecter",
|
||||||
"signing_in": "Connexion en cours...",
|
"signing_in": "Connexion en cours...",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
@@ -115,6 +118,7 @@
|
|||||||
"demo_reset": "Réinitialiser",
|
"demo_reset": "Réinitialiser",
|
||||||
"demo_tour": "Visite",
|
"demo_tour": "Visite",
|
||||||
"tags": "Étiquettes",
|
"tags": "Étiquettes",
|
||||||
|
"folders": "Dossiers",
|
||||||
"mail": "Messagerie",
|
"mail": "Messagerie",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
"add_app": "Apps"
|
"add_app": "Apps"
|
||||||
@@ -196,6 +200,8 @@
|
|||||||
"no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
|
"no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
|
||||||
"no_conversation_selected": "Aucune conversation sélectionnée",
|
"no_conversation_selected": "Aucune conversation sélectionnée",
|
||||||
"no_conversation_description": "Choisissez une conversation dans la liste pour la lire ici",
|
"no_conversation_description": "Choisissez une conversation dans la liste pour la lire ici",
|
||||||
|
"compose": "Rédiger",
|
||||||
|
"compose_hint": "Rédiger un nouveau message",
|
||||||
"no_subject": "(Sans objet)",
|
"no_subject": "(Sans objet)",
|
||||||
"loading_email": "Chargement de l'email...",
|
"loading_email": "Chargement de l'email...",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
@@ -477,7 +483,7 @@
|
|||||||
"smime_sign_off": "Signature S/MIME désactivée",
|
"smime_sign_off": "Signature S/MIME désactivée",
|
||||||
"smime_encrypt_on": "Chiffrement S/MIME activé",
|
"smime_encrypt_on": "Chiffrement S/MIME activé",
|
||||||
"smime_encrypt_off": "Chiffrement S/MIME désactivé",
|
"smime_encrypt_off": "Chiffrement S/MIME désactivé",
|
||||||
"smime_encrypt_unavailable": "Le chiffrement S/MIME n'est pas disponible : {reason}",
|
"smime_encrypt_unavailable": "Chiffrement S/MIME indisponible – certificats des destinataires manquants",
|
||||||
"smime_unlock_title": "Déverrouiller la clé S/MIME",
|
"smime_unlock_title": "Déverrouiller la clé S/MIME",
|
||||||
"smime_unlock_message": "Entrez la phrase secrète pour déverrouiller votre clé S/MIME.",
|
"smime_unlock_message": "Entrez la phrase secrète pour déverrouiller votre clé S/MIME.",
|
||||||
"smime_unlock_button": "Déverrouiller",
|
"smime_unlock_button": "Déverrouiller",
|
||||||
@@ -683,6 +689,14 @@
|
|||||||
"toolbar_labels": {
|
"toolbar_labels": {
|
||||||
"label": "Show Toolbar Labels",
|
"label": "Show Toolbar Labels",
|
||||||
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
||||||
|
},
|
||||||
|
"hide_account_switcher": {
|
||||||
|
"label": "Masquer le sélecteur de compte de la barre latérale",
|
||||||
|
"description": "Masquer le sélecteur de compte en haut de la barre latérale des dossiers. Vous pouvez toujours changer de compte depuis la barre de navigation inférieure."
|
||||||
|
},
|
||||||
|
"show_rail_account_list": {
|
||||||
|
"label": "Afficher les avatars de compte sur la barre de navigation",
|
||||||
|
"description": "Afficher les cercles de comptes individuels en bas de la barre de navigation pour un changement rapide, avec un bouton de déconnexion en dessous."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
@@ -799,6 +813,14 @@
|
|||||||
"label": "Supprimer définitivement les indésirables",
|
"label": "Supprimer définitivement les indésirables",
|
||||||
"description": "Supprimer définitivement les e-mails du dossier Indésirables au lieu de les déplacer vers la corbeille"
|
"description": "Supprimer définitivement les e-mails du dossier Indésirables au lieu de les déplacer vers la corbeille"
|
||||||
},
|
},
|
||||||
|
"mail_layout": {
|
||||||
|
"label": "Disposition du courrier",
|
||||||
|
"description": "Choisissez entre le volet de lecture divisé classique et un flux de lecture focalisé style Gmail.",
|
||||||
|
"split": "Volet divisé",
|
||||||
|
"split_description": "Garder la liste des messages et le volet de lecture visibles côte à côte.",
|
||||||
|
"focus": "Liste focalisée",
|
||||||
|
"focus_description": "Afficher une ligne par message et ouvrir le courrier en pleine largeur tout en gardant la barre latérale des dossiers visible."
|
||||||
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Désactiver le regroupement par conversation",
|
"label": "Désactiver le regroupement par conversation",
|
||||||
"description": "Afficher les e-mails comme messages individuels plutôt que groupés par conversation"
|
"description": "Afficher les e-mails comme messages individuels plutôt que groupés par conversation"
|
||||||
@@ -807,9 +829,14 @@
|
|||||||
"label": "Texte brut uniquement",
|
"label": "Texte brut uniquement",
|
||||||
"description": "Désactiver l'éditeur de texte enrichi et envoyer tous les e-mails en texte brut uniquement, y compris les réponses et les transferts"
|
"description": "Désactiver l'éditeur de texte enrichi et envoyer tous les e-mails en texte brut uniquement, y compris les réponses et les transferts"
|
||||||
},
|
},
|
||||||
|
"auto_select_reply_identity": {
|
||||||
|
"label": "Sélection automatique de l'adresse de réponse",
|
||||||
|
"description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine"
|
||||||
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Afficher l'aperçu",
|
"label": "Afficher l'aperçu",
|
||||||
"description": "Afficher l'aperçu de l'email dans la liste"
|
"description": "Afficher l'aperçu de l'email dans la liste",
|
||||||
|
"focus_description": "Afficher le texte d'aperçu en ligne dans la liste de messages focalisée sur une seule ligne"
|
||||||
},
|
},
|
||||||
"attachment_click_action": {
|
"attachment_click_action": {
|
||||||
"label": "Action au clic sur les pièces jointes",
|
"label": "Action au clic sur les pièces jointes",
|
||||||
@@ -1023,6 +1050,14 @@
|
|||||||
"enabled": "Chiffrement au repos activé",
|
"enabled": "Chiffrement au repos activé",
|
||||||
"disabled_success": "Chiffrement au repos désactivé",
|
"disabled_success": "Chiffrement au repos désactivé",
|
||||||
"error": "Impossible de mettre à jour les paramètres de chiffrement"
|
"error": "Impossible de mettre à jour les paramètres de chiffrement"
|
||||||
|
},
|
||||||
|
"email_client": {
|
||||||
|
"title": "Configuration du client de messagerie",
|
||||||
|
"description": "Utilisez ces identifiants pour configurer votre client de messagerie de bureau ou mobile (Thunderbird, Apple Mail, Outlook, etc.)",
|
||||||
|
"jmap_username_label": "Nom d'utilisateur JMAP",
|
||||||
|
"copy": "Copier",
|
||||||
|
"copied": "Copié",
|
||||||
|
"password_instructions": "Utilisez votre nom d'utilisateur JMAP ci-dessus avec un mot de passe d'application pour vous connecter à votre client de messagerie. Créez un mot de passe d'application dans la section ci-dessus si vous ne l'avez pas encore fait."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"identities": {
|
"identities": {
|
||||||
@@ -1859,6 +1894,7 @@
|
|||||||
"title": "Titre",
|
"title": "Titre",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"location": "Lieu",
|
"location": "Lieu",
|
||||||
|
"meeting_link": "Lien de réunion",
|
||||||
"start_date": "Date de début",
|
"start_date": "Date de début",
|
||||||
"end_date": "Date de fin",
|
"end_date": "Date de fin",
|
||||||
"start_time": "Heure de début",
|
"start_time": "Heure de début",
|
||||||
@@ -1946,7 +1982,14 @@
|
|||||||
"enable_tasks": "Activer les tâches",
|
"enable_tasks": "Activer les tâches",
|
||||||
"enable_tasks_desc": "Afficher une vue des tâches dans le calendrier pour gérer les choses à faire",
|
"enable_tasks_desc": "Afficher une vue des tâches dans le calendrier pour gérer les choses à faire",
|
||||||
"show_tasks_on_calendar": "Afficher les tâches dans le calendrier",
|
"show_tasks_on_calendar": "Afficher les tâches dans le calendrier",
|
||||||
"show_tasks_on_calendar_desc": "Afficher les pastilles des tâches dans les vues jour et semaine du calendrier"
|
"show_tasks_on_calendar_desc": "Afficher les pastilles des tâches dans les vues jour et semaine du calendrier",
|
||||||
|
"hover_preview": "Aperçu au survol",
|
||||||
|
"hover_preview_desc": "Afficher un popover de détails au survol des événements du calendrier",
|
||||||
|
"hover_preview_instant": "Instantané",
|
||||||
|
"hover_preview_delay_500ms": "Délai de 0,5 seconde",
|
||||||
|
"hover_preview_delay_1s": "Délai de 1 seconde",
|
||||||
|
"hover_preview_delay_2s": "Délai de 2 secondes",
|
||||||
|
"hover_preview_off": "Désactivé"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Lundi",
|
"monday": "Lundi",
|
||||||
@@ -2015,7 +2058,7 @@
|
|||||||
"importing": "Importation en cours...",
|
"importing": "Importation en cours...",
|
||||||
"success": "{count} événements importés avec succès",
|
"success": "{count} événements importés avec succès",
|
||||||
"error": "Échec de l'importation du calendrier",
|
"error": "Échec de l'importation du calendrier",
|
||||||
"file_too_large": "Le fichier dépasse la limite de 5 Mo",
|
"file_too_large": "Le fichier dépasse la limite de 10 Mo",
|
||||||
"invalid_format": "Format de fichier calendrier invalide"
|
"invalid_format": "Format de fichier calendrier invalide"
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
+47
-4
@@ -5,6 +5,9 @@
|
|||||||
"username_placeholder": "utente@esempio.com",
|
"username_placeholder": "utente@esempio.com",
|
||||||
"password_label": "Password",
|
"password_label": "Password",
|
||||||
"password_placeholder": "Inserisci la tua password",
|
"password_placeholder": "Inserisci la tua password",
|
||||||
|
"jmap_endpoint_label": "Server JMAP",
|
||||||
|
"jmap_endpoint_placeholder": "https://mail.example.com",
|
||||||
|
"jmap_endpoint_cors_hint": "Il server deve consentire le richieste CORS da questo dominio.",
|
||||||
"sign_in": "Accedi",
|
"sign_in": "Accedi",
|
||||||
"signing_in": "Accesso in corso...",
|
"signing_in": "Accesso in corso...",
|
||||||
"loading": "Caricamento...",
|
"loading": "Caricamento...",
|
||||||
@@ -115,6 +118,7 @@
|
|||||||
"demo_reset": "Reimposta",
|
"demo_reset": "Reimposta",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Etichette",
|
"tags": "Etichette",
|
||||||
|
"folders": "Cartelle",
|
||||||
"mail": "Posta",
|
"mail": "Posta",
|
||||||
"nav_label": "Navigazione",
|
"nav_label": "Navigazione",
|
||||||
"add_app": "App"
|
"add_app": "App"
|
||||||
@@ -196,6 +200,8 @@
|
|||||||
"no_email_description": "Seleziona un messaggio dall'elenco per visualizzarlo qui",
|
"no_email_description": "Seleziona un messaggio dall'elenco per visualizzarlo qui",
|
||||||
"no_conversation_selected": "Nessuna conversazione selezionata",
|
"no_conversation_selected": "Nessuna conversazione selezionata",
|
||||||
"no_conversation_description": "Scegli una conversazione dall'elenco per leggerla qui",
|
"no_conversation_description": "Scegli una conversazione dall'elenco per leggerla qui",
|
||||||
|
"compose": "Componi",
|
||||||
|
"compose_hint": "Componi nuovo messaggio",
|
||||||
"no_subject": "(Nessun oggetto)",
|
"no_subject": "(Nessun oggetto)",
|
||||||
"loading_email": "Caricamento messaggio...",
|
"loading_email": "Caricamento messaggio...",
|
||||||
"loading": "Caricamento...",
|
"loading": "Caricamento...",
|
||||||
@@ -477,7 +483,7 @@
|
|||||||
"smime_sign_off": "Firma S/MIME disattivata",
|
"smime_sign_off": "Firma S/MIME disattivata",
|
||||||
"smime_encrypt_on": "Cifratura S/MIME attivata",
|
"smime_encrypt_on": "Cifratura S/MIME attivata",
|
||||||
"smime_encrypt_off": "Cifratura S/MIME disattivata",
|
"smime_encrypt_off": "Cifratura S/MIME disattivata",
|
||||||
"smime_encrypt_unavailable": "La cifratura S/MIME non è disponibile: {reason}",
|
"smime_encrypt_unavailable": "Cifratura S/MIME non disponibile – certificati destinatario mancanti",
|
||||||
"smime_unlock_title": "Sblocca chiave S/MIME",
|
"smime_unlock_title": "Sblocca chiave S/MIME",
|
||||||
"smime_unlock_message": "Inserisci la passphrase per sbloccare la tua chiave S/MIME.",
|
"smime_unlock_message": "Inserisci la passphrase per sbloccare la tua chiave S/MIME.",
|
||||||
"smime_unlock_button": "Sblocca",
|
"smime_unlock_button": "Sblocca",
|
||||||
@@ -683,6 +689,14 @@
|
|||||||
"toolbar_labels": {
|
"toolbar_labels": {
|
||||||
"label": "Show Toolbar Labels",
|
"label": "Show Toolbar Labels",
|
||||||
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
||||||
|
},
|
||||||
|
"hide_account_switcher": {
|
||||||
|
"label": "Nascondi selettore account dalla barra laterale",
|
||||||
|
"description": "Nascondi il selettore account nella parte superiore della barra laterale delle cartelle. Puoi comunque cambiare account dalla barra di navigazione inferiore."
|
||||||
|
},
|
||||||
|
"show_rail_account_list": {
|
||||||
|
"label": "Mostra avatar account nella barra di navigazione",
|
||||||
|
"description": "Visualizza i cerchi degli account individuali nella parte inferiore della barra di navigazione per un cambio rapido, con un pulsante di disconnessione sotto."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
@@ -799,6 +813,14 @@
|
|||||||
"label": "Elimina spam definitivamente",
|
"label": "Elimina spam definitivamente",
|
||||||
"description": "Elimina definitivamente i messaggi dalla cartella Spam invece di spostarli nel cestino"
|
"description": "Elimina definitivamente i messaggi dalla cartella Spam invece di spostarli nel cestino"
|
||||||
},
|
},
|
||||||
|
"mail_layout": {
|
||||||
|
"label": "Layout della posta",
|
||||||
|
"description": "Scegli tra il classico pannello di lettura diviso e un flusso di lettura focalizzato in stile Gmail.",
|
||||||
|
"split": "Pannello diviso",
|
||||||
|
"split_description": "Mantieni la lista dei messaggi e il pannello di lettura visibili affiancati.",
|
||||||
|
"focus": "Lista focalizzata",
|
||||||
|
"focus_description": "Mostra una riga per messaggio e apri la posta a larghezza piena mantenendo visibile la barra laterale delle cartelle."
|
||||||
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Disabilita raggruppamento conversazioni",
|
"label": "Disabilita raggruppamento conversazioni",
|
||||||
"description": "Mostra le email come messaggi singoli anziché raggruppati per conversazione"
|
"description": "Mostra le email come messaggi singoli anziché raggruppati per conversazione"
|
||||||
@@ -807,9 +829,14 @@
|
|||||||
"label": "Solo testo normale",
|
"label": "Solo testo normale",
|
||||||
"description": "Disabilita l'editor di testo formattato e invia tutte le email solo come testo normale, incluse risposte e inoltri"
|
"description": "Disabilita l'editor di testo formattato e invia tutte le email solo come testo normale, incluse risposte e inoltri"
|
||||||
},
|
},
|
||||||
|
"auto_select_reply_identity": {
|
||||||
|
"label": "Seleziona automaticamente l'indirizzo di risposta",
|
||||||
|
"description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale"
|
||||||
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Mostra anteprima testo",
|
"label": "Mostra anteprima testo",
|
||||||
"description": "Visualizza l'anteprima del messaggio nell'elenco"
|
"description": "Visualizza l'anteprima del messaggio nell'elenco",
|
||||||
|
"focus_description": "Visualizza il testo di anteprima in linea nella lista di messaggi focalizzata a riga singola"
|
||||||
},
|
},
|
||||||
"attachment_click_action": {
|
"attachment_click_action": {
|
||||||
"label": "Azione al clic sugli allegati",
|
"label": "Azione al clic sugli allegati",
|
||||||
@@ -1023,6 +1050,14 @@
|
|||||||
"enabled": "Crittografia a riposo abilitata",
|
"enabled": "Crittografia a riposo abilitata",
|
||||||
"disabled_success": "Crittografia a riposo disabilitata",
|
"disabled_success": "Crittografia a riposo disabilitata",
|
||||||
"error": "Impossibile aggiornare le impostazioni di crittografia"
|
"error": "Impossibile aggiornare le impostazioni di crittografia"
|
||||||
|
},
|
||||||
|
"email_client": {
|
||||||
|
"title": "Configurazione client e-mail",
|
||||||
|
"description": "Usa queste credenziali per configurare il tuo client e-mail desktop o mobile (Thunderbird, Apple Mail, Outlook, ecc.)",
|
||||||
|
"jmap_username_label": "Nome utente JMAP",
|
||||||
|
"copy": "Copia",
|
||||||
|
"copied": "Copiato",
|
||||||
|
"password_instructions": "Usa il tuo nome utente JMAP sopra indicato insieme a una password dell'app per accedere al tuo client e-mail. Crea una password dell'app nella sezione sopra se non l'hai ancora fatto."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"identities": {
|
"identities": {
|
||||||
@@ -1859,6 +1894,7 @@
|
|||||||
"title": "Titolo",
|
"title": "Titolo",
|
||||||
"description": "Descrizione",
|
"description": "Descrizione",
|
||||||
"location": "Luogo",
|
"location": "Luogo",
|
||||||
|
"meeting_link": "Link riunione",
|
||||||
"start_date": "Data di inizio",
|
"start_date": "Data di inizio",
|
||||||
"end_date": "Data di fine",
|
"end_date": "Data di fine",
|
||||||
"start_time": "Ora di inizio",
|
"start_time": "Ora di inizio",
|
||||||
@@ -1946,7 +1982,14 @@
|
|||||||
"enable_tasks": "Abilita attività",
|
"enable_tasks": "Abilita attività",
|
||||||
"enable_tasks_desc": "Mostra una vista attività nel calendario per gestire le cose da fare",
|
"enable_tasks_desc": "Mostra una vista attività nel calendario per gestire le cose da fare",
|
||||||
"show_tasks_on_calendar": "Mostra attività nel calendario",
|
"show_tasks_on_calendar": "Mostra attività nel calendario",
|
||||||
"show_tasks_on_calendar_desc": "Mostra gli indicatori delle attività nelle viste giornaliera e settimanale del calendario"
|
"show_tasks_on_calendar_desc": "Mostra gli indicatori delle attività nelle viste giornaliera e settimanale del calendario",
|
||||||
|
"hover_preview": "Anteprima al passaggio del mouse",
|
||||||
|
"hover_preview_desc": "Mostra un popover dettagliato al passaggio del mouse sugli eventi del calendario",
|
||||||
|
"hover_preview_instant": "Istantaneo",
|
||||||
|
"hover_preview_delay_500ms": "Ritardo di 0,5 secondi",
|
||||||
|
"hover_preview_delay_1s": "Ritardo di 1 secondo",
|
||||||
|
"hover_preview_delay_2s": "Ritardo di 2 secondi",
|
||||||
|
"hover_preview_off": "Disabilitato"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Lunedì",
|
"monday": "Lunedì",
|
||||||
@@ -2015,7 +2058,7 @@
|
|||||||
"importing": "Importazione eventi...",
|
"importing": "Importazione eventi...",
|
||||||
"success": "{count} eventi importati con successo",
|
"success": "{count} eventi importati con successo",
|
||||||
"error": "Importazione del calendario fallita",
|
"error": "Importazione del calendario fallita",
|
||||||
"file_too_large": "Il file supera il limite di 5 MB",
|
"file_too_large": "Il file supera il limite di 10 MB",
|
||||||
"invalid_format": "Formato del file calendario non valido"
|
"invalid_format": "Formato del file calendario non valido"
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
+47
-4
@@ -5,6 +5,9 @@
|
|||||||
"username_placeholder": "user@example.com",
|
"username_placeholder": "user@example.com",
|
||||||
"password_label": "パスワード",
|
"password_label": "パスワード",
|
||||||
"password_placeholder": "パスワードを入力",
|
"password_placeholder": "パスワードを入力",
|
||||||
|
"jmap_endpoint_label": "JMAPサーバー",
|
||||||
|
"jmap_endpoint_placeholder": "https://mail.example.com",
|
||||||
|
"jmap_endpoint_cors_hint": "サーバーはこのドメインからのCORSリクエストを許可する必要があります。",
|
||||||
"sign_in": "サインイン",
|
"sign_in": "サインイン",
|
||||||
"signing_in": "サインイン中...",
|
"signing_in": "サインイン中...",
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
@@ -115,6 +118,7 @@
|
|||||||
"demo_reset": "リセット",
|
"demo_reset": "リセット",
|
||||||
"demo_tour": "ツアー",
|
"demo_tour": "ツアー",
|
||||||
"tags": "タグ",
|
"tags": "タグ",
|
||||||
|
"folders": "フォルダ",
|
||||||
"mail": "メール",
|
"mail": "メール",
|
||||||
"nav_label": "ナビゲーション",
|
"nav_label": "ナビゲーション",
|
||||||
"add_app": "アプリ"
|
"add_app": "アプリ"
|
||||||
@@ -196,6 +200,8 @@
|
|||||||
"no_email_description": "リストからメールを選択して表示してください",
|
"no_email_description": "リストからメールを選択して表示してください",
|
||||||
"no_conversation_selected": "会話が選択されていません",
|
"no_conversation_selected": "会話が選択されていません",
|
||||||
"no_conversation_description": "リストから会話を選択して読んでください",
|
"no_conversation_description": "リストから会話を選択して読んでください",
|
||||||
|
"compose": "作成",
|
||||||
|
"compose_hint": "新しいメッセージを作成",
|
||||||
"no_subject": "(件名なし)",
|
"no_subject": "(件名なし)",
|
||||||
"loading_email": "メールを読み込み中...",
|
"loading_email": "メールを読み込み中...",
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
@@ -477,7 +483,7 @@
|
|||||||
"smime_sign_off": "S/MIME 署名を無効化しました",
|
"smime_sign_off": "S/MIME 署名を無効化しました",
|
||||||
"smime_encrypt_on": "S/MIME 暗号化を有効化しました",
|
"smime_encrypt_on": "S/MIME 暗号化を有効化しました",
|
||||||
"smime_encrypt_off": "S/MIME 暗号化を無効化しました",
|
"smime_encrypt_off": "S/MIME 暗号化を無効化しました",
|
||||||
"smime_encrypt_unavailable": "S/MIME 暗号化は利用できません: {reason}",
|
"smime_encrypt_unavailable": "S/MIME 暗号化は利用できません – 受信者の証明書がありません",
|
||||||
"smime_unlock_title": "S/MIME 鍵のロックを解除",
|
"smime_unlock_title": "S/MIME 鍵のロックを解除",
|
||||||
"smime_unlock_message": "S/MIME 鍵を解除するためのパスフレーズを入力してください。",
|
"smime_unlock_message": "S/MIME 鍵を解除するためのパスフレーズを入力してください。",
|
||||||
"smime_unlock_button": "ロック解除",
|
"smime_unlock_button": "ロック解除",
|
||||||
@@ -683,6 +689,14 @@
|
|||||||
"toolbar_labels": {
|
"toolbar_labels": {
|
||||||
"label": "Show Toolbar Labels",
|
"label": "Show Toolbar Labels",
|
||||||
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
||||||
|
},
|
||||||
|
"hide_account_switcher": {
|
||||||
|
"label": "サイドバーのアカウント切替を非表示",
|
||||||
|
"description": "フォルダサイドバー上部のアカウント選択を非表示にします。下部のナビゲーションレールからアカウントを切り替えることは引き続き可能です。"
|
||||||
|
},
|
||||||
|
"show_rail_account_list": {
|
||||||
|
"label": "ナビゲーションレールにアカウントアバターを表示",
|
||||||
|
"description": "ナビゲーションレールの下部に個々のアカウントの丸を表示して素早く切り替えできるようにし、その下にサインアウトボタンを配置します。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
@@ -799,6 +813,14 @@
|
|||||||
"label": "迷惑メールを完全に削除",
|
"label": "迷惑メールを完全に削除",
|
||||||
"description": "迷惑メールフォルダのメールをゴミ箱に移動せずに完全に削除する"
|
"description": "迷惑メールフォルダのメールをゴミ箱に移動せずに完全に削除する"
|
||||||
},
|
},
|
||||||
|
"mail_layout": {
|
||||||
|
"label": "メールレイアウト",
|
||||||
|
"description": "クラシックな分割読み取りペインとGmailスタイルの集中読み取りフローから選択します。",
|
||||||
|
"split": "分割ペイン",
|
||||||
|
"split_description": "メッセージリストと読み取りペインを並べて表示します。",
|
||||||
|
"focus": "集中リスト",
|
||||||
|
"focus_description": "メッセージごとに1行表示し、フォルダサイドバーを表示したままメールを全幅で開きます。"
|
||||||
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "会話グループ化を無効にする",
|
"label": "会話グループ化を無効にする",
|
||||||
"description": "メールを会話ごとにグループ化せず、個別のメッセージとして表示します"
|
"description": "メールを会話ごとにグループ化せず、個別のメッセージとして表示します"
|
||||||
@@ -807,9 +829,14 @@
|
|||||||
"label": "プレーンテキストのみ",
|
"label": "プレーンテキストのみ",
|
||||||
"description": "リッチテキストエディターを無効にし、返信や転送を含むすべてのメールをプレーンテキストのみで送信します"
|
"description": "リッチテキストエディターを無効にし、返信や転送を含むすべてのメールをプレーンテキストのみで送信します"
|
||||||
},
|
},
|
||||||
|
"auto_select_reply_identity": {
|
||||||
|
"label": "返信元アドレスを自動選択",
|
||||||
|
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
|
||||||
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "プレビューテキストを表示",
|
"label": "プレビューテキストを表示",
|
||||||
"description": "リストにメールのプレビューを表示"
|
"description": "リストにメールのプレビューを表示",
|
||||||
|
"focus_description": "集中型1行メッセージリスト内にインラインプレビューテキストを表示"
|
||||||
},
|
},
|
||||||
"attachment_click_action": {
|
"attachment_click_action": {
|
||||||
"label": "添付ファイルクリック時の動作",
|
"label": "添付ファイルクリック時の動作",
|
||||||
@@ -1023,6 +1050,14 @@
|
|||||||
"enabled": "保存時の暗号化が有効になりました",
|
"enabled": "保存時の暗号化が有効になりました",
|
||||||
"disabled_success": "保存時の暗号化が無効になりました",
|
"disabled_success": "保存時の暗号化が無効になりました",
|
||||||
"error": "暗号化設定を更新できませんでした"
|
"error": "暗号化設定を更新できませんでした"
|
||||||
|
},
|
||||||
|
"email_client": {
|
||||||
|
"title": "メールクライアントの設定",
|
||||||
|
"description": "これらの資格情報を使用して、デスクトップまたはモバイルのメールクライアント(Thunderbird、Apple Mail、Outlookなど)を設定します",
|
||||||
|
"jmap_username_label": "JMAPユーザー名",
|
||||||
|
"copy": "コピー",
|
||||||
|
"copied": "コピー済み",
|
||||||
|
"password_instructions": "上記のJMAPユーザー名とアプリパスワードを使用してメールクライアントにサインインします。まだ作成していない場合は、上のセクションでアプリパスワードを作成してください。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"identities": {
|
"identities": {
|
||||||
@@ -1859,6 +1894,7 @@
|
|||||||
"title": "タイトル",
|
"title": "タイトル",
|
||||||
"description": "説明",
|
"description": "説明",
|
||||||
"location": "場所",
|
"location": "場所",
|
||||||
|
"meeting_link": "会議リンク",
|
||||||
"start_date": "開始日",
|
"start_date": "開始日",
|
||||||
"end_date": "終了日",
|
"end_date": "終了日",
|
||||||
"start_time": "開始時刻",
|
"start_time": "開始時刻",
|
||||||
@@ -1946,7 +1982,14 @@
|
|||||||
"enable_tasks": "タスクを有効化",
|
"enable_tasks": "タスクを有効化",
|
||||||
"enable_tasks_desc": "カレンダーにタスク表示を追加して、ToDoを管理します",
|
"enable_tasks_desc": "カレンダーにタスク表示を追加して、ToDoを管理します",
|
||||||
"show_tasks_on_calendar": "カレンダーにタスクを表示",
|
"show_tasks_on_calendar": "カレンダーにタスクを表示",
|
||||||
"show_tasks_on_calendar_desc": "日表示と週表示にタスクチップを表示します"
|
"show_tasks_on_calendar_desc": "日表示と週表示にタスクチップを表示します",
|
||||||
|
"hover_preview": "イベントホバープレビュー",
|
||||||
|
"hover_preview_desc": "カレンダーイベントにマウスを合わせたときに詳細ポップオーバーを表示",
|
||||||
|
"hover_preview_instant": "即時",
|
||||||
|
"hover_preview_delay_500ms": "0.5秒遅延",
|
||||||
|
"hover_preview_delay_1s": "1秒遅延",
|
||||||
|
"hover_preview_delay_2s": "2秒遅延",
|
||||||
|
"hover_preview_off": "無効"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "月曜日",
|
"monday": "月曜日",
|
||||||
@@ -2015,7 +2058,7 @@
|
|||||||
"importing": "イベントをインポート中...",
|
"importing": "イベントをインポート中...",
|
||||||
"success": "{count}件のイベントをインポートしました",
|
"success": "{count}件のイベントをインポートしました",
|
||||||
"error": "カレンダーのインポートに失敗しました",
|
"error": "カレンダーのインポートに失敗しました",
|
||||||
"file_too_large": "ファイルサイズが5MBを超えています",
|
"file_too_large": "ファイルサイズが10MBを超えています",
|
||||||
"invalid_format": "無効なカレンダーファイル形式"
|
"invalid_format": "無効なカレンダーファイル形式"
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
+47
-4
@@ -5,6 +5,9 @@
|
|||||||
"username_placeholder": "gebruiker@voorbeeld.nl",
|
"username_placeholder": "gebruiker@voorbeeld.nl",
|
||||||
"password_label": "Wachtwoord",
|
"password_label": "Wachtwoord",
|
||||||
"password_placeholder": "Voer je wachtwoord in",
|
"password_placeholder": "Voer je wachtwoord in",
|
||||||
|
"jmap_endpoint_label": "JMAP-server",
|
||||||
|
"jmap_endpoint_placeholder": "https://mail.example.com",
|
||||||
|
"jmap_endpoint_cors_hint": "De server moet CORS-verzoeken van dit domein toestaan.",
|
||||||
"sign_in": "Aanmelden",
|
"sign_in": "Aanmelden",
|
||||||
"signing_in": "Aanmelden...",
|
"signing_in": "Aanmelden...",
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
@@ -115,6 +118,7 @@
|
|||||||
"demo_reset": "Resetten",
|
"demo_reset": "Resetten",
|
||||||
"demo_tour": "Rondleiding",
|
"demo_tour": "Rondleiding",
|
||||||
"tags": "Labels",
|
"tags": "Labels",
|
||||||
|
"folders": "Mappen",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navigatie",
|
"nav_label": "Navigatie",
|
||||||
"add_app": "Apps"
|
"add_app": "Apps"
|
||||||
@@ -196,6 +200,8 @@
|
|||||||
"no_email_description": "Selecteer een e-mail uit de lijst om deze hier te bekijken",
|
"no_email_description": "Selecteer een e-mail uit de lijst om deze hier te bekijken",
|
||||||
"no_conversation_selected": "Geen gesprek geselecteerd",
|
"no_conversation_selected": "Geen gesprek geselecteerd",
|
||||||
"no_conversation_description": "Kies een gesprek uit de lijst om het hier te lezen",
|
"no_conversation_description": "Kies een gesprek uit de lijst om het hier te lezen",
|
||||||
|
"compose": "Opstellen",
|
||||||
|
"compose_hint": "Nieuw bericht opstellen",
|
||||||
"no_subject": "(Geen onderwerp)",
|
"no_subject": "(Geen onderwerp)",
|
||||||
"loading_email": "E-mail laden...",
|
"loading_email": "E-mail laden...",
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
@@ -477,7 +483,7 @@
|
|||||||
"smime_sign_off": "S/MIME-ondertekening uitgeschakeld",
|
"smime_sign_off": "S/MIME-ondertekening uitgeschakeld",
|
||||||
"smime_encrypt_on": "S/MIME-versleuteling ingeschakeld",
|
"smime_encrypt_on": "S/MIME-versleuteling ingeschakeld",
|
||||||
"smime_encrypt_off": "S/MIME-versleuteling uitgeschakeld",
|
"smime_encrypt_off": "S/MIME-versleuteling uitgeschakeld",
|
||||||
"smime_encrypt_unavailable": "S/MIME-versleuteling is niet beschikbaar: {reason}",
|
"smime_encrypt_unavailable": "S/MIME-versleuteling niet beschikbaar – ontbrekende ontvangercertificaten",
|
||||||
"smime_unlock_title": "S/MIME-sleutel ontgrendelen",
|
"smime_unlock_title": "S/MIME-sleutel ontgrendelen",
|
||||||
"smime_unlock_message": "Voer de wachtwoordzin in om uw S/MIME-sleutel te ontgrendelen.",
|
"smime_unlock_message": "Voer de wachtwoordzin in om uw S/MIME-sleutel te ontgrendelen.",
|
||||||
"smime_unlock_button": "Ontgrendelen",
|
"smime_unlock_button": "Ontgrendelen",
|
||||||
@@ -683,6 +689,14 @@
|
|||||||
"toolbar_labels": {
|
"toolbar_labels": {
|
||||||
"label": "Show Toolbar Labels",
|
"label": "Show Toolbar Labels",
|
||||||
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
||||||
|
},
|
||||||
|
"hide_account_switcher": {
|
||||||
|
"label": "Accountwisselaar in zijbalk verbergen",
|
||||||
|
"description": "Verberg de accountselector bovenaan de mappenzijbalk. U kunt nog steeds van account wisselen via de onderste navigatiebalk."
|
||||||
|
},
|
||||||
|
"show_rail_account_list": {
|
||||||
|
"label": "Accountavatars tonen op navigatiebalk",
|
||||||
|
"description": "Toon individuele accountcirkels onderaan de navigatiebalk voor snel wisselen, met een afmeldknop eronder."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
@@ -799,6 +813,14 @@
|
|||||||
"label": "Spam permanent verwijderen",
|
"label": "Spam permanent verwijderen",
|
||||||
"description": "E-mails uit de map Spam permanent verwijderen in plaats van naar de prullenbak te verplaatsen"
|
"description": "E-mails uit de map Spam permanent verwijderen in plaats van naar de prullenbak te verplaatsen"
|
||||||
},
|
},
|
||||||
|
"mail_layout": {
|
||||||
|
"label": "E-mailindeling",
|
||||||
|
"description": "Kies tussen het klassieke gesplitste leesvenster en een gefocuste leesstroom in Gmail-stijl.",
|
||||||
|
"split": "Gesplitst venster",
|
||||||
|
"split_description": "Houd de berichtenlijst en het leesvenster naast elkaar zichtbaar.",
|
||||||
|
"focus": "Gefocuste lijst",
|
||||||
|
"focus_description": "Toon één regel per bericht en open e-mail op volledige breedte terwijl de mappenzijbalk zichtbaar blijft."
|
||||||
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Conversatiegroepering uitschakelen",
|
"label": "Conversatiegroepering uitschakelen",
|
||||||
"description": "Toon e-mails als afzonderlijke berichten in plaats van gegroepeerd op gesprek"
|
"description": "Toon e-mails als afzonderlijke berichten in plaats van gegroepeerd op gesprek"
|
||||||
@@ -807,9 +829,14 @@
|
|||||||
"label": "Alleen platte tekst",
|
"label": "Alleen platte tekst",
|
||||||
"description": "Schakel de rich text-editor uit en verzend alle e-mails alleen als platte tekst, inclusief antwoorden en doorgestuurde berichten"
|
"description": "Schakel de rich text-editor uit en verzend alle e-mails alleen als platte tekst, inclusief antwoorden en doorgestuurde berichten"
|
||||||
},
|
},
|
||||||
|
"auto_select_reply_identity": {
|
||||||
|
"label": "Antwoordadres automatisch selecteren",
|
||||||
|
"description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving"
|
||||||
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Voorbeeldtekst tonen",
|
"label": "Voorbeeldtekst tonen",
|
||||||
"description": "E-mailvoorbeeld weergeven in de lijst"
|
"description": "E-mailvoorbeeld weergeven in de lijst",
|
||||||
|
"focus_description": "Inline voorbeeldtekst weergeven in de gefocuste berichtenlijst met één regel"
|
||||||
},
|
},
|
||||||
"attachment_click_action": {
|
"attachment_click_action": {
|
||||||
"label": "Actie bij klikken op bijlagen",
|
"label": "Actie bij klikken op bijlagen",
|
||||||
@@ -1023,6 +1050,14 @@
|
|||||||
"enabled": "Versleuteling in rust ingeschakeld",
|
"enabled": "Versleuteling in rust ingeschakeld",
|
||||||
"disabled_success": "Versleuteling in rust uitgeschakeld",
|
"disabled_success": "Versleuteling in rust uitgeschakeld",
|
||||||
"error": "Kan versleutelingsinstellingen niet bijwerken"
|
"error": "Kan versleutelingsinstellingen niet bijwerken"
|
||||||
|
},
|
||||||
|
"email_client": {
|
||||||
|
"title": "E-mailclient instellen",
|
||||||
|
"description": "Gebruik deze inloggegevens om uw desktop- of mobiele e-mailclient te configureren (Thunderbird, Apple Mail, Outlook, enz.)",
|
||||||
|
"jmap_username_label": "JMAP-gebruikersnaam",
|
||||||
|
"copy": "Kopiëren",
|
||||||
|
"copied": "Gekopieerd",
|
||||||
|
"password_instructions": "Gebruik uw hierboven vermelde JMAP-gebruikersnaam samen met een app-wachtwoord om in te loggen bij uw e-mailclient. Maak een app-wachtwoord aan in het bovenstaande gedeelte als u dat nog niet heeft gedaan."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"identities": {
|
"identities": {
|
||||||
@@ -1859,6 +1894,7 @@
|
|||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
"description": "Beschrijving",
|
"description": "Beschrijving",
|
||||||
"location": "Locatie",
|
"location": "Locatie",
|
||||||
|
"meeting_link": "Vergaderlink",
|
||||||
"start_date": "Startdatum",
|
"start_date": "Startdatum",
|
||||||
"end_date": "Einddatum",
|
"end_date": "Einddatum",
|
||||||
"start_time": "Starttijd",
|
"start_time": "Starttijd",
|
||||||
@@ -1946,7 +1982,14 @@
|
|||||||
"enable_tasks": "Taken inschakelen",
|
"enable_tasks": "Taken inschakelen",
|
||||||
"enable_tasks_desc": "Een takenweergave in de agenda tonen om to-do's te beheren",
|
"enable_tasks_desc": "Een takenweergave in de agenda tonen om to-do's te beheren",
|
||||||
"show_tasks_on_calendar": "Taken in agenda tonen",
|
"show_tasks_on_calendar": "Taken in agenda tonen",
|
||||||
"show_tasks_on_calendar_desc": "Taaklabels tonen in dag- en weekweergave van de agenda"
|
"show_tasks_on_calendar_desc": "Taaklabels tonen in dag- en weekweergave van de agenda",
|
||||||
|
"hover_preview": "Voorvertoning bij hover",
|
||||||
|
"hover_preview_desc": "Toon een detailpopover bij het hoveren over agenda-evenementen",
|
||||||
|
"hover_preview_instant": "Direct",
|
||||||
|
"hover_preview_delay_500ms": "0,5 seconde vertraging",
|
||||||
|
"hover_preview_delay_1s": "1 seconde vertraging",
|
||||||
|
"hover_preview_delay_2s": "2 seconden vertraging",
|
||||||
|
"hover_preview_off": "Uitgeschakeld"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Maandag",
|
"monday": "Maandag",
|
||||||
@@ -2015,7 +2058,7 @@
|
|||||||
"importing": "Evenementen importeren...",
|
"importing": "Evenementen importeren...",
|
||||||
"success": "{count} evenementen succesvol geïmporteerd",
|
"success": "{count} evenementen succesvol geïmporteerd",
|
||||||
"error": "Agenda importeren mislukt",
|
"error": "Agenda importeren mislukt",
|
||||||
"file_too_large": "Bestand overschrijdt de limiet van 5 MB",
|
"file_too_large": "Bestand overschrijdt de limiet van 10 MB",
|
||||||
"invalid_format": "Ongeldig agendabestandsformaat"
|
"invalid_format": "Ongeldig agendabestandsformaat"
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
+47
-4
@@ -5,6 +5,9 @@
|
|||||||
"username_placeholder": "usuario@exemplo.com",
|
"username_placeholder": "usuario@exemplo.com",
|
||||||
"password_label": "Senha",
|
"password_label": "Senha",
|
||||||
"password_placeholder": "Digite sua senha",
|
"password_placeholder": "Digite sua senha",
|
||||||
|
"jmap_endpoint_label": "Servidor JMAP",
|
||||||
|
"jmap_endpoint_placeholder": "https://mail.example.com",
|
||||||
|
"jmap_endpoint_cors_hint": "O servidor deve permitir solicitações CORS deste domínio.",
|
||||||
"sign_in": "Entrar",
|
"sign_in": "Entrar",
|
||||||
"signing_in": "Entrando...",
|
"signing_in": "Entrando...",
|
||||||
"loading": "Carregando...",
|
"loading": "Carregando...",
|
||||||
@@ -115,6 +118,7 @@
|
|||||||
"demo_reset": "Repor",
|
"demo_reset": "Repor",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
|
"folders": "Pastas",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navegação",
|
"nav_label": "Navegação",
|
||||||
"add_app": "Apps"
|
"add_app": "Apps"
|
||||||
@@ -196,6 +200,8 @@
|
|||||||
"no_email_description": "Selecione um e-mail da lista para visualizá-lo aqui",
|
"no_email_description": "Selecione um e-mail da lista para visualizá-lo aqui",
|
||||||
"no_conversation_selected": "Nenhuma conversa selecionada",
|
"no_conversation_selected": "Nenhuma conversa selecionada",
|
||||||
"no_conversation_description": "Escolha uma conversa da lista para lê-la aqui",
|
"no_conversation_description": "Escolha uma conversa da lista para lê-la aqui",
|
||||||
|
"compose": "Redigir",
|
||||||
|
"compose_hint": "Redigir nova mensagem",
|
||||||
"no_subject": "(Sem Assunto)",
|
"no_subject": "(Sem Assunto)",
|
||||||
"loading_email": "Carregando e-mail...",
|
"loading_email": "Carregando e-mail...",
|
||||||
"loading": "Carregando...",
|
"loading": "Carregando...",
|
||||||
@@ -477,7 +483,7 @@
|
|||||||
"smime_sign_off": "Assinatura S/MIME desativada",
|
"smime_sign_off": "Assinatura S/MIME desativada",
|
||||||
"smime_encrypt_on": "Criptografia S/MIME ativada",
|
"smime_encrypt_on": "Criptografia S/MIME ativada",
|
||||||
"smime_encrypt_off": "Criptografia S/MIME desativada",
|
"smime_encrypt_off": "Criptografia S/MIME desativada",
|
||||||
"smime_encrypt_unavailable": "A criptografia S/MIME não está disponível: {reason}",
|
"smime_encrypt_unavailable": "Criptografia S/MIME indisponível – certificados de destinatário ausentes",
|
||||||
"smime_unlock_title": "Desbloquear chave S/MIME",
|
"smime_unlock_title": "Desbloquear chave S/MIME",
|
||||||
"smime_unlock_message": "Insira a frase secreta para desbloquear sua chave S/MIME.",
|
"smime_unlock_message": "Insira a frase secreta para desbloquear sua chave S/MIME.",
|
||||||
"smime_unlock_button": "Desbloquear",
|
"smime_unlock_button": "Desbloquear",
|
||||||
@@ -683,6 +689,14 @@
|
|||||||
"toolbar_labels": {
|
"toolbar_labels": {
|
||||||
"label": "Show Toolbar Labels",
|
"label": "Show Toolbar Labels",
|
||||||
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
|
||||||
|
},
|
||||||
|
"hide_account_switcher": {
|
||||||
|
"label": "Ocultar seletor de conta da barra lateral",
|
||||||
|
"description": "Ocultar o seletor de conta no topo da barra lateral de pastas. Você ainda pode trocar de conta pela barra de navegação inferior."
|
||||||
|
},
|
||||||
|
"show_rail_account_list": {
|
||||||
|
"label": "Mostrar avatares de conta na barra de navegação",
|
||||||
|
"description": "Exibir círculos de contas individuais na parte inferior da barra de navegação para troca rápida, com um botão de sair abaixo."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
@@ -799,6 +813,14 @@
|
|||||||
"label": "Excluir spam permanentemente",
|
"label": "Excluir spam permanentemente",
|
||||||
"description": "Excluir permanentemente e-mails da pasta Spam em vez de movê-los para a Lixeira"
|
"description": "Excluir permanentemente e-mails da pasta Spam em vez de movê-los para a Lixeira"
|
||||||
},
|
},
|
||||||
|
"mail_layout": {
|
||||||
|
"label": "Layout do e-mail",
|
||||||
|
"description": "Escolha entre o painel de leitura dividido clássico e um fluxo de leitura focado estilo Gmail.",
|
||||||
|
"split": "Painel dividido",
|
||||||
|
"split_description": "Manter a lista de mensagens e o painel de leitura visíveis lado a lado.",
|
||||||
|
"focus": "Lista focada",
|
||||||
|
"focus_description": "Mostrar uma linha por mensagem e abrir o e-mail em largura total mantendo a barra lateral de pastas visível."
|
||||||
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Desativar agrupamento de conversas",
|
"label": "Desativar agrupamento de conversas",
|
||||||
"description": "Mostrar e-mails como mensagens individuais em vez de agrupados por conversa"
|
"description": "Mostrar e-mails como mensagens individuais em vez de agrupados por conversa"
|
||||||
@@ -807,9 +829,14 @@
|
|||||||
"label": "Apenas texto simples",
|
"label": "Apenas texto simples",
|
||||||
"description": "Desativar o editor de texto formatado e enviar todos os e-mails apenas como texto simples, incluindo respostas e encaminhamentos"
|
"description": "Desativar o editor de texto formatado e enviar todos os e-mails apenas como texto simples, incluindo respostas e encaminhamentos"
|
||||||
},
|
},
|
||||||
|
"auto_select_reply_identity": {
|
||||||
|
"label": "Selecionar automaticamente o endereço de resposta",
|
||||||
|
"description": "Ao responder, muda automaticamente o endereço do remetente para a identidade que recebeu a mensagem original"
|
||||||
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Mostrar Texto de Visualização",
|
"label": "Mostrar Texto de Visualização",
|
||||||
"description": "Exibir visualização do e-mail na lista"
|
"description": "Exibir visualização do e-mail na lista",
|
||||||
|
"focus_description": "Exibir texto de pré-visualização em linha dentro da lista de mensagens focada de uma linha"
|
||||||
},
|
},
|
||||||
"attachment_click_action": {
|
"attachment_click_action": {
|
||||||
"label": "Ação ao clicar em anexos",
|
"label": "Ação ao clicar em anexos",
|
||||||
@@ -1023,6 +1050,14 @@
|
|||||||
"enabled": "Criptografia em repouso habilitada",
|
"enabled": "Criptografia em repouso habilitada",
|
||||||
"disabled_success": "Criptografia em repouso desabilitada",
|
"disabled_success": "Criptografia em repouso desabilitada",
|
||||||
"error": "Não foi possível atualizar as configurações de criptografia"
|
"error": "Não foi possível atualizar as configurações de criptografia"
|
||||||
|
},
|
||||||
|
"email_client": {
|
||||||
|
"title": "Configuração do Cliente de E-mail",
|
||||||
|
"description": "Use estas credenciais para configurar seu cliente de e-mail desktop ou móvel (Thunderbird, Apple Mail, Outlook, etc.)",
|
||||||
|
"jmap_username_label": "Nome de Usuário JMAP",
|
||||||
|
"copy": "Copiar",
|
||||||
|
"copied": "Copiado",
|
||||||
|
"password_instructions": "Use seu nome de usuário JMAP acima junto com uma senha de aplicativo para entrar no seu cliente de e-mail. Crie uma senha de aplicativo na seção acima se ainda não tiver uma."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"identities": {
|
"identities": {
|
||||||
@@ -1859,6 +1894,7 @@
|
|||||||
"title": "Título",
|
"title": "Título",
|
||||||
"description": "Descrição",
|
"description": "Descrição",
|
||||||
"location": "Local",
|
"location": "Local",
|
||||||
|
"meeting_link": "Link da reunião",
|
||||||
"start_date": "Data de início",
|
"start_date": "Data de início",
|
||||||
"end_date": "Data de término",
|
"end_date": "Data de término",
|
||||||
"start_time": "Hora de início",
|
"start_time": "Hora de início",
|
||||||
@@ -1946,7 +1982,14 @@
|
|||||||
"enable_tasks": "Ativar tarefas",
|
"enable_tasks": "Ativar tarefas",
|
||||||
"enable_tasks_desc": "Mostrar uma visualização de tarefas no calendário para gerenciar pendências",
|
"enable_tasks_desc": "Mostrar uma visualização de tarefas no calendário para gerenciar pendências",
|
||||||
"show_tasks_on_calendar": "Mostrar tarefas no calendário",
|
"show_tasks_on_calendar": "Mostrar tarefas no calendário",
|
||||||
"show_tasks_on_calendar_desc": "Exibir marcadores de tarefas nas visualizações diária e semanal do calendário"
|
"show_tasks_on_calendar_desc": "Exibir marcadores de tarefas nas visualizações diária e semanal do calendário",
|
||||||
|
"hover_preview": "Pré-visualização ao passar o rato",
|
||||||
|
"hover_preview_desc": "Mostrar um popover de detalhes ao passar o rato sobre eventos do calendário",
|
||||||
|
"hover_preview_instant": "Instantâneo",
|
||||||
|
"hover_preview_delay_500ms": "Atraso de 0,5 segundos",
|
||||||
|
"hover_preview_delay_1s": "Atraso de 1 segundo",
|
||||||
|
"hover_preview_delay_2s": "Atraso de 2 segundos",
|
||||||
|
"hover_preview_off": "Desativado"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Segunda-feira",
|
"monday": "Segunda-feira",
|
||||||
@@ -2015,7 +2058,7 @@
|
|||||||
"importing": "Importando eventos...",
|
"importing": "Importando eventos...",
|
||||||
"success": "{count} eventos importados com sucesso",
|
"success": "{count} eventos importados com sucesso",
|
||||||
"error": "Falha ao importar calendário",
|
"error": "Falha ao importar calendário",
|
||||||
"file_too_large": "Arquivo excede o limite de 5 MB",
|
"file_too_large": "Arquivo excede o limite de 10 MB",
|
||||||
"invalid_format": "Formato de arquivo de calendário inválido"
|
"invalid_format": "Formato de arquivo de calendário inválido"
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
+46
-3
@@ -5,6 +5,9 @@
|
|||||||
"username_placeholder": "user@пример.рф",
|
"username_placeholder": "user@пример.рф",
|
||||||
"password_label": "Пароль",
|
"password_label": "Пароль",
|
||||||
"password_placeholder": "Введите пароль",
|
"password_placeholder": "Введите пароль",
|
||||||
|
"jmap_endpoint_label": "Сервер JMAP",
|
||||||
|
"jmap_endpoint_placeholder": "https://mail.example.com",
|
||||||
|
"jmap_endpoint_cors_hint": "Сервер должен разрешать CORS-запросы с этого домена.",
|
||||||
"sign_in": "Войти",
|
"sign_in": "Войти",
|
||||||
"signing_in": "Вход...",
|
"signing_in": "Вход...",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -115,6 +118,7 @@
|
|||||||
"demo_reset": "Сбросить",
|
"demo_reset": "Сбросить",
|
||||||
"demo_tour": "Тур",
|
"demo_tour": "Тур",
|
||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
|
"folders": "Папки",
|
||||||
"mail": "Почта",
|
"mail": "Почта",
|
||||||
"nav_label": "Навигация",
|
"nav_label": "Навигация",
|
||||||
"add_app": "Приложения"
|
"add_app": "Приложения"
|
||||||
@@ -196,6 +200,8 @@
|
|||||||
"no_email_description": "Выберите письмо из списка, чтобы просмотреть его здесь",
|
"no_email_description": "Выберите письмо из списка, чтобы просмотреть его здесь",
|
||||||
"no_conversation_selected": "Беседа не выбрана",
|
"no_conversation_selected": "Беседа не выбрана",
|
||||||
"no_conversation_description": "Выберите беседу из списка, чтобы прочитать её здесь",
|
"no_conversation_description": "Выберите беседу из списка, чтобы прочитать её здесь",
|
||||||
|
"compose": "Написать",
|
||||||
|
"compose_hint": "Написать новое сообщение",
|
||||||
"no_subject": "(Без темы)",
|
"no_subject": "(Без темы)",
|
||||||
"loading_email": "Загрузка письма...",
|
"loading_email": "Загрузка письма...",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -683,6 +689,14 @@
|
|||||||
"toolbar_labels": {
|
"toolbar_labels": {
|
||||||
"label": "Показывать подписи панели инструментов",
|
"label": "Показывать подписи панели инструментов",
|
||||||
"description": "Отображать текстовые подписи рядом с иконками панели. Отключите для экономии места, когда освоитесь с иконками."
|
"description": "Отображать текстовые подписи рядом с иконками панели. Отключите для экономии места, когда освоитесь с иконками."
|
||||||
|
},
|
||||||
|
"hide_account_switcher": {
|
||||||
|
"label": "Скрыть переключатель аккаунтов в боковой панели",
|
||||||
|
"description": "Скрыть выбор аккаунта в верхней части боковой панели папок. Вы по-прежнему можете переключать аккаунты через нижнюю панель навигации."
|
||||||
|
},
|
||||||
|
"show_rail_account_list": {
|
||||||
|
"label": "Показать аватары аккаунтов на панели навигации",
|
||||||
|
"description": "Отображать отдельные круги аккаунтов в нижней части панели навигации для быстрого переключения, с кнопкой выхода ниже."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
@@ -799,6 +813,14 @@
|
|||||||
"label": "Удалять спам навсегда",
|
"label": "Удалять спам навсегда",
|
||||||
"description": "Удалять письма из папки «Спам» навсегда вместо перемещения в корзину"
|
"description": "Удалять письма из папки «Спам» навсегда вместо перемещения в корзину"
|
||||||
},
|
},
|
||||||
|
"mail_layout": {
|
||||||
|
"label": "Макет почты",
|
||||||
|
"description": "Выберите между классической разделённой панелью чтения и сфокусированным потоком чтения в стиле Gmail.",
|
||||||
|
"split": "Разделённая панель",
|
||||||
|
"split_description": "Список сообщений и панель чтения отображаются рядом друг с другом.",
|
||||||
|
"focus": "Сфокусированный список",
|
||||||
|
"focus_description": "Показывать одну строку на сообщение и открывать письма на всю ширину, сохраняя видимой боковую панель папок."
|
||||||
|
},
|
||||||
"disable_threading": {
|
"disable_threading": {
|
||||||
"label": "Отключить группировку по беседам",
|
"label": "Отключить группировку по беседам",
|
||||||
"description": "Отображать письма как отдельные сообщения, а не сгруппированные по беседам"
|
"description": "Отображать письма как отдельные сообщения, а не сгруппированные по беседам"
|
||||||
@@ -807,9 +829,14 @@
|
|||||||
"label": "Только простой текст",
|
"label": "Только простой текст",
|
||||||
"description": "Отключить редактор форматированного текста и отправлять все письма только в виде простого текста, включая ответы и пересылки"
|
"description": "Отключить редактор форматированного текста и отправлять все письма только в виде простого текста, включая ответы и пересылки"
|
||||||
},
|
},
|
||||||
|
"auto_select_reply_identity": {
|
||||||
|
"label": "Автоматически выбирать адрес для ответа",
|
||||||
|
"description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
|
||||||
|
},
|
||||||
"show_preview": {
|
"show_preview": {
|
||||||
"label": "Показывать текст предпросмотра",
|
"label": "Показывать текст предпросмотра",
|
||||||
"description": "Отображать предпросмотр письма в списке"
|
"description": "Отображать предпросмотр письма в списке",
|
||||||
|
"focus_description": "Отображать встроенный текст предпросмотра в сфокусированном однострочном списке сообщений"
|
||||||
},
|
},
|
||||||
"attachment_click_action": {
|
"attachment_click_action": {
|
||||||
"label": "Действие при нажатии на вложение",
|
"label": "Действие при нажатии на вложение",
|
||||||
@@ -1023,6 +1050,14 @@
|
|||||||
"enabled": "Шифрование хранилища включено",
|
"enabled": "Шифрование хранилища включено",
|
||||||
"disabled_success": "Шифрование хранилища отключено",
|
"disabled_success": "Шифрование хранилища отключено",
|
||||||
"error": "Не удалось обновить настройки шифрования"
|
"error": "Не удалось обновить настройки шифрования"
|
||||||
|
},
|
||||||
|
"email_client": {
|
||||||
|
"title": "Настройка почтового клиента",
|
||||||
|
"description": "Используйте эти учётные данные для настройки вашего почтового клиента (Thunderbird, Apple Mail, Outlook и др.)",
|
||||||
|
"jmap_username_label": "Имя пользователя JMAP",
|
||||||
|
"copy": "Копировать",
|
||||||
|
"copied": "Скопировано",
|
||||||
|
"password_instructions": "Используйте указанное выше имя пользователя JMAP вместе с паролем приложения для входа в почтовый клиент. Создайте пароль приложения в разделе выше, если вы ещё этого не сделали."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"identities": {
|
"identities": {
|
||||||
@@ -1859,6 +1894,7 @@
|
|||||||
"title": "Название",
|
"title": "Название",
|
||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
"location": "Место",
|
"location": "Место",
|
||||||
|
"meeting_link": "Ссылка на встречу",
|
||||||
"start_date": "Дата начала",
|
"start_date": "Дата начала",
|
||||||
"end_date": "Дата окончания",
|
"end_date": "Дата окончания",
|
||||||
"start_time": "Время начала",
|
"start_time": "Время начала",
|
||||||
@@ -1946,7 +1982,14 @@
|
|||||||
"enable_tasks": "Включить задачи",
|
"enable_tasks": "Включить задачи",
|
||||||
"enable_tasks_desc": "Показывать представление задач в календаре для управления делами",
|
"enable_tasks_desc": "Показывать представление задач в календаре для управления делами",
|
||||||
"show_tasks_on_calendar": "Показывать задачи в календаре",
|
"show_tasks_on_calendar": "Показывать задачи в календаре",
|
||||||
"show_tasks_on_calendar_desc": "Отображать чипы задач в видах «день» и «неделя»"
|
"show_tasks_on_calendar_desc": "Отображать чипы задач в видах «день» и «неделя»",
|
||||||
|
"hover_preview": "Предпросмотр при наведении",
|
||||||
|
"hover_preview_desc": "Показывать всплывающее окно с деталями при наведении на события календаря",
|
||||||
|
"hover_preview_instant": "Мгновенно",
|
||||||
|
"hover_preview_delay_500ms": "Задержка 0,5 секунды",
|
||||||
|
"hover_preview_delay_1s": "Задержка 1 секунда",
|
||||||
|
"hover_preview_delay_2s": "Задержка 2 секунды",
|
||||||
|
"hover_preview_off": "Отключено"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Понедельник",
|
"monday": "Понедельник",
|
||||||
@@ -2015,7 +2058,7 @@
|
|||||||
"importing": "Импортирование событий...",
|
"importing": "Импортирование событий...",
|
||||||
"success": "{count} событий успешно импортировано",
|
"success": "{count} событий успешно импортировано",
|
||||||
"error": "Не удалось импортировать календарь",
|
"error": "Не удалось импортировать календарь",
|
||||||
"file_too_large": "Файл превышает лимит 5 МБ",
|
"file_too_large": "Файл превышает лимит 10 МБ",
|
||||||
"invalid_format": "Неверный формат файла календаря"
|
"invalid_format": "Неверный формат файла календаря"
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
Generated
+66
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.9",
|
"version": "1.4.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.9",
|
"version": "1.4.10",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
@@ -3282,6 +3282,70 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.1.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.4.9",
|
"version": "1.4.10",
|
||||||
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
@@ -339,5 +339,39 @@ describe('filter-store', () => {
|
|||||||
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
|
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
|
||||||
expect(useFilterStore.getState().rawScript).toBe(script);
|
expect(useFilterStore.getState().rawScript).toBe(script);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should skip the server-managed vacation script', async () => {
|
||||||
|
const { generateScript } = await import('@/lib/sieve/generator');
|
||||||
|
const rules = [makeRule({ name: 'MyFilter' })];
|
||||||
|
const script = generateScript(rules);
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
getSieveCapabilities: () => null,
|
||||||
|
getSieveScripts: async () => [
|
||||||
|
{ id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true },
|
||||||
|
{ id: 's1', name: 'filters', blobId: 'b1', isActive: false },
|
||||||
|
],
|
||||||
|
getSieveScriptContent: async (blobId: string) => {
|
||||||
|
if (blobId === 'b1') return script;
|
||||||
|
return 'require "vacation"; vacation "I am away";';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
|
||||||
|
// Should pick the 'filters' script, not the 'vacation' one
|
||||||
|
expect(useFilterStore.getState().activeScriptId).toBe('s1');
|
||||||
|
expect(useFilterStore.getState().rules[0].name).toBe('MyFilter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle only vacation script present (no filter scripts)', async () => {
|
||||||
|
const mockClient = {
|
||||||
|
getSieveCapabilities: () => null,
|
||||||
|
getSieveScripts: async () => [
|
||||||
|
{ id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
|
||||||
|
expect(useFilterStore.getState().activeScriptId).toBeNull();
|
||||||
|
expect(useFilterStore.getState().rules).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,4 +29,14 @@ describe('settings-store attachment action', () => {
|
|||||||
|
|
||||||
expect(exported.calendarInvitationParsingEnabled).toBe(false);
|
expect(exported.calendarInvitationParsingEnabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes reply identity auto-selection in exported settings', () => {
|
||||||
|
useSettingsStore.getState().updateSetting('autoSelectReplyIdentity', true);
|
||||||
|
|
||||||
|
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
|
||||||
|
autoSelectReplyIdentity?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(exported.autoSelectReplyIdentity).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+57
-8
@@ -52,6 +52,7 @@ interface AuthState {
|
|||||||
|
|
||||||
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
||||||
{ key: 'cors_blocked', matches: ['CORS_ERROR'] },
|
{ key: 'cors_blocked', matches: ['CORS_ERROR'] },
|
||||||
|
{ key: 'totp_required', matches: ['TOTP_REQUIRED'] },
|
||||||
{ key: 'invalid_credentials', matches: ['Invalid username or password', '401', 'Unauthorized'] },
|
{ key: 'invalid_credentials', matches: ['Invalid username or password', '401', 'Unauthorized'] },
|
||||||
{ key: 'connection_failed', matches: ['network', 'Failed to fetch', 'NetworkError', 'ECONNREFUSED', 'Load failed', 'cancelled'] },
|
{ key: 'connection_failed', matches: ['network', 'Failed to fetch', 'NetworkError', 'ECONNREFUSED', 'Load failed', 'cancelled'] },
|
||||||
{ key: 'server_error', matches: ['500', '502', '503', '504', 'Internal Server Error', 'Service Unavailable'] },
|
{ key: 'server_error', matches: ['500', '502', '503', '504', 'Internal Server Error', 'Service Unavailable'] },
|
||||||
@@ -371,6 +372,47 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
clearAllStores();
|
clearAllStores();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When TOTP was used, try to upgrade to token-based auth so the
|
||||||
|
// session survives TOTP rotation (basic auth embeds the TOTP in
|
||||||
|
// every request, which expires after ~30 seconds).
|
||||||
|
let upgradedToOAuth = false;
|
||||||
|
let oauthAccessToken: string | null = null;
|
||||||
|
let oauthExpiresIn = 0;
|
||||||
|
|
||||||
|
if (totp) {
|
||||||
|
try {
|
||||||
|
const tokenRes = await fetch('/api/auth/totp-token-exchange', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
||||||
|
});
|
||||||
|
if (tokenRes.ok) {
|
||||||
|
const { access_token, expires_in, has_refresh_token } = await tokenRes.json();
|
||||||
|
// Upgrade client to Bearer auth
|
||||||
|
client.upgradeToBearer(access_token, () => get().refreshAccessToken());
|
||||||
|
oauthAccessToken = access_token;
|
||||||
|
oauthExpiresIn = expires_in;
|
||||||
|
upgradedToOAuth = true;
|
||||||
|
debug.log('TOTP login upgraded to token-based auth (has_refresh_token=' + has_refresh_token + ')');
|
||||||
|
} else {
|
||||||
|
const errorBody = await tokenRes.json().catch(() => ({ error: 'unknown' }));
|
||||||
|
debug.warn('TOTP token exchange failed:', tokenRes.status, errorBody);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debug.warn('TOTP token exchange error:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If token exchange failed, enable TOTP re-auth prompt so the
|
||||||
|
// client can ask for a fresh code on 401 instead of disconnecting.
|
||||||
|
if (!upgradedToOAuth) {
|
||||||
|
const { useTotpReauthStore } = await import('@/stores/totp-reauth-store');
|
||||||
|
client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp());
|
||||||
|
debug.log('TOTP re-auth enabled — user will be prompted for fresh codes on session expiry');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveAuthMode = upgradedToOAuth ? 'oauth' : 'basic';
|
||||||
|
|
||||||
// Store client in multi-account map
|
// Store client in multi-account map
|
||||||
clients.set(accountId, client);
|
clients.set(accountId, client);
|
||||||
bindClientStatusHandlers(client, set, get, accountId);
|
bindClientStatusHandlers(client, set, get, accountId);
|
||||||
@@ -379,7 +421,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
label: primaryIdentity?.name || username,
|
label: primaryIdentity?.name || username,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
username,
|
username,
|
||||||
authMode: 'basic',
|
authMode: effectiveAuthMode,
|
||||||
rememberMe: !!rememberMe,
|
rememberMe: !!rememberMe,
|
||||||
displayName: primaryIdentity?.name || username,
|
displayName: primaryIdentity?.name || username,
|
||||||
email: primaryIdentity?.email || username,
|
email: primaryIdentity?.email || username,
|
||||||
@@ -392,6 +434,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
// Update account entry in case it already existed (addAccount is a no-op for existing accounts)
|
// Update account entry in case it already existed (addAccount is a no-op for existing accounts)
|
||||||
accountStore.updateAccount(accountId, {
|
accountStore.updateAccount(accountId, {
|
||||||
|
authMode: effectiveAuthMode,
|
||||||
rememberMe: !!rememberMe,
|
rememberMe: !!rememberMe,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
hasError: false,
|
hasError: false,
|
||||||
@@ -402,7 +445,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
// Store session cookie BEFORE setting isAuthenticated to avoid a race
|
// Store session cookie BEFORE setting isAuthenticated to avoid a race
|
||||||
// condition: setting isAuthenticated triggers navigation to the main page,
|
// condition: setting isAuthenticated triggers navigation to the main page,
|
||||||
// whose checkAuth() would try to read the cookie before it was stored.
|
// whose checkAuth() would try to read the cookie before it was stored.
|
||||||
if (rememberMe) {
|
if (rememberMe && !upgradedToOAuth) {
|
||||||
|
// For basic auth (no TOTP or TOTP upgrade failed), store encrypted credentials
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, {
|
const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -426,15 +470,20 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
...getClientRateLimitState(client),
|
...getClientRateLimitState(client),
|
||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
authMode: 'basic',
|
authMode: effectiveAuthMode,
|
||||||
rememberMe: !!rememberMe,
|
rememberMe: !!rememberMe,
|
||||||
accessToken: null,
|
accessToken: oauthAccessToken,
|
||||||
tokenExpiresAt: null,
|
tokenExpiresAt: oauthAccessToken ? Date.now() + oauthExpiresIn * 1000 : null,
|
||||||
connectionLost: false,
|
connectionLost: false,
|
||||||
error: null,
|
error: null,
|
||||||
activeAccountId: accountId,
|
activeAccountId: accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Schedule token refresh for TOTP-upgraded sessions
|
||||||
|
if (upgradedToOAuth && oauthExpiresIn > 0) {
|
||||||
|
scheduleRefresh(oauthExpiresIn, get().refreshAccessToken, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
// Sync settings from server (only if enabled)
|
// Sync settings from server (only if enabled)
|
||||||
fetchConfig().then(config => {
|
fetchConfig().then(config => {
|
||||||
if (!config.settingsSyncEnabled) return;
|
if (!config.settingsSyncEnabled) return;
|
||||||
@@ -965,7 +1014,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||||
}
|
}
|
||||||
} else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) {
|
} else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) {
|
||||||
const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`);
|
const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const { serverUrl, username, password } = await res.json();
|
const { serverUrl, username, password } = await res.json();
|
||||||
targetClient = new JMAPClient(serverUrl, username, password);
|
targetClient = new JMAPClient(serverUrl, username, password);
|
||||||
@@ -1130,7 +1179,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
throw new Error(`Token refresh failed: ${res.status}`);
|
throw new Error(`Token refresh failed: ${res.status}`);
|
||||||
}
|
}
|
||||||
} else if (account.authMode === 'basic' && account.rememberMe) {
|
} else if (account.authMode === 'basic' && account.rememberMe) {
|
||||||
const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`);
|
const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'PUT' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const { serverUrl, username, password } = await res.json();
|
const { serverUrl, username, password } = await res.json();
|
||||||
const client = new JMAPClient(serverUrl, username, password);
|
const client = new JMAPClient(serverUrl, username, password);
|
||||||
@@ -1321,7 +1370,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (state.authMode === 'basic') {
|
if (state.authMode === 'basic') {
|
||||||
set({ isLoading: true, isRateLimited: false, rateLimitUntil: null });
|
set({ isLoading: true, isRateLimited: false, rateLimitUntil: null });
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/session');
|
const res = await fetch('/api/auth/session', { method: 'PUT' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.serverUrl || !data.username || !data.password) {
|
if (!data.serverUrl || !data.username || !data.password) {
|
||||||
|
|||||||
+246
-254
@@ -5,6 +5,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/ty
|
|||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||||
|
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||||
|
|
||||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
|
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
|
||||||
|
|
||||||
@@ -132,6 +133,7 @@ interface CalendarStore {
|
|||||||
// iCal subscriptions
|
// iCal subscriptions
|
||||||
icalSubscriptions: ICalSubscription[];
|
icalSubscriptions: ICalSubscription[];
|
||||||
addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
|
addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
|
||||||
|
updateICalSubscription: (client: IJMAPClient, subscriptionId: string, updates: { url?: string; name?: string; color?: string; refreshInterval?: number }) => Promise<void>;
|
||||||
removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
|
removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
|
||||||
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
|
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
|
||||||
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
|
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
|
||||||
@@ -190,13 +192,17 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
before: end,
|
before: end,
|
||||||
});
|
});
|
||||||
// Filter out malformed events missing required 'start' field
|
// Filter out malformed events missing required 'start' field
|
||||||
const events = rawEvents.filter(e => typeof e.start === 'string' && e.start);
|
const validEvents = rawEvents.filter(e => typeof e.start === 'string' && e.start);
|
||||||
const droppedEvents = rawEvents.length - events.length;
|
const droppedEvents = rawEvents.length - validEvents.length;
|
||||||
|
// Expand recurring events client-side (Stalwart doesn't support
|
||||||
|
// mutations on synthetic IDs from server-side expandRecurrences)
|
||||||
|
const events = expandRecurringEvents(validEvents, start, end);
|
||||||
debug.log('Calendar fetchEvents completed', {
|
debug.log('Calendar fetchEvents completed', {
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
rawCount: rawEvents.length,
|
rawCount: rawEvents.length,
|
||||||
usableCount: events.length,
|
validCount: validEvents.length,
|
||||||
|
expandedCount: events.length,
|
||||||
droppedEvents,
|
droppedEvents,
|
||||||
});
|
});
|
||||||
if (droppedEvents > 0) {
|
if (droppedEvents > 0) {
|
||||||
@@ -291,10 +297,18 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
|
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
// Resolve shared event IDs
|
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||||
const storeEvent = get().events.find(e => e.id === id);
|
const storeEvent = get().events.find(e => e.id === id);
|
||||||
const realId = storeEvent?.originalId || id;
|
const realId = storeEvent?.originalId || id;
|
||||||
const targetAccountId = storeEvent?.accountId;
|
const targetAccountId = storeEvent?.accountId;
|
||||||
|
debug.log('Calendar updateEvent', {
|
||||||
|
storeId: id,
|
||||||
|
realId,
|
||||||
|
uid: storeEvent?.uid,
|
||||||
|
recurrenceId: storeEvent?.recurrenceId,
|
||||||
|
targetAccountId,
|
||||||
|
updateKeys: Object.keys(updates),
|
||||||
|
});
|
||||||
// Remap namespaced calendarIds back to original IDs
|
// Remap namespaced calendarIds back to original IDs
|
||||||
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
|
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
|
||||||
if (cleanUpdates.calendarIds) {
|
if (cleanUpdates.calendarIds) {
|
||||||
@@ -305,54 +319,26 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}
|
}
|
||||||
cleanUpdates.calendarIds = remapped;
|
cleanUpdates.calendarIds = remapped;
|
||||||
}
|
}
|
||||||
try {
|
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||||
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
|
||||||
} catch (updateError) {
|
|
||||||
// Stalwart rejects updates to "synthetic" JMAP IDs (CalDAV-created events
|
|
||||||
// or expanded recurring-event instances returned by expandRecurrences).
|
|
||||||
// Resolve the real event via a UID query and retry.
|
|
||||||
const message = updateError instanceof Error ? updateError.message : '';
|
|
||||||
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
|
||||||
debug.log('Event has synthetic ID, resolving real ID via UID query');
|
|
||||||
const queryResults = await client.queryCalendarEvents(
|
|
||||||
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
|
|
||||||
);
|
|
||||||
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
|
|
||||||
if (realEvent) {
|
|
||||||
const resolvedId = realEvent.originalId || realEvent.id;
|
|
||||||
if (storeEvent.recurrenceId) {
|
|
||||||
// Recurring instance: patch the master event's recurrenceOverrides.
|
|
||||||
// Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1
|
|
||||||
const escapedRecurrenceId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
||||||
// Build override object with all changed properties
|
|
||||||
const overrideObj: Record<string, unknown> = {};
|
|
||||||
for (const [key, value] of Object.entries(cleanUpdates as Record<string, unknown>)) {
|
|
||||||
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
|
|
||||||
overrideObj[key] = value;
|
|
||||||
}
|
|
||||||
const patchUpdates: Record<string, unknown> = {
|
|
||||||
[`recurrenceOverrides/${escapedRecurrenceId}`]: overrideObj,
|
|
||||||
};
|
|
||||||
await client.updateCalendarEvent(
|
|
||||||
resolvedId,
|
|
||||||
patchUpdates as unknown as Partial<CalendarEvent>,
|
|
||||||
sendSchedulingMessages,
|
|
||||||
targetAccountId
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Non-recurring event with synthetic ID: retry with the real ID
|
|
||||||
await client.updateCalendarEvent(resolvedId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
|
||||||
}
|
|
||||||
set((state) => ({
|
|
||||||
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e),
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw updateError;
|
|
||||||
}
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e),
|
events: state.events.map(e => {
|
||||||
|
if (e.id !== id) return e;
|
||||||
|
const merged = { ...e, ...cleanUpdates };
|
||||||
|
// When start changes, shift utcStart/utcEnd by the same delta so the
|
||||||
|
// event renders at the new position immediately (optimistic update).
|
||||||
|
if (cleanUpdates.start && e.start && e.utcStart) {
|
||||||
|
const oldStart = new Date(e.start).getTime();
|
||||||
|
const newStart = new Date(cleanUpdates.start).getTime();
|
||||||
|
const delta = newStart - oldStart;
|
||||||
|
if (delta !== 0) {
|
||||||
|
merged.utcStart = new Date(new Date(e.utcStart).getTime() + delta).toISOString();
|
||||||
|
if (e.utcEnd) {
|
||||||
|
merged.utcEnd = new Date(new Date(e.utcEnd).getTime() + delta).toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to update event:', error);
|
debug.error('Failed to update event:', error);
|
||||||
@@ -370,7 +356,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
throw new Error('Invalid participant ID');
|
throw new Error('Invalid participant ID');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Resolve shared event IDs
|
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||||
const storeEvent = get().events.find(e => e.id === eventId);
|
const storeEvent = get().events.find(e => e.id === eventId);
|
||||||
const realId = storeEvent?.originalId || eventId;
|
const realId = storeEvent?.originalId || eventId;
|
||||||
const targetAccountId = storeEvent?.accountId;
|
const targetAccountId = storeEvent?.accountId;
|
||||||
@@ -383,65 +369,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
if (replyTo) {
|
if (replyTo) {
|
||||||
patch.replyTo = replyTo;
|
patch.replyTo = replyTo;
|
||||||
}
|
}
|
||||||
try {
|
await client.updateCalendarEvent(
|
||||||
await client.updateCalendarEvent(
|
realId,
|
||||||
realId,
|
patch as unknown as Partial<CalendarEvent>,
|
||||||
patch as unknown as Partial<CalendarEvent>,
|
true,
|
||||||
true,
|
targetAccountId
|
||||||
targetAccountId
|
);
|
||||||
);
|
|
||||||
} catch (updateError) {
|
|
||||||
// Stalwart rejects updates to synthetic IDs. Resolve real ID via UID query.
|
|
||||||
const message = updateError instanceof Error ? updateError.message : '';
|
|
||||||
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
|
||||||
debug.log('RSVP: Event has synthetic ID, resolving real ID via UID query');
|
|
||||||
const queryResults = await client.queryCalendarEvents(
|
|
||||||
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
|
|
||||||
);
|
|
||||||
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
|
|
||||||
if (realEvent) {
|
|
||||||
const resolvedId = realEvent.originalId || realEvent.id;
|
|
||||||
if (storeEvent.recurrenceId) {
|
|
||||||
// Recurring instance: patch RSVP as recurrence override on master
|
|
||||||
// Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1
|
|
||||||
const escapedRecId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
||||||
const overrideObj: Record<string, unknown> = {
|
|
||||||
[patchKey]: status,
|
|
||||||
};
|
|
||||||
if (replyTo) {
|
|
||||||
overrideObj['replyTo'] = replyTo;
|
|
||||||
}
|
|
||||||
const overridePatch: Record<string, unknown> = {
|
|
||||||
[`recurrenceOverrides/${escapedRecId}`]: overrideObj,
|
|
||||||
};
|
|
||||||
await client.updateCalendarEvent(
|
|
||||||
resolvedId,
|
|
||||||
overridePatch as unknown as Partial<CalendarEvent>,
|
|
||||||
true,
|
|
||||||
targetAccountId
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Non-recurring event: retry RSVP with real ID
|
|
||||||
await client.updateCalendarEvent(
|
|
||||||
resolvedId,
|
|
||||||
patch as unknown as Partial<CalendarEvent>,
|
|
||||||
true,
|
|
||||||
targetAccountId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
set((state) => ({
|
|
||||||
events: state.events.map(e => e.id === eventId ? { ...e, participants: {
|
|
||||||
...e.participants,
|
|
||||||
...(e.participants?.[participantId] ? {
|
|
||||||
[participantId]: { ...e.participants[participantId], participationStatus: status as CalendarParticipant['participationStatus'] },
|
|
||||||
} : {}),
|
|
||||||
}} : e),
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw updateError;
|
|
||||||
}
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.map(e => {
|
events: state.events.map(e => {
|
||||||
if (e.id !== eventId || !e.participants?.[participantId]) return e;
|
if (e.id !== eventId || !e.participants?.[participantId]) return e;
|
||||||
@@ -462,117 +395,175 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
importEvents: async (client, events, calendarId) => {
|
importEvents: async (client, events, calendarId) => {
|
||||||
let imported = 0;
|
|
||||||
// Resolve shared calendar IDs
|
// Resolve shared calendar IDs
|
||||||
const cal = get().calendars.find(c => c.id === calendarId);
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
const realCalendarId = cal?.originalId || calendarId;
|
const realCalendarId = cal?.originalId || calendarId;
|
||||||
const targetAccountId = cal?.accountId;
|
const targetAccountId = cal?.accountId;
|
||||||
for (const event of events) {
|
|
||||||
const src = sanitizeOutgoingCalendarEventData(event as Partial<CalendarEvent>);
|
|
||||||
try {
|
|
||||||
let cleanParticipants: Record<string, CalendarParticipant> | null = null;
|
|
||||||
if (src.participants) {
|
|
||||||
cleanParticipants = {};
|
|
||||||
for (const [key, p] of Object.entries(src.participants)) {
|
|
||||||
const participant: Record<string, unknown> = {
|
|
||||||
'@type': 'Participant',
|
|
||||||
name: p.name,
|
|
||||||
email: p.email,
|
|
||||||
calendarAddress: p.calendarAddress,
|
|
||||||
description: p.description,
|
|
||||||
sendTo: p.sendTo,
|
|
||||||
kind: p.kind,
|
|
||||||
roles: p.roles,
|
|
||||||
participationStatus: p.participationStatus,
|
|
||||||
participationComment: p.participationComment,
|
|
||||||
expectReply: p.expectReply,
|
|
||||||
scheduleAgent: p.scheduleAgent,
|
|
||||||
scheduleForceSend: p.scheduleForceSend,
|
|
||||||
scheduleId: p.scheduleId,
|
|
||||||
delegatedTo: p.delegatedTo,
|
|
||||||
delegatedFrom: p.delegatedFrom,
|
|
||||||
memberOf: p.memberOf,
|
|
||||||
locationId: p.locationId,
|
|
||||||
language: p.language,
|
|
||||||
links: p.links,
|
|
||||||
};
|
|
||||||
Object.keys(participant).forEach(k => {
|
|
||||||
if (participant[k] === undefined || participant[k] === null) delete participant[k];
|
|
||||||
});
|
|
||||||
cleanParticipants[key] = participant as unknown as CalendarParticipant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: Partial<CalendarEvent> = {
|
// Deduplicate UIDs: Stalwart enforces UID uniqueness across all calendars.
|
||||||
calendarIds: { [realCalendarId]: true },
|
// - Events already in the target calendar → skip (true duplicates)
|
||||||
uid: src.uid,
|
// - Events in other calendars → link to target calendar via calendarIds update
|
||||||
title: src.title,
|
// - New events → create as normal
|
||||||
description: src.description,
|
let eventsToProcess = events;
|
||||||
descriptionContentType: src.descriptionContentType,
|
let linked = 0;
|
||||||
start: src.start,
|
try {
|
||||||
duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration,
|
const allServerEvents = await client.getCalendarEvents(undefined, targetAccountId);
|
||||||
timeZone: src.showWithoutTime ? null : src.timeZone,
|
const uidToEvent = new Map<string, { id: string; calendarIds: Record<string, boolean> }>();
|
||||||
showWithoutTime: src.showWithoutTime,
|
for (const e of allServerEvents) {
|
||||||
status: src.status,
|
if (e.uid) {
|
||||||
freeBusyStatus: src.freeBusyStatus,
|
uidToEvent.set(e.uid, {
|
||||||
privacy: src.privacy,
|
id: (e as CalendarEvent).originalId || e.id,
|
||||||
color: src.color,
|
calendarIds: (e as CalendarEvent).originalCalendarIds || e.calendarIds || {},
|
||||||
keywords: src.keywords,
|
});
|
||||||
categories: src.categories,
|
}
|
||||||
locale: src.locale,
|
}
|
||||||
replyTo: src.replyTo || (src.organizerCalendarAddress ? { imip: src.organizerCalendarAddress } : undefined),
|
|
||||||
locations: src.locations,
|
const newEvents: Partial<CalendarEvent>[] = [];
|
||||||
virtualLocations: src.virtualLocations,
|
const eventsToLink: { eventId: string; calendarIds: Record<string, boolean> }[] = [];
|
||||||
links: src.links,
|
|
||||||
recurrenceRules: src.recurrenceRules,
|
for (const e of eventsToProcess) {
|
||||||
recurrenceOverrides: src.recurrenceOverrides,
|
if (!e.uid || !uidToEvent.has(e.uid)) {
|
||||||
excludedRecurrenceRules: src.excludedRecurrenceRules,
|
// UID doesn't exist on server — create it
|
||||||
alerts: src.alerts,
|
newEvents.push(e);
|
||||||
participants: cleanParticipants,
|
} else {
|
||||||
};
|
const existing = uidToEvent.get(e.uid)!;
|
||||||
Object.keys(data).forEach(k => {
|
if (existing.calendarIds[realCalendarId]) {
|
||||||
const v = (data as Record<string, unknown>)[k];
|
// Already in target calendar — skip
|
||||||
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
|
|
||||||
});
|
|
||||||
const created = await client.createCalendarEvent(data, undefined, targetAccountId);
|
|
||||||
const mappedCreated = mapServerEventToStoreEvent(created, get().calendars, targetAccountId);
|
|
||||||
set((state) => ({ events: [...state.events, mappedCreated] }));
|
|
||||||
imported++;
|
|
||||||
} catch (error) {
|
|
||||||
const msg = error instanceof Error ? error.message : '';
|
|
||||||
if ((msg.includes('already exists') || msg.includes('duplicate') || msg.includes('conflict')) && src.uid) {
|
|
||||||
const { events: storeEvents } = get();
|
|
||||||
const alreadyInStore = storeEvents.some((e) => e.uid === src.uid);
|
|
||||||
if (alreadyInStore) {
|
|
||||||
imported++;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
// Exists in another calendar — link to target calendar
|
||||||
const all = await client.queryCalendarEvents({}, undefined, undefined, targetAccountId);
|
eventsToLink.push({
|
||||||
const matching = all.filter((e) => e.uid === src.uid);
|
eventId: existing.id,
|
||||||
if (matching.length > 0) {
|
calendarIds: { ...existing.calendarIds, [realCalendarId]: true },
|
||||||
const existingIds = new Set(storeEvents.map((e) => e.id));
|
});
|
||||||
const newEvents = matching.filter((e) => !existingIds.has(e.id));
|
|
||||||
if (newEvents.length > 0) {
|
|
||||||
set((state) => ({ events: [...state.events, ...newEvents] }));
|
|
||||||
}
|
|
||||||
imported++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through to error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
debug.error('Failed to import event:', event.title, error);
|
}
|
||||||
|
|
||||||
|
// Batch-link existing events to the target calendar
|
||||||
|
for (const { eventId, calendarIds } of eventsToLink) {
|
||||||
|
try {
|
||||||
|
await client.updateCalendarEvent(eventId, { calendarIds } as Partial<CalendarEvent>, undefined, targetAccountId);
|
||||||
|
linked++;
|
||||||
|
} catch (err) {
|
||||||
|
debug.warn(`Import: failed to link event ${eventId} to target calendar:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linked > 0) {
|
||||||
|
debug.log(`Import: linked ${linked} existing events to target calendar`);
|
||||||
|
}
|
||||||
|
const skipped = eventsToProcess.length - newEvents.length - eventsToLink.length;
|
||||||
|
if (skipped > 0) {
|
||||||
|
debug.log(`Import: skipped ${skipped} events already in target calendar`);
|
||||||
|
}
|
||||||
|
eventsToProcess = newEvents;
|
||||||
|
} catch (error) {
|
||||||
|
debug.warn('Could not fetch existing events for deduplication, proceeding without:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare all events for batch creation
|
||||||
|
const prepared: Partial<CalendarEvent>[] = [];
|
||||||
|
for (const event of eventsToProcess) {
|
||||||
|
const src = sanitizeOutgoingCalendarEventData(event as Partial<CalendarEvent>);
|
||||||
|
let cleanParticipants: Record<string, CalendarParticipant> | null = null;
|
||||||
|
if (src.participants) {
|
||||||
|
cleanParticipants = {};
|
||||||
|
for (const [key, p] of Object.entries(src.participants)) {
|
||||||
|
const participant: Record<string, unknown> = {
|
||||||
|
'@type': 'Participant',
|
||||||
|
name: p.name,
|
||||||
|
email: p.email,
|
||||||
|
calendarAddress: p.calendarAddress,
|
||||||
|
description: p.description,
|
||||||
|
sendTo: p.sendTo,
|
||||||
|
kind: p.kind,
|
||||||
|
roles: p.roles,
|
||||||
|
participationStatus: p.participationStatus,
|
||||||
|
participationComment: p.participationComment,
|
||||||
|
expectReply: p.expectReply,
|
||||||
|
scheduleAgent: p.scheduleAgent,
|
||||||
|
scheduleForceSend: p.scheduleForceSend,
|
||||||
|
scheduleId: p.scheduleId,
|
||||||
|
delegatedTo: p.delegatedTo,
|
||||||
|
delegatedFrom: p.delegatedFrom,
|
||||||
|
memberOf: p.memberOf,
|
||||||
|
locationId: p.locationId,
|
||||||
|
language: p.language,
|
||||||
|
links: p.links,
|
||||||
|
};
|
||||||
|
Object.keys(participant).forEach(k => {
|
||||||
|
if (participant[k] === undefined || participant[k] === null) delete participant[k];
|
||||||
|
});
|
||||||
|
cleanParticipants[key] = participant as unknown as CalendarParticipant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Partial<CalendarEvent> = {
|
||||||
|
calendarIds: { [realCalendarId]: true },
|
||||||
|
uid: src.uid,
|
||||||
|
title: src.title,
|
||||||
|
description: src.description,
|
||||||
|
descriptionContentType: src.descriptionContentType,
|
||||||
|
start: src.start,
|
||||||
|
duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration,
|
||||||
|
timeZone: src.showWithoutTime ? null : src.timeZone,
|
||||||
|
showWithoutTime: src.showWithoutTime,
|
||||||
|
status: src.status,
|
||||||
|
freeBusyStatus: src.freeBusyStatus,
|
||||||
|
privacy: src.privacy,
|
||||||
|
color: src.color,
|
||||||
|
keywords: src.keywords,
|
||||||
|
categories: src.categories,
|
||||||
|
locale: src.locale,
|
||||||
|
replyTo: src.replyTo || (src.organizerCalendarAddress ? { imip: src.organizerCalendarAddress } : undefined),
|
||||||
|
locations: src.locations,
|
||||||
|
virtualLocations: src.virtualLocations,
|
||||||
|
links: src.links,
|
||||||
|
recurrenceRules: src.recurrenceRules,
|
||||||
|
recurrenceOverrides: src.recurrenceOverrides,
|
||||||
|
excludedRecurrenceRules: src.excludedRecurrenceRules,
|
||||||
|
alerts: src.alerts,
|
||||||
|
participants: cleanParticipants,
|
||||||
|
};
|
||||||
|
Object.keys(data).forEach(k => {
|
||||||
|
const v = (data as Record<string, unknown>)[k];
|
||||||
|
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
|
||||||
|
});
|
||||||
|
prepared.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prepared.length === 0) return linked;
|
||||||
|
|
||||||
|
// Batch create in chunks of 50 to avoid oversized requests
|
||||||
|
const BATCH_SIZE = 50;
|
||||||
|
let imported = 0;
|
||||||
|
for (let i = 0; i < prepared.length; i += BATCH_SIZE) {
|
||||||
|
const batch = prepared.slice(i, i + BATCH_SIZE);
|
||||||
|
try {
|
||||||
|
const { created, failed } = await client.batchCreateCalendarEvents(batch, targetAccountId);
|
||||||
|
imported += created.length;
|
||||||
|
if (failed.length > 0) {
|
||||||
|
debug.warn(`Import batch ${i / BATCH_SIZE + 1}: ${failed.length} events failed`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug.error(`Import batch ${i / BATCH_SIZE + 1} failed:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return imported;
|
|
||||||
|
// Re-fetch all events properly so the store state is consistent
|
||||||
|
// (with recurrence expansion, multi-account mapping, etc.)
|
||||||
|
const { dateRange } = get();
|
||||||
|
if (dateRange) {
|
||||||
|
await get().fetchEvents(client, dateRange.start, dateRange.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
return imported + linked;
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
// Resolve shared event IDs
|
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||||
const storeEvent = get().events.find(e => e.id === id);
|
const storeEvent = get().events.find(e => e.id === id);
|
||||||
const realId = storeEvent?.originalId || id;
|
const realId = storeEvent?.originalId || id;
|
||||||
const targetAccountId = storeEvent?.accountId;
|
const targetAccountId = storeEvent?.accountId;
|
||||||
@@ -586,39 +577,14 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
debug.error('Failed to send cancellation emails:', e);
|
debug.error('Failed to send cancellation emails:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
debug.log('Calendar deleteEvent', {
|
||||||
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
|
storeId: id,
|
||||||
} catch (deleteError) {
|
realId,
|
||||||
// Stalwart rejects deletes on synthetic IDs (CalDAV-created events or
|
uid: storeEvent?.uid,
|
||||||
// expanded recurring instances). Resolve the real ID via UID query.
|
recurrenceId: storeEvent?.recurrenceId,
|
||||||
const message = deleteError instanceof Error ? deleteError.message : '';
|
targetAccountId,
|
||||||
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
});
|
||||||
debug.log('Event has synthetic ID, resolving real ID via UID query for delete');
|
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
|
||||||
const queryResults = await client.queryCalendarEvents(
|
|
||||||
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
|
|
||||||
);
|
|
||||||
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
|
|
||||||
if (realEvent) {
|
|
||||||
const resolvedId = realEvent.originalId || realEvent.id;
|
|
||||||
if (storeEvent.recurrenceId) {
|
|
||||||
// Recurring instance: exclude via recurrenceOverrides on master
|
|
||||||
await client.updateCalendarEvent(
|
|
||||||
resolvedId,
|
|
||||||
{ [`recurrenceOverrides/${storeEvent.recurrenceId}`]: { excluded: true } } as unknown as Partial<CalendarEvent>,
|
|
||||||
false,
|
|
||||||
targetAccountId
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Non-recurring event: delete using the real ID
|
|
||||||
await client.deleteCalendarEvent(resolvedId, sendSchedulingMessages, targetAccountId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw deleteError;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw deleteError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.filter(e => e.id !== id),
|
events: state.events.filter(e => e.id !== id),
|
||||||
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
||||||
@@ -788,6 +754,38 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateICalSubscription: async (client, subscriptionId, updates) => {
|
||||||
|
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
|
||||||
|
if (!sub) return;
|
||||||
|
|
||||||
|
// Update the calendar on the server if name or color changed
|
||||||
|
if (updates.name || updates.color) {
|
||||||
|
const calUpdates: Record<string, unknown> = {};
|
||||||
|
if (updates.name) calUpdates.name = updates.name;
|
||||||
|
if (updates.color) calUpdates.color = updates.color;
|
||||||
|
await client.updateCalendar(sub.calendarId, calUpdates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local subscription record
|
||||||
|
const updated = { ...sub, ...updates };
|
||||||
|
set((state) => ({
|
||||||
|
icalSubscriptions: state.icalSubscriptions.map(s => s.id === subscriptionId ? updated : s),
|
||||||
|
calendars: state.calendars.map(c => {
|
||||||
|
if (c.id !== sub.calendarId) return c;
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
...(updates.name ? { name: updates.name } : {}),
|
||||||
|
...(updates.color ? { color: updates.color } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// If URL changed, refresh to fetch events from new source
|
||||||
|
if (updates.url && updates.url !== sub.url) {
|
||||||
|
await get().refreshICalSubscription(client, subscriptionId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
removeICalSubscription: async (client, subscriptionId) => {
|
removeICalSubscription: async (client, subscriptionId) => {
|
||||||
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
|
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
|
||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
@@ -858,24 +856,18 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
// Import only events that don't already exist on server
|
// Import only events that don't already exist on server
|
||||||
const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid));
|
const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid));
|
||||||
|
|
||||||
// Remove stale local events for this calendar
|
// Import new events (importEvents will re-fetch all events at the end)
|
||||||
set((state) => ({
|
|
||||||
events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Import new events
|
|
||||||
if (eventsToImport.length > 0) {
|
if (eventsToImport.length > 0) {
|
||||||
await get().importEvents(client, eventsToImport, sub.calendarId);
|
await get().importEvents(client, eventsToImport, sub.calendarId);
|
||||||
|
} else {
|
||||||
|
// No new events to import, but stale ones may have been deleted
|
||||||
|
// Re-fetch to reflect deletions
|
||||||
|
const { dateRange } = get();
|
||||||
|
if (dateRange) {
|
||||||
|
await get().fetchEvents(client, dateRange.start, dateRange.end);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-fetch ALL events from server and filter for this calendar
|
|
||||||
const allUpdatedEvents = await client.getCalendarEvents();
|
|
||||||
const updatedEvents = allUpdatedEvents.filter(e => e.calendarIds?.[sub.calendarId]);
|
|
||||||
set((state) => {
|
|
||||||
const otherEvents = state.events.filter(e => !e.calendarIds?.[sub.calendarId]);
|
|
||||||
return { events: [...otherEvents, ...updatedEvents] };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update last refreshed timestamp
|
// Update last refreshed timestamp
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
icalSubscriptions: state.icalSubscriptions.map(s =>
|
icalSubscriptions: state.icalSubscriptions.map(s =>
|
||||||
|
|||||||
+43
-10
@@ -342,6 +342,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Get emails per page from settings
|
// Get emails per page from settings
|
||||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
|
|
||||||
|
// Capture position from current email count before the async call
|
||||||
|
const position = emails.length;
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
|
|
||||||
const { searchFilters } = get();
|
const { searchFilters } = get();
|
||||||
@@ -355,9 +358,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
if (hasFilters) {
|
if (hasFilters) {
|
||||||
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||||||
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, emails.length);
|
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, position);
|
||||||
} else {
|
} else {
|
||||||
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
|
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Load more from mailbox
|
// Load more from mailbox
|
||||||
@@ -369,11 +372,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
||||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||||
|
|
||||||
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
|
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use fresh state when merging to avoid overwriting concurrent updates
|
||||||
|
// (e.g. refreshCurrentMailbox running during the load)
|
||||||
|
const currentEmails = get().emails;
|
||||||
|
|
||||||
|
// Deduplicate: the server may return overlapping results if new emails
|
||||||
|
// arrived between paginated requests and shifted positions.
|
||||||
|
const existingIds = new Set(currentEmails.map(e => e.id));
|
||||||
|
const newEmails = result.emails.filter((e: Email) => !existingIds.has(e.id));
|
||||||
|
|
||||||
set({
|
set({
|
||||||
emails: [...emails, ...result.emails],
|
emails: [...currentEmails, ...newEmails],
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
isLoadingMore: false
|
isLoadingMore: false
|
||||||
@@ -1242,12 +1254,30 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
get().handleNewEmailNotification(result.emails[0]);
|
get().handleNewEmailNotification(result.emails[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip state update if emails haven't actually changed to avoid
|
// Merge the refreshed first page with the existing loaded emails.
|
||||||
// unnecessary re-renders that cause a visible list flicker
|
// This avoids discarding already-loaded pages which would cause the
|
||||||
|
// virtual list to shrink and then rapidly re-load (scroll bounce).
|
||||||
|
const freshMap = new Map(result.emails.map((e: Email) => [e.id, e]));
|
||||||
|
|
||||||
|
// Build the merged list: start with the fresh first page, then append
|
||||||
|
// existing emails beyond that page (if any), skipping duplicates and
|
||||||
|
// emails removed from the first page (e.g. deleted or moved).
|
||||||
|
const merged: Email[] = [...result.emails];
|
||||||
|
const mergedIds = new Set(result.emails.map((e: Email) => e.id));
|
||||||
|
|
||||||
|
for (const email of currentEmails) {
|
||||||
|
if (!mergedIds.has(email.id)) {
|
||||||
|
merged.push(email);
|
||||||
|
mergedIds.add(email.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if anything actually changed to avoid unnecessary re-renders
|
||||||
const hasChanged =
|
const hasChanged =
|
||||||
currentEmails.length !== result.emails.length ||
|
currentEmails.length !== merged.length ||
|
||||||
result.emails.some((email, i) => {
|
merged.some((email, i) => {
|
||||||
const curr = currentEmails[i];
|
const curr = currentEmails[i];
|
||||||
|
if (!curr) return true;
|
||||||
return (
|
return (
|
||||||
curr.id !== email.id ||
|
curr.id !== email.id ||
|
||||||
curr.threadId !== email.threadId ||
|
curr.threadId !== email.threadId ||
|
||||||
@@ -1256,9 +1286,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (hasChanged) {
|
if (hasChanged) {
|
||||||
|
// hasMore should reflect whether there are still more emails beyond
|
||||||
|
// what we have loaded, using the fresh total from the server.
|
||||||
|
const hasMore = merged.length < (result.total || 0);
|
||||||
set({
|
set({
|
||||||
emails: result.emails,
|
emails: merged,
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-5
@@ -52,8 +52,12 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
const capabilities = client.getSieveCapabilities();
|
const capabilities = client.getSieveCapabilities();
|
||||||
set({ sieveCapabilities: capabilities });
|
set({ sieveCapabilities: capabilities });
|
||||||
|
|
||||||
const scripts = await client.getSieveScripts();
|
const allScripts = await client.getSieveScripts();
|
||||||
debug.log('Sieve scripts fetched:', scripts.length);
|
debug.log('Sieve scripts fetched:', allScripts.length);
|
||||||
|
|
||||||
|
// Skip the server-managed 'vacation' script (RFC 9661 §4) — it can only
|
||||||
|
// be modified via VacationResponse/set, not SieveScript/set.
|
||||||
|
const scripts = allScripts.filter(s => s.name !== 'vacation');
|
||||||
|
|
||||||
const activeScript = scripts.find(s => s.isActive) || scripts[0];
|
const activeScript = scripts.find(s => s.isActive) || scripts[0];
|
||||||
if (!activeScript) {
|
if (!activeScript) {
|
||||||
@@ -163,7 +167,9 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
|
|
||||||
// Always re-fetch scripts from the server to get the current state
|
// Always re-fetch scripts from the server to get the current state
|
||||||
// after Stalwart may have rewritten the active script.
|
// after Stalwart may have rewritten the active script.
|
||||||
const scripts = await client.getSieveScripts();
|
const allScripts = await client.getSieveScripts();
|
||||||
|
// Skip the server-managed 'vacation' script (RFC 9661 §4)
|
||||||
|
const scripts = allScripts.filter(s => s.name !== 'vacation');
|
||||||
const activeScript = scripts.find(s => s.isActive) || scripts[0];
|
const activeScript = scripts.find(s => s.isActive) || scripts[0];
|
||||||
|
|
||||||
let rules = previousRules;
|
let rules = previousRules;
|
||||||
@@ -182,7 +188,10 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
const content = generateScript(rules, vacation.isEnabled ? vacation : undefined);
|
const content = generateScript(rules, vacation.isEnabled ? vacation : undefined);
|
||||||
|
|
||||||
if (activeScript) {
|
if (activeScript) {
|
||||||
await client.updateSieveScript(activeScript.id, content, true);
|
// Preserve the script's current activation state — don't pass activate: true
|
||||||
|
// unconditionally, as that would deactivate the server-managed 'vacation'
|
||||||
|
// script and cause VacationResponse/get to return isEnabled: false.
|
||||||
|
await client.updateSieveScript(activeScript.id, content, activeScript.isActive);
|
||||||
set({
|
set({
|
||||||
activeScriptId: activeScript.id,
|
activeScriptId: activeScript.id,
|
||||||
rawScript: content,
|
rawScript: content,
|
||||||
@@ -191,7 +200,9 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
isOpaque: false,
|
isOpaque: false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const script = await client.createSieveScript('filters', content, true);
|
// Don't activate; there may be a server-managed 'vacation' script active.
|
||||||
|
// The filters script will be activated when the user saves filters normally.
|
||||||
|
const script = await client.createSieveScript('filters', content, false);
|
||||||
set({
|
set({
|
||||||
activeScriptId: script.id,
|
activeScriptId: script.id,
|
||||||
rawScript: content,
|
rawScript: content,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { usePolicyStore } from '@/stores/policy-store';
|
|||||||
const SLOT_NAMES: SlotName[] = [
|
const SLOT_NAMES: SlotName[] = [
|
||||||
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
|
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
|
||||||
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
|
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
|
||||||
|
'calendar-event-actions', 'admin-plugin-page',
|
||||||
];
|
];
|
||||||
|
|
||||||
function emptySlots(): Record<SlotName, SlotRegistration[]> {
|
function emptySlots(): Record<SlotName, SlotRegistration[]> {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export type MailAttachmentAction = 'preview' | 'download';
|
|||||||
export type AttachmentPosition = 'beside-sender' | 'below-header';
|
export type AttachmentPosition = 'beside-sender' | 'below-header';
|
||||||
export type ToolbarPosition = 'top' | 'below-subject';
|
export type ToolbarPosition = 'top' | 'below-subject';
|
||||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||||
|
export type MailLayout = 'split' | 'focus';
|
||||||
|
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
|
||||||
|
|
||||||
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
|
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
|
||||||
export type HoverActionsMode = 'inline' | 'floating';
|
export type HoverActionsMode = 'inline' | 'floating';
|
||||||
@@ -105,6 +107,7 @@ interface SettingsState {
|
|||||||
deleteAction: DeleteAction;
|
deleteAction: DeleteAction;
|
||||||
permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash
|
permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash
|
||||||
showPreview: boolean;
|
showPreview: boolean;
|
||||||
|
mailLayout: MailLayout;
|
||||||
emailsPerPage: number;
|
emailsPerPage: number;
|
||||||
externalContentPolicy: ExternalContentPolicy;
|
externalContentPolicy: ExternalContentPolicy;
|
||||||
mailAttachmentAction: MailAttachmentAction;
|
mailAttachmentAction: MailAttachmentAction;
|
||||||
@@ -119,6 +122,7 @@ interface SettingsState {
|
|||||||
autoSaveDraftInterval: number; // milliseconds
|
autoSaveDraftInterval: number; // milliseconds
|
||||||
sendConfirmation: boolean;
|
sendConfirmation: boolean;
|
||||||
defaultReplyMode: ReplyMode;
|
defaultReplyMode: ReplyMode;
|
||||||
|
autoSelectReplyIdentity: boolean;
|
||||||
plainTextMode: boolean; // Send plain text only (no rich text editor)
|
plainTextMode: boolean; // Send plain text only (no rich text editor)
|
||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
@@ -131,6 +135,7 @@ interface SettingsState {
|
|||||||
// Calendar
|
// Calendar
|
||||||
showTimeInMonthView: boolean;
|
showTimeInMonthView: boolean;
|
||||||
showWeekNumbers: boolean;
|
showWeekNumbers: boolean;
|
||||||
|
calendarHoverPreview: CalendarHoverPreview;
|
||||||
|
|
||||||
// Calendar Tasks
|
// Calendar Tasks
|
||||||
enableCalendarTasks: boolean;
|
enableCalendarTasks: boolean;
|
||||||
@@ -149,6 +154,8 @@ interface SettingsState {
|
|||||||
// Layout
|
// Layout
|
||||||
toolbarPosition: ToolbarPosition;
|
toolbarPosition: ToolbarPosition;
|
||||||
showToolbarLabels: boolean;
|
showToolbarLabels: boolean;
|
||||||
|
hideAccountSwitcher: boolean;
|
||||||
|
showRailAccountList: boolean;
|
||||||
|
|
||||||
// Email Display
|
// Email Display
|
||||||
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
|
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
|
||||||
@@ -224,6 +231,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
deleteAction: 'trash' as DeleteAction,
|
deleteAction: 'trash' as DeleteAction,
|
||||||
permanentlyDeleteJunk: false,
|
permanentlyDeleteJunk: false,
|
||||||
showPreview: true,
|
showPreview: true,
|
||||||
|
mailLayout: 'split' as MailLayout,
|
||||||
emailsPerPage: 50,
|
emailsPerPage: 50,
|
||||||
externalContentPolicy: 'ask' as ExternalContentPolicy,
|
externalContentPolicy: 'ask' as ExternalContentPolicy,
|
||||||
mailAttachmentAction: 'preview' as MailAttachmentAction,
|
mailAttachmentAction: 'preview' as MailAttachmentAction,
|
||||||
@@ -238,6 +246,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
autoSaveDraftInterval: 60000, // 1 minute
|
autoSaveDraftInterval: 60000, // 1 minute
|
||||||
sendConfirmation: false,
|
sendConfirmation: false,
|
||||||
defaultReplyMode: 'reply' as ReplyMode,
|
defaultReplyMode: 'reply' as ReplyMode,
|
||||||
|
autoSelectReplyIdentity: false,
|
||||||
plainTextMode: false,
|
plainTextMode: false,
|
||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
@@ -250,6 +259,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
// Calendar
|
// Calendar
|
||||||
showTimeInMonthView: false,
|
showTimeInMonthView: false,
|
||||||
showWeekNumbers: false,
|
showWeekNumbers: false,
|
||||||
|
calendarHoverPreview: 'delay-500ms' as CalendarHoverPreview,
|
||||||
|
|
||||||
// Calendar Tasks
|
// Calendar Tasks
|
||||||
enableCalendarTasks: false,
|
enableCalendarTasks: false,
|
||||||
@@ -268,6 +278,8 @@ const DEFAULT_SETTINGS = {
|
|||||||
// Layout
|
// Layout
|
||||||
toolbarPosition: 'top' as ToolbarPosition,
|
toolbarPosition: 'top' as ToolbarPosition,
|
||||||
showToolbarLabels: true,
|
showToolbarLabels: true,
|
||||||
|
hideAccountSwitcher: false,
|
||||||
|
showRailAccountList: false,
|
||||||
|
|
||||||
// Email Display
|
// Email Display
|
||||||
disableThreading: false,
|
disableThreading: false,
|
||||||
@@ -333,6 +345,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
markAsReadDelay: state.markAsReadDelay,
|
markAsReadDelay: state.markAsReadDelay,
|
||||||
deleteAction: state.deleteAction,
|
deleteAction: state.deleteAction,
|
||||||
showPreview: state.showPreview,
|
showPreview: state.showPreview,
|
||||||
|
mailLayout: state.mailLayout,
|
||||||
emailsPerPage: state.emailsPerPage,
|
emailsPerPage: state.emailsPerPage,
|
||||||
externalContentPolicy: state.externalContentPolicy,
|
externalContentPolicy: state.externalContentPolicy,
|
||||||
mailAttachmentAction: state.mailAttachmentAction,
|
mailAttachmentAction: state.mailAttachmentAction,
|
||||||
@@ -346,6 +359,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
autoSaveDraftInterval: state.autoSaveDraftInterval,
|
autoSaveDraftInterval: state.autoSaveDraftInterval,
|
||||||
sendConfirmation: state.sendConfirmation,
|
sendConfirmation: state.sendConfirmation,
|
||||||
defaultReplyMode: state.defaultReplyMode,
|
defaultReplyMode: state.defaultReplyMode,
|
||||||
|
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
|
||||||
plainTextMode: state.plainTextMode,
|
plainTextMode: state.plainTextMode,
|
||||||
sessionTimeout: state.sessionTimeout,
|
sessionTimeout: state.sessionTimeout,
|
||||||
emailNotificationsEnabled: state.emailNotificationsEnabled,
|
emailNotificationsEnabled: state.emailNotificationsEnabled,
|
||||||
@@ -359,7 +373,10 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
expandedFilterView: state.expandedFilterView,
|
expandedFilterView: state.expandedFilterView,
|
||||||
showTimeInMonthView: state.showTimeInMonthView,
|
showTimeInMonthView: state.showTimeInMonthView,
|
||||||
showWeekNumbers: state.showWeekNumbers,
|
showWeekNumbers: state.showWeekNumbers,
|
||||||
|
calendarHoverPreview: state.calendarHoverPreview,
|
||||||
toolbarPosition: state.toolbarPosition,
|
toolbarPosition: state.toolbarPosition,
|
||||||
|
hideAccountSwitcher: state.hideAccountSwitcher,
|
||||||
|
showRailAccountList: state.showRailAccountList,
|
||||||
senderFavicons: state.senderFavicons,
|
senderFavicons: state.senderFavicons,
|
||||||
folderIcons: state.folderIcons,
|
folderIcons: state.folderIcons,
|
||||||
emailKeywords: state.emailKeywords,
|
emailKeywords: state.emailKeywords,
|
||||||
|
|||||||
+137
-26
@@ -91,10 +91,12 @@ function clearRememberedUnlocks(): void {
|
|||||||
async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
|
async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
|
||||||
unlockedKeys: Map<string, CryptoKey>;
|
unlockedKeys: Map<string, CryptoKey>;
|
||||||
unlockedDecryptionKeys: Map<string, CryptoKey>;
|
unlockedDecryptionKeys: Map<string, CryptoKey>;
|
||||||
|
unlockedLegacyDecryptionKeys: Map<string, CryptoKey>;
|
||||||
}> {
|
}> {
|
||||||
const rememberedUnlocks = readRememberedUnlocks();
|
const rememberedUnlocks = readRememberedUnlocks();
|
||||||
const unlockedKeys = new Map<string, CryptoKey>();
|
const unlockedKeys = new Map<string, CryptoKey>();
|
||||||
const unlockedDecryptionKeys = new Map<string, CryptoKey>();
|
const unlockedDecryptionKeys = new Map<string, CryptoKey>();
|
||||||
|
const unlockedLegacyDecryptionKeys = new Map<string, CryptoKey>();
|
||||||
let removedStaleEntries = false;
|
let removedStaleEntries = false;
|
||||||
|
|
||||||
for (const record of keyRecords) {
|
for (const record of keyRecords) {
|
||||||
@@ -104,11 +106,14 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { signingKey, decryptionKey } = await unlockPrivateKey(record, passphrase);
|
const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase);
|
||||||
unlockedKeys.set(record.id, signingKey);
|
unlockedKeys.set(record.id, signingKey);
|
||||||
if (decryptionKey) {
|
if (decryptionKey) {
|
||||||
unlockedDecryptionKeys.set(record.id, decryptionKey);
|
unlockedDecryptionKeys.set(record.id, decryptionKey);
|
||||||
}
|
}
|
||||||
|
if (legacyDecryptionKey) {
|
||||||
|
unlockedLegacyDecryptionKeys.set(record.id, legacyDecryptionKey);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
delete rememberedUnlocks[record.id];
|
delete rememberedUnlocks[record.id];
|
||||||
removedStaleEntries = true;
|
removedStaleEntries = true;
|
||||||
@@ -119,29 +124,40 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
|
|||||||
writeRememberedUnlocks(rememberedUnlocks);
|
writeRememberedUnlocks(rememberedUnlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { unlockedKeys, unlockedDecryptionKeys };
|
return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SmimePersistedState {
|
interface SmimePersistedState {
|
||||||
identityKeyBindings: Record<string, string>; // identityId → keyRecordId
|
/** Account-scoped preferences: accountId → { identityKeyBindings, defaultSignIdentity, defaultEncrypt } */
|
||||||
defaultSignIdentity: Record<string, boolean>; // identityId → sign by default
|
accountPreferences: Record<string, {
|
||||||
defaultEncrypt: boolean;
|
identityKeyBindings: Record<string, string>;
|
||||||
|
defaultSignIdentity: Record<string, boolean>;
|
||||||
|
defaultEncrypt: boolean;
|
||||||
|
}>;
|
||||||
rememberUnlockedKeys: boolean;
|
rememberUnlockedKeys: boolean;
|
||||||
autoImportSignerCerts: boolean;
|
autoImportSignerCerts: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SmimeStore extends SmimePersistedState {
|
interface SmimeStore extends SmimePersistedState {
|
||||||
|
// Current account scope
|
||||||
|
currentAccountId: string | null;
|
||||||
|
// Account-scoped convenience accessors (derived from accountPreferences + currentAccountId)
|
||||||
|
identityKeyBindings: Record<string, string>;
|
||||||
|
defaultSignIdentity: Record<string, boolean>;
|
||||||
|
defaultEncrypt: boolean;
|
||||||
// Loaded from IndexedDB
|
// Loaded from IndexedDB
|
||||||
keyRecords: SmimeKeyRecord[];
|
keyRecords: SmimeKeyRecord[];
|
||||||
publicCerts: SmimePublicCert[];
|
publicCerts: SmimePublicCert[];
|
||||||
// Runtime only — never persisted
|
// Runtime only — never persisted
|
||||||
unlockedKeys: Map<string, CryptoKey>;
|
unlockedKeys: Map<string, CryptoKey>;
|
||||||
unlockedDecryptionKeys: Map<string, CryptoKey>;
|
unlockedDecryptionKeys: Map<string, CryptoKey>;
|
||||||
|
unlockedLegacyDecryptionKeys: Map<string, CryptoKey>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
load: () => Promise<void>;
|
load: (accountId?: string) => Promise<void>;
|
||||||
|
clearState: () => void;
|
||||||
importPKCS12: (file: ArrayBuffer, p12Passphrase: string, storagePassphrase: string) => Promise<SmimeKeyRecord>;
|
importPKCS12: (file: ArrayBuffer, p12Passphrase: string, storagePassphrase: string) => Promise<SmimeKeyRecord>;
|
||||||
importPublicCert: (data: ArrayBuffer | string, source: SmimePublicCert['source'], contactId?: string) => Promise<SmimePublicCert>;
|
importPublicCert: (data: ArrayBuffer | string, source: SmimePublicCert['source'], contactId?: string) => Promise<SmimePublicCert>;
|
||||||
bindIdentityToKey: (identityId: string, keyRecordId: string | null) => void;
|
bindIdentityToKey: (identityId: string, keyRecordId: string | null) => void;
|
||||||
@@ -166,26 +182,47 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
// Persisted preferences
|
// Persisted preferences
|
||||||
|
accountPreferences: {},
|
||||||
|
rememberUnlockedKeys: false,
|
||||||
|
autoImportSignerCerts: true,
|
||||||
|
|
||||||
|
// Runtime state
|
||||||
|
currentAccountId: null,
|
||||||
identityKeyBindings: {},
|
identityKeyBindings: {},
|
||||||
defaultSignIdentity: {},
|
defaultSignIdentity: {},
|
||||||
defaultEncrypt: false,
|
defaultEncrypt: false,
|
||||||
rememberUnlockedKeys: false,
|
|
||||||
autoImportSignerCerts: false,
|
|
||||||
|
|
||||||
// Runtime state
|
|
||||||
keyRecords: [],
|
keyRecords: [],
|
||||||
publicCerts: [],
|
publicCerts: [],
|
||||||
unlockedKeys: new Map(),
|
unlockedKeys: new Map(),
|
||||||
unlockedDecryptionKeys: new Map(),
|
unlockedDecryptionKeys: new Map(),
|
||||||
|
unlockedLegacyDecryptionKeys: new Map(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
load: async () => {
|
load: async (accountId) => {
|
||||||
set({ isLoading: true, error: null });
|
const acctId = accountId ?? get().currentAccountId;
|
||||||
|
set({ isLoading: true, error: null, currentAccountId: acctId });
|
||||||
|
|
||||||
|
// Restore account-scoped preferences
|
||||||
|
const prefs = acctId ? get().accountPreferences[acctId] : undefined;
|
||||||
|
if (prefs) {
|
||||||
|
set({
|
||||||
|
identityKeyBindings: prefs.identityKeyBindings,
|
||||||
|
defaultSignIdentity: prefs.defaultSignIdentity,
|
||||||
|
defaultEncrypt: prefs.defaultEncrypt,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
identityKeyBindings: {},
|
||||||
|
defaultSignIdentity: {},
|
||||||
|
defaultEncrypt: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [keyRecords, publicCerts] = await Promise.all([
|
const [keyRecords, publicCerts] = await Promise.all([
|
||||||
listKeyRecords(),
|
listKeyRecords(acctId ?? undefined),
|
||||||
listPublicCerts(),
|
listPublicCerts(acctId ?? undefined),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (get().rememberUnlockedKeys) {
|
if (get().rememberUnlockedKeys) {
|
||||||
@@ -201,6 +238,10 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
...state.unlockedDecryptionKeys,
|
...state.unlockedDecryptionKeys,
|
||||||
...restoredKeys.unlockedDecryptionKeys,
|
...restoredKeys.unlockedDecryptionKeys,
|
||||||
]),
|
]),
|
||||||
|
unlockedLegacyDecryptionKeys: new Map([
|
||||||
|
...state.unlockedLegacyDecryptionKeys,
|
||||||
|
...restoredKeys.unlockedLegacyDecryptionKeys,
|
||||||
|
]),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
@@ -219,6 +260,8 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const { keyRecord } = await importPkcs12(file, p12Passphrase, storagePassphrase);
|
const { keyRecord } = await importPkcs12(file, p12Passphrase, storagePassphrase);
|
||||||
|
const acctId = get().currentAccountId;
|
||||||
|
if (acctId) keyRecord.accountId = acctId;
|
||||||
await saveKeyRecord(keyRecord);
|
await saveKeyRecord(keyRecord);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
keyRecords: [...state.keyRecords, keyRecord],
|
keyRecords: [...state.keyRecords, keyRecord],
|
||||||
@@ -245,6 +288,7 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
|
|
||||||
const publicCert: SmimePublicCert = {
|
const publicCert: SmimePublicCert = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
|
accountId: get().currentAccountId ?? undefined,
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
certificate: der,
|
certificate: der,
|
||||||
issuer: info.issuer,
|
issuer: info.issuer,
|
||||||
@@ -279,7 +323,15 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
} else {
|
} else {
|
||||||
bindings[identityId] = keyRecordId;
|
bindings[identityId] = keyRecordId;
|
||||||
}
|
}
|
||||||
return { identityKeyBindings: bindings };
|
const accountPreferences = { ...state.accountPreferences };
|
||||||
|
const acctId = state.currentAccountId;
|
||||||
|
if (acctId) {
|
||||||
|
accountPreferences[acctId] = {
|
||||||
|
...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }),
|
||||||
|
identityKeyBindings: bindings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { identityKeyBindings: bindings, accountPreferences };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -291,16 +343,25 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
unlockedKeys.delete(id);
|
unlockedKeys.delete(id);
|
||||||
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
|
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
|
||||||
unlockedDecryptionKeys.delete(id);
|
unlockedDecryptionKeys.delete(id);
|
||||||
|
const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys);
|
||||||
|
unlockedLegacyDecryptionKeys.delete(id);
|
||||||
// Remove any identity bindings pointing to this key
|
// Remove any identity bindings pointing to this key
|
||||||
const bindings = { ...state.identityKeyBindings };
|
const bindings = { ...state.identityKeyBindings };
|
||||||
for (const [identityId, keyId] of Object.entries(bindings)) {
|
for (const [identityId, keyId] of Object.entries(bindings)) {
|
||||||
if (keyId === id) delete bindings[identityId];
|
if (keyId === id) delete bindings[identityId];
|
||||||
}
|
}
|
||||||
|
const accountPreferences = { ...state.accountPreferences };
|
||||||
|
const acctId = state.currentAccountId;
|
||||||
|
if (acctId && accountPreferences[acctId]) {
|
||||||
|
accountPreferences[acctId] = { ...accountPreferences[acctId], identityKeyBindings: bindings };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
keyRecords: state.keyRecords.filter((k) => k.id !== id),
|
keyRecords: state.keyRecords.filter((k) => k.id !== id),
|
||||||
unlockedKeys,
|
unlockedKeys,
|
||||||
unlockedDecryptionKeys,
|
unlockedDecryptionKeys,
|
||||||
|
unlockedLegacyDecryptionKeys,
|
||||||
identityKeyBindings: bindings,
|
identityKeyBindings: bindings,
|
||||||
|
accountPreferences,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -316,7 +377,7 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
const record = get().keyRecords.find((k) => k.id === id);
|
const record = get().keyRecords.find((k) => k.id === id);
|
||||||
if (!record) throw new Error('Key record not found');
|
if (!record) throw new Error('Key record not found');
|
||||||
|
|
||||||
const { signingKey, decryptionKey } = await unlockPrivateKey(record, passphrase);
|
const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase);
|
||||||
if (get().rememberUnlockedKeys) {
|
if (get().rememberUnlockedKeys) {
|
||||||
rememberUnlockedKey(id, passphrase);
|
rememberUnlockedKey(id, passphrase);
|
||||||
}
|
}
|
||||||
@@ -327,7 +388,11 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
if (decryptionKey) {
|
if (decryptionKey) {
|
||||||
unlockedDecryptionKeys.set(id, decryptionKey);
|
unlockedDecryptionKeys.set(id, decryptionKey);
|
||||||
}
|
}
|
||||||
return { unlockedKeys, unlockedDecryptionKeys };
|
const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys);
|
||||||
|
if (legacyDecryptionKey) {
|
||||||
|
unlockedLegacyDecryptionKeys.set(id, legacyDecryptionKey);
|
||||||
|
}
|
||||||
|
return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -338,13 +403,15 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
unlockedKeys.delete(id);
|
unlockedKeys.delete(id);
|
||||||
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
|
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
|
||||||
unlockedDecryptionKeys.delete(id);
|
unlockedDecryptionKeys.delete(id);
|
||||||
return { unlockedKeys, unlockedDecryptionKeys };
|
const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys);
|
||||||
|
unlockedLegacyDecryptionKeys.delete(id);
|
||||||
|
return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
lockAllKeys: () => {
|
lockAllKeys: () => {
|
||||||
clearRememberedUnlocks();
|
clearRememberedUnlocks();
|
||||||
set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map() });
|
set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), unlockedLegacyDecryptionKeys: new Map() });
|
||||||
},
|
},
|
||||||
|
|
||||||
getKeyRecordForIdentity: (identityId) => {
|
getKeyRecordForIdentity: (identityId) => {
|
||||||
@@ -378,13 +445,32 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setSignDefault: (identityId, value) => {
|
setSignDefault: (identityId, value) => {
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
defaultSignIdentity: { ...state.defaultSignIdentity, [identityId]: value },
|
const defaultSignIdentity = { ...state.defaultSignIdentity, [identityId]: value };
|
||||||
}));
|
const accountPreferences = { ...state.accountPreferences };
|
||||||
|
const acctId = state.currentAccountId;
|
||||||
|
if (acctId) {
|
||||||
|
accountPreferences[acctId] = {
|
||||||
|
...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }),
|
||||||
|
defaultSignIdentity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { defaultSignIdentity, accountPreferences };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setEncryptDefault: (value) => {
|
setEncryptDefault: (value) => {
|
||||||
set({ defaultEncrypt: value });
|
set((state) => {
|
||||||
|
const accountPreferences = { ...state.accountPreferences };
|
||||||
|
const acctId = state.currentAccountId;
|
||||||
|
if (acctId) {
|
||||||
|
accountPreferences[acctId] = {
|
||||||
|
...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }),
|
||||||
|
defaultEncrypt: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { defaultEncrypt: value, accountPreferences };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setRememberUnlockedKeys: (value) => {
|
setRememberUnlockedKeys: (value) => {
|
||||||
@@ -403,17 +489,42 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
|
|
||||||
getUnlockedKey: (id) => get().unlockedKeys.get(id),
|
getUnlockedKey: (id) => get().unlockedKeys.get(id),
|
||||||
|
|
||||||
|
clearState: () => {
|
||||||
|
clearRememberedUnlocks();
|
||||||
|
set({
|
||||||
|
keyRecords: [],
|
||||||
|
publicCerts: [],
|
||||||
|
unlockedKeys: new Map(),
|
||||||
|
unlockedDecryptionKeys: new Map(),
|
||||||
|
unlockedLegacyDecryptionKeys: new Map(),
|
||||||
|
identityKeyBindings: {},
|
||||||
|
defaultSignIdentity: {},
|
||||||
|
defaultEncrypt: false,
|
||||||
|
currentAccountId: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
setError: (error) => set({ error }),
|
setError: (error) => set({ error }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'smime-preferences',
|
name: 'smime-preferences',
|
||||||
partialize: (state): SmimePersistedState => ({
|
partialize: (state): SmimePersistedState => ({
|
||||||
identityKeyBindings: state.identityKeyBindings,
|
accountPreferences: state.accountPreferences,
|
||||||
defaultSignIdentity: state.defaultSignIdentity,
|
|
||||||
defaultEncrypt: state.defaultEncrypt,
|
|
||||||
rememberUnlockedKeys: state.rememberUnlockedKeys,
|
rememberUnlockedKeys: state.rememberUnlockedKeys,
|
||||||
autoImportSignerCerts: state.autoImportSignerCerts,
|
autoImportSignerCerts: state.autoImportSignerCerts,
|
||||||
}),
|
}),
|
||||||
|
merge: (persisted, current) => {
|
||||||
|
const p = persisted as Partial<SmimePersistedState & { identityKeyBindings?: Record<string, string>; defaultSignIdentity?: Record<string, boolean>; defaultEncrypt?: boolean }>;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
// Migrate legacy flat preferences into accountPreferences
|
||||||
|
accountPreferences: p?.accountPreferences ?? {},
|
||||||
|
rememberUnlockedKeys: p?.rememberUnlockedKeys ?? false,
|
||||||
|
autoImportSignerCerts: p?.autoImportSignerCerts ?? true,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { CalendarTask } from '@/lib/jmap/types';
|
import type { CalendarTask } from '@/lib/jmap/types';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
import { debug } from '@/lib/debug';
|
||||||
|
|
||||||
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
||||||
|
|
||||||
@@ -36,18 +37,29 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
|||||||
setShowCompleted: (show) => set({ showCompleted: show }),
|
setShowCompleted: (show) => set({ showCompleted: show }),
|
||||||
|
|
||||||
fetchTasks: async (client, calendarIds) => {
|
fetchTasks: async (client, calendarIds) => {
|
||||||
|
debug.log('TaskStore/fetchTasks start', { calendarIds: calendarIds || 'all' });
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const tasks = await client.getCalendarTasks(calendarIds);
|
const tasks = await client.getCalendarTasks(calendarIds);
|
||||||
|
debug.log('TaskStore/fetchTasks received', tasks.length, 'tasks');
|
||||||
|
tasks.forEach((t, i) => {
|
||||||
|
debug.log(`TaskStore/fetchTasks [${i}]`, {
|
||||||
|
id: t.id, uid: t.uid, '@type': t['@type'],
|
||||||
|
title: t.title, due: t.due, progress: t.progress,
|
||||||
|
showWithoutTime: t.showWithoutTime, calendarIds: t.calendarIds,
|
||||||
|
});
|
||||||
|
});
|
||||||
set({ tasks, isLoading: false });
|
set({ tasks, isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch tasks:', error);
|
debug.error('TaskStore/fetchTasks failed', error);
|
||||||
set({ isLoading: false, error: 'Failed to fetch tasks' });
|
set({ isLoading: false, error: 'Failed to fetch tasks' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createTask: async (client, task) => {
|
createTask: async (client, task) => {
|
||||||
|
debug.log('TaskStore/createTask', task);
|
||||||
const created = await client.createCalendarTask(task);
|
const created = await client.createCalendarTask(task);
|
||||||
|
debug.log('TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title });
|
||||||
set({ tasks: [...get().tasks, created] });
|
set({ tasks: [...get().tasks, created] });
|
||||||
return created;
|
return created;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global store for TOTP re-authentication prompts.
|
||||||
|
*
|
||||||
|
* When a JMAP client detects a 401 and TOTP was used for login,
|
||||||
|
* it calls the registered callback which triggers this store to show
|
||||||
|
* a dialog. The dialog collects a fresh TOTP code and resolves the
|
||||||
|
* pending promise so the client can retry with updated credentials.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TotpReauthState {
|
||||||
|
isOpen: boolean;
|
||||||
|
resolve: ((totp: string | null) => void) | null;
|
||||||
|
/** Request a fresh TOTP code from the user. Returns the code or null if cancelled. */
|
||||||
|
requestTotp: () => Promise<string | null>;
|
||||||
|
/** Submit the TOTP code from the dialog. */
|
||||||
|
submit: (totp: string) => void;
|
||||||
|
/** Cancel/dismiss the dialog. */
|
||||||
|
cancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTotpReauthStore = create<TotpReauthState>()((set, get) => ({
|
||||||
|
isOpen: false,
|
||||||
|
resolve: null,
|
||||||
|
|
||||||
|
requestTotp: () => {
|
||||||
|
// If already open, cancel the previous request
|
||||||
|
const prev = get().resolve;
|
||||||
|
if (prev) prev(null);
|
||||||
|
|
||||||
|
return new Promise<string | null>((resolve) => {
|
||||||
|
set({ isOpen: true, resolve });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
submit: (totp: string) => {
|
||||||
|
const { resolve } = get();
|
||||||
|
if (resolve) resolve(totp);
|
||||||
|
set({ isOpen: false, resolve: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: () => {
|
||||||
|
const { resolve } = get();
|
||||||
|
if (resolve) resolve(null);
|
||||||
|
set({ isOpen: false, resolve: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user