feat: implement calendar management features including create, update, and delete functionalities

This commit is contained in:
Linus Rath
2026-03-15 00:14:58 +01:00
parent f149501dff
commit caed067cda
18 changed files with 1772 additions and 68 deletions
+15 -1
View File
@@ -51,7 +51,8 @@ export default function CalendarPage() {
calendars, events, selectedDate, viewMode, selectedCalendarIds,
isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat } = useSettingsStore();
const { identities } = useIdentityStore();
@@ -97,6 +98,16 @@ export default function CalendarPage() {
}
}, [client, fetchCalendars]);
// Auto-refresh iCal subscriptions
useEffect(() => {
if (!client) return;
// Refresh on mount (respects per-subscription interval)
refreshAllSubscriptions(client);
// Check again every 5 minutes
const interval = setInterval(() => refreshAllSubscriptions(client), 5 * 60 * 1000);
return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]);
const dateRange = useMemo(() => {
const d = selectedDate;
switch (viewMode) {
@@ -712,6 +723,9 @@ export default function CalendarPage() {
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
onColorChange={client ? (calendarId, color) => {
updateCalendar(client, calendarId, { color });
} : undefined}
/>
</div>
)}
+2 -1
View File
@@ -11,6 +11,7 @@ import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
import { CalendarSettings } from '@/components/settings/calendar-settings';
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
@@ -84,7 +85,7 @@ export default function SettingsPage() {
{activeTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <CalendarSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from 'next/server';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000;
function isValidExternalUrl(urlString: string): boolean {
let url: URL;
try {
url = new URL(urlString);
} catch {
return false;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return false;
}
const hostname = url.hostname.toLowerCase();
// Block private/internal hostnames
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
) {
return false;
}
// Block URLs with credentials
if (url.username || url.password) {
return false;
}
return true;
}
export async function POST(request: NextRequest) {
let body: { url?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const { url } = body;
if (!url || typeof url !== 'string') {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
if (!isValidExternalUrl(url)) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'follow',
});
clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json(
{ error: `Remote server returned ${response.status}` },
{ status: 502 }
);
}
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
return NextResponse.json({ error: 'File too large' }, { status: 413 });
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength > MAX_RESPONSE_SIZE) {
return NextResponse.json({ error: 'File too large' }, { status: 413 });
}
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'text/calendar',
'Content-Length': buffer.byteLength.toString(),
},
});
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
}
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
}
}