feat: add calendar event normalization and sanitization functions with tests
This commit is contained in:
@@ -38,6 +38,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
@@ -384,6 +385,20 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [client, fetchEvents]);
|
||||
|
||||
const focusCalendarOnEvent = useCallback((event: Pick<CalendarEvent, "start">) => {
|
||||
if (!event.start) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventDate = parseISO(event.start);
|
||||
if (Number.isNaN(eventDate.getTime())) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedDate(eventDate);
|
||||
setMiniMonth(eventDate);
|
||||
}, [setSelectedDate]);
|
||||
|
||||
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
|
||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||
try {
|
||||
@@ -400,6 +415,9 @@ export default function CalendarPage() {
|
||||
return;
|
||||
}
|
||||
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
||||
if (data.start) {
|
||||
focusCalendarOnEvent({ start: data.start });
|
||||
}
|
||||
toast.success(t("notifications.event_updated"));
|
||||
} else {
|
||||
const created = await createEvent(client, data, sendSchedulingMessages);
|
||||
@@ -407,6 +425,7 @@ export default function CalendarPage() {
|
||||
toast.error(t("notifications.event_error"));
|
||||
return;
|
||||
}
|
||||
focusCalendarOnEvent(created);
|
||||
if (sendSchedulingMessages) {
|
||||
toast.success(t("notifications.invitation_sent"));
|
||||
} else {
|
||||
@@ -418,7 +437,7 @@ export default function CalendarPage() {
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [client, editEvent, createEvent, updateEvent, t]);
|
||||
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, t]);
|
||||
|
||||
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
|
||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||
@@ -428,6 +447,7 @@ export default function CalendarPage() {
|
||||
toast.error(t("notifications.event_error"));
|
||||
return;
|
||||
}
|
||||
focusCalendarOnEvent(created);
|
||||
toast.success(t("notifications.event_duplicated"));
|
||||
setEditEvent(created);
|
||||
setDefaultModalDate(undefined);
|
||||
@@ -436,7 +456,7 @@ export default function CalendarPage() {
|
||||
setShowEventModal(false);
|
||||
setEditEvent(null);
|
||||
}
|
||||
}, [client, createEvent, t]);
|
||||
}, [client, createEvent, focusCalendarOnEvent, t]);
|
||||
|
||||
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
|
||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||
@@ -632,7 +652,7 @@ export default function CalendarPage() {
|
||||
if (!detailEvent || !client) return;
|
||||
const start = parseISO(detailEvent.start);
|
||||
const newStart = addDays(start, 1);
|
||||
const data: Partial<CalendarEvent> = {
|
||||
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||
title: detailEvent.title,
|
||||
description: detailEvent.description,
|
||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
@@ -643,7 +663,7 @@ export default function CalendarPage() {
|
||||
status: "confirmed",
|
||||
freeBusyStatus: detailEvent.freeBusyStatus,
|
||||
privacy: detailEvent.privacy,
|
||||
};
|
||||
});
|
||||
if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations);
|
||||
if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules);
|
||||
if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts);
|
||||
@@ -716,6 +736,35 @@ export default function CalendarPage() {
|
||||
[events, selectedCalendarIds]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const hiddenEvents = events.filter((event) => {
|
||||
if (!event.start || !event.calendarIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !Object.keys(event.calendarIds).some((calendarId) => selectedCalendarIds.includes(calendarId));
|
||||
});
|
||||
|
||||
if (hiddenEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
debug.log('Calendar visibility summary', {
|
||||
totalEvents: events.length,
|
||||
visibleEvents: visibleEvents.length,
|
||||
hiddenEvents: hiddenEvents.length,
|
||||
selectedCalendarIds,
|
||||
hiddenSamples: hiddenEvents.slice(0, 5).map((event) => ({
|
||||
id: event.id,
|
||||
originalId: event.originalId,
|
||||
title: event.title,
|
||||
calendarIds: event.calendarIds,
|
||||
originalCalendarIds: event.originalCalendarIds,
|
||||
accountId: event.accountId,
|
||||
})),
|
||||
});
|
||||
}, [events, selectedCalendarIds, visibleEvents]);
|
||||
|
||||
if (!isAuthenticated || !supportsCalendar) return null;
|
||||
|
||||
const renderView = () => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import {
|
||||
isAllDayEventLike,
|
||||
normalizeCalendarEventLike,
|
||||
sanitizeOutgoingCalendarEventData,
|
||||
} from '../calendar-event-normalization';
|
||||
|
||||
function makeEvent(overrides: Partial<CalendarEvent> = {}): Partial<CalendarEvent> {
|
||||
return {
|
||||
start: '2026-03-16T00:00:00',
|
||||
duration: 'PT24H',
|
||||
showWithoutTime: false,
|
||||
timeZone: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('calendar event normalization', () => {
|
||||
it('infers all-day events from midnight-to-midnight spans without the flag', () => {
|
||||
expect(isAllDayEventLike(makeEvent())).toBe(true);
|
||||
});
|
||||
|
||||
it('does not infer all-day for midnight events with non-day durations', () => {
|
||||
expect(isAllDayEventLike(makeEvent({ duration: 'PT12H' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes inferred all-day durations to day units', () => {
|
||||
expect(normalizeCalendarEventLike(makeEvent({ duration: 'PT48H' }))).toMatchObject({
|
||||
showWithoutTime: true,
|
||||
duration: 'P2D',
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes outgoing all-day starts to date-only values', () => {
|
||||
expect(sanitizeOutgoingCalendarEventData(makeEvent({
|
||||
showWithoutTime: true,
|
||||
start: '2026-03-16T00:00:00',
|
||||
duration: 'PT24H',
|
||||
timeZone: 'UTC',
|
||||
}))).toMatchObject({
|
||||
start: '2026-03-16',
|
||||
duration: 'P1D',
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -372,6 +372,16 @@ describe('formatEventSummary', () => {
|
||||
expect(summary.isAllDay).toBe(true);
|
||||
});
|
||||
|
||||
it('infers all-day summaries from midnight-to-midnight events without the flag', () => {
|
||||
const summary = formatEventSummary({
|
||||
start: '2026-03-16T00:00:00',
|
||||
duration: 'PT24H',
|
||||
});
|
||||
expect(summary.start).toBe('2026-03-16T00:00:00');
|
||||
expect(summary.end).toBe('2026-03-17T00:00:00');
|
||||
expect(summary.isAllDay).toBe(true);
|
||||
});
|
||||
|
||||
it('returns local format end for timed events with local start', () => {
|
||||
const summary = formatEventSummary({
|
||||
start: '2026-02-17T10:00:00',
|
||||
|
||||
@@ -171,4 +171,43 @@ describe('sieve generator', () => {
|
||||
expect(script).toContain('"Line with \\"quotes\\" and \\\\backslash"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stalwart vacation-only script detection (parseScript)', () => {
|
||||
it('should detect a plain Stalwart vacation-only script as non-opaque', () => {
|
||||
const script = `require ["vacation"];\n\nvacation :subject "OOO" "I am away.";\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.vacation?.isEnabled).toBe(true);
|
||||
expect(result.vacation?.subject).toBe('OOO');
|
||||
expect(result.rules).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect vacation script when body contains "if" keyword', () => {
|
||||
const script = `require ["vacation"];\n\nvacation :subject "Away" "if you need help contact support.";\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.vacation?.isEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect vacation script when body contains "else" keyword', () => {
|
||||
const script = `require ["vacation"];\n\nvacation "Otherwise I will respond when I return.";\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.vacation?.isEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark as opaque when real filter rules exist alongside vacation', () => {
|
||||
const script = `require ["vacation", "fileinto"];\n\nvacation "Away";\n\nif header :contains "From" "boss@example.com" {\n fileinto "Important";\n}\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle Stalwart :mime format vacation script', () => {
|
||||
const script = `require ["vacation", "relational", "date"];\n\nvacation :mime :subject "Test" "Content-Type: text/plain; charset=\\"utf-8\\"\nContent-Transfer-Encoding: 7bit\n\ntest";\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.vacation?.isEnabled).toBe(true);
|
||||
expect(result.vacation?.subject).toBe('Test');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { parseISO } from 'date-fns';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
const DURATION_RE = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
||||
|
||||
function isDateOnlyValue(value: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||
}
|
||||
|
||||
function isMidnightValue(value: string): boolean {
|
||||
if (isDateOnlyValue(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const match = value.match(/T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?$/);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match[1] === '00' && match[2] === '00' && (match[3] ?? '00') === '00';
|
||||
}
|
||||
|
||||
function parseDurationSeconds(duration: string | undefined): number | null {
|
||||
if (!duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = DURATION_RE.exec(duration);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const weeks = parseInt(match[1] || '0', 10);
|
||||
const days = parseInt(match[2] || '0', 10);
|
||||
const hours = parseInt(match[3] || '0', 10);
|
||||
const minutes = parseInt(match[4] || '0', 10);
|
||||
const seconds = parseInt(match[5] || '0', 10);
|
||||
|
||||
return (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds;
|
||||
}
|
||||
|
||||
export function normalizeAllDayDurationValue(duration: string | undefined): string | undefined {
|
||||
const totalSeconds = parseDurationSeconds(duration);
|
||||
if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
return `P${totalSeconds / 86400}D`;
|
||||
}
|
||||
|
||||
export function isAllDayEventLike(event: Pick<Partial<CalendarEvent>, 'start' | 'duration' | 'showWithoutTime'>): boolean {
|
||||
if (event.showWithoutTime) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!event.start || !event.duration || !isMidnightValue(event.start)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const totalSeconds = parseDurationSeconds(event.duration);
|
||||
if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const start = parseISO(event.start);
|
||||
if (Number.isNaN(start.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const end = new Date(start.getTime() + totalSeconds * 1000);
|
||||
return end.getHours() === 0
|
||||
&& end.getMinutes() === 0
|
||||
&& end.getSeconds() === 0
|
||||
&& end.getMilliseconds() === 0;
|
||||
}
|
||||
|
||||
export function normalizeCalendarEventLike<T extends Partial<CalendarEvent>>(event: T): T {
|
||||
if (!isAllDayEventLike(event)) {
|
||||
return event;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
showWithoutTime: true,
|
||||
duration: normalizeAllDayDurationValue(event.duration),
|
||||
} as T;
|
||||
}
|
||||
|
||||
export function sanitizeOutgoingCalendarEventData<T extends Partial<CalendarEvent>>(event: T): T {
|
||||
const normalized = normalizeCalendarEventLike(event);
|
||||
if (!normalized.showWithoutTime) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
start: normalized.start ? normalized.start.slice(0, 10) : normalized.start,
|
||||
duration: normalizeAllDayDurationValue(normalized.duration),
|
||||
timeZone: null,
|
||||
} as T;
|
||||
}
|
||||
+19
-20
@@ -1,5 +1,6 @@
|
||||
import { parseISO } from 'date-fns';
|
||||
import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPart } from '@/lib/jmap/types';
|
||||
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
||||
|
||||
export type InvitationMethod =
|
||||
| 'publish'
|
||||
@@ -251,9 +252,6 @@ function looksLikeReply(event: Partial<CalendarEvent>): boolean {
|
||||
if (!event.participants) return false;
|
||||
|
||||
const participants = Object.values(event.participants);
|
||||
const hasOrganizer = participants.some((participant) => isOrganizerParticipant(participant));
|
||||
if (!hasOrganizer) return false;
|
||||
|
||||
return participants.some((participant) =>
|
||||
participant.roles?.attendee
|
||||
&& !isOrganizerParticipant(participant)
|
||||
@@ -459,9 +457,10 @@ export interface EventSummary {
|
||||
}
|
||||
|
||||
export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary {
|
||||
const normalizedEvent = normalizeCalendarEventLike(event);
|
||||
let location: string | null = null;
|
||||
if (event.locations) {
|
||||
const firstLocation = Object.values(event.locations)[0];
|
||||
if (normalizedEvent.locations) {
|
||||
const firstLocation = Object.values(normalizedEvent.locations)[0];
|
||||
if (firstLocation?.name) {
|
||||
location = firstLocation.name;
|
||||
}
|
||||
@@ -471,8 +470,8 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
|
||||
let organizerEmail: string | null = null;
|
||||
let attendeeCount = 0;
|
||||
|
||||
if (event.participants) {
|
||||
for (const p of Object.values(event.participants)) {
|
||||
if (normalizedEvent.participants) {
|
||||
for (const p of Object.values(normalizedEvent.participants)) {
|
||||
if (p.roles?.owner || p.roles?.chair) {
|
||||
organizer = p.name || getParticipantEmail(p) || null;
|
||||
organizerEmail = getParticipantEmail(p);
|
||||
@@ -484,12 +483,12 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
|
||||
}
|
||||
|
||||
// Stalwart provides organizerCalendarAddress instead of roles.owner/chair
|
||||
if (!organizerEmail && event.organizerCalendarAddress) {
|
||||
organizerEmail = normalizeEmailAddress(event.organizerCalendarAddress);
|
||||
if (!organizer && event.participants) {
|
||||
if (!organizerEmail && normalizedEvent.organizerCalendarAddress) {
|
||||
organizerEmail = normalizeEmailAddress(normalizedEvent.organizerCalendarAddress);
|
||||
if (!organizer && normalizedEvent.participants) {
|
||||
// Find the participant matching the organizer address for their name
|
||||
for (const p of Object.values(event.participants)) {
|
||||
if (p.calendarAddress === event.organizerCalendarAddress) {
|
||||
for (const p of Object.values(normalizedEvent.participants)) {
|
||||
if (p.calendarAddress === normalizedEvent.organizerCalendarAddress) {
|
||||
organizer = p.name || organizerEmail;
|
||||
break;
|
||||
}
|
||||
@@ -498,23 +497,23 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
|
||||
if (!organizer) organizer = organizerEmail;
|
||||
}
|
||||
|
||||
const isAllDay = event.showWithoutTime ?? false;
|
||||
const isAllDay = normalizedEvent.showWithoutTime ?? false;
|
||||
|
||||
let end: string | null = null;
|
||||
if (event.utcEnd) {
|
||||
end = event.utcEnd;
|
||||
} else if (event.start && event.duration) {
|
||||
end = addDurationToDate(event.start, event.duration, event.timeZone);
|
||||
if (normalizedEvent.utcEnd) {
|
||||
end = normalizedEvent.utcEnd;
|
||||
} else if (normalizedEvent.start && normalizedEvent.duration) {
|
||||
end = addDurationToDate(normalizedEvent.start, normalizedEvent.duration, normalizedEvent.timeZone);
|
||||
}
|
||||
|
||||
// For all-day events, prefer the local start (no timezone) to avoid
|
||||
// UTC conversion shifting the displayed date in non-UTC timezones.
|
||||
const start = isAllDay
|
||||
? (event.start || null)
|
||||
: (event.utcStart || event.start || null);
|
||||
? (normalizedEvent.start || null)
|
||||
: (normalizedEvent.utcStart || normalizedEvent.start || null);
|
||||
|
||||
return {
|
||||
title: event.title || '',
|
||||
title: normalizedEvent.title || '',
|
||||
start,
|
||||
end,
|
||||
isAllDay,
|
||||
|
||||
+8
-4
@@ -3,6 +3,7 @@ import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
retryAfterMs: number;
|
||||
@@ -2972,7 +2973,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
||||
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
|
||||
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
|
||||
.map((event) => normalizeCalendarEventLike(event));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -3049,7 +3051,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
||||
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
|
||||
return ((response.methodResponses[1][1].list || []) as CalendarEvent[])
|
||||
.map((event) => normalizeCalendarEventLike(event));
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
@@ -3070,7 +3073,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||
const list = response.methodResponses[0][1].list || [];
|
||||
return list[0] || null;
|
||||
return list[0] ? normalizeCalendarEventLike(list[0] as CalendarEvent) : null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
@@ -3177,7 +3180,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const parsed = result.parsed?.[blobId];
|
||||
if (parsed) {
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
return (Array.isArray(parsed) ? parsed : [parsed])
|
||||
.map((event) => normalizeCalendarEventLike(event as Partial<CalendarEvent>));
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
+22
-11
@@ -55,23 +55,34 @@ function detectVacationOnlyScript(content: string): ParseResult | null {
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.trim();
|
||||
|
||||
// After stripping, the only meaningful statement should be a vacation command.
|
||||
// Check there are no if/elsif/else blocks (i.e., no filter rules).
|
||||
if (/\b(?:if|elsif|else)\b/.test(stripped)) return null;
|
||||
// Strip quoted string *contents* before checking for structural keywords so that
|
||||
// message body text like "if you need urgent help..." doesn't cause false rejection.
|
||||
const structural = stripped.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
||||
|
||||
// Extract subject if present
|
||||
const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/);
|
||||
// Check there are no if/elsif/else filter blocks
|
||||
if (/\b(?:if|elsif|else)\b/.test(structural)) return null;
|
||||
|
||||
// Must still have a vacation command after stripping boilerplate
|
||||
if (!/\bvacation\b/.test(structural)) return null;
|
||||
|
||||
// Extract subject if present (:subject "...")
|
||||
const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/);
|
||||
const subject = subjectMatch ? subjectMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : '';
|
||||
|
||||
// Extract the body text (last quoted string in the vacation command)
|
||||
// Stalwart uses MIME format; extract the plain text after the MIME headers
|
||||
const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\n]*\n\n([\s\S]*?)"\s*;\s*$/);
|
||||
const simpleBodyMatch = stripped.match(/vacation[^;]*"((?:[^"\\]|\\.)*)"\s*;\s*$/);
|
||||
// Extract the body text. Stalwart uses :mime format where the body is a full MIME
|
||||
// message. Extract the plain text after the Content-Transfer-Encoding header.
|
||||
// Handle both LF and CRLF line endings.
|
||||
let textBody = '';
|
||||
const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\r\n]*\r?\n\r?\n([\s\S]*?)"[\s\S]*?;/);
|
||||
if (mimeBodyMatch) {
|
||||
textBody = mimeBodyMatch[1].trim();
|
||||
} else if (simpleBodyMatch) {
|
||||
textBody = simpleBodyMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
} else {
|
||||
// Plain format: last quoted string argument in the vacation statement
|
||||
const allQuoted = [...stripped.matchAll(/"((?:[^"\\]|\\.)*)"/g)];
|
||||
const last = allQuoted[allQuoted.length - 1];
|
||||
if (last) {
|
||||
textBody = last[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"description": "Erstellen Sie Regeln, um eingehende E-Mails automatisch zu sortieren, zu kennzeichnen und zu verwalten",
|
||||
"add_rule": "Regel hinzufügen",
|
||||
"no_rules": "Keine Filterregeln",
|
||||
"vacation_active": "Abwesenheitsnotiz ist aktiv",
|
||||
"vacation_active_description": "Automatische Antwort ist für eingehende Nachrichten aktiviert",
|
||||
"vacation_configure": "Konfigurieren",
|
||||
"no_rules_description": "Erstellen Sie Regeln, um Ihre eingehenden E-Mails automatisch zu organisieren",
|
||||
"edit_rule": "Regel bearbeiten",
|
||||
"new_rule": "Neue Regel",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"description": "Cree reglas para ordenar, etiquetar y gestionar automáticamente los correos entrantes",
|
||||
"add_rule": "Agregar regla",
|
||||
"no_rules": "Sin reglas de filtrado",
|
||||
"vacation_active": "El respondedor de vacaciones está activo",
|
||||
"vacation_active_description": "La respuesta automática está habilitada para los mensajes entrantes",
|
||||
"vacation_configure": "Configurar",
|
||||
"no_rules_description": "Cree reglas para organizar automáticamente sus correos entrantes",
|
||||
"edit_rule": "Editar regla",
|
||||
"new_rule": "Nueva regla",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"description": "Créez des règles pour trier, étiqueter et gérer automatiquement les courriers entrants",
|
||||
"add_rule": "Ajouter une règle",
|
||||
"no_rules": "Aucune règle de filtrage",
|
||||
"vacation_active": "Le répondeur d'absence est actif",
|
||||
"vacation_active_description": "La réponse automatique est activée pour les messages entrants",
|
||||
"vacation_configure": "Configurer",
|
||||
"no_rules_description": "Créez des règles pour organiser automatiquement vos courriers entrants",
|
||||
"edit_rule": "Modifier la règle",
|
||||
"new_rule": "Nouvelle règle",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"description": "Crea regole per ordinare, etichettare e gestire automaticamente le email in arrivo",
|
||||
"add_rule": "Aggiungi regola",
|
||||
"no_rules": "Nessuna regola di filtro",
|
||||
"vacation_active": "Il risponditore automatico è attivo",
|
||||
"vacation_active_description": "La risposta automatica è abilitata per i messaggi in arrivo",
|
||||
"vacation_configure": "Configura",
|
||||
"no_rules_description": "Crea regole per organizzare automaticamente le tue email in arrivo",
|
||||
"edit_rule": "Modifica regola",
|
||||
"new_rule": "Nuova regola",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"description": "受信メールを自動で振り分け、ラベル付け、管理するルールを作成します",
|
||||
"add_rule": "ルールを追加",
|
||||
"no_rules": "フィルタールールなし",
|
||||
"vacation_active": "不在応答が有効です",
|
||||
"vacation_active_description": "受信メッセージへの自動返信が有効になっています",
|
||||
"vacation_configure": "設定",
|
||||
"no_rules_description": "受信メールを自動で整理するルールを作成してください",
|
||||
"edit_rule": "ルールを編集",
|
||||
"new_rule": "新しいルール",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"description": "Maak regels om inkomende e-mails automatisch te sorteren, labelen en beheren",
|
||||
"add_rule": "Regel toevoegen",
|
||||
"no_rules": "Geen filterregels",
|
||||
"vacation_active": "Afwezigheidsantwoord is actief",
|
||||
"vacation_active_description": "Automatisch antwoord is ingeschakeld voor inkomende berichten",
|
||||
"vacation_configure": "Configureren",
|
||||
"no_rules_description": "Maak regels om uw inkomende e-mails automatisch te organiseren",
|
||||
"edit_rule": "Regel bewerken",
|
||||
"new_rule": "Nieuwe regel",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"description": "Crie regras para classificar, rotular e gerenciar automaticamente os e-mails recebidos",
|
||||
"add_rule": "Adicionar regra",
|
||||
"no_rules": "Nenhuma regra de filtro",
|
||||
"vacation_active": "O respondedor de ausência está ativo",
|
||||
"vacation_active_description": "A resposta automática está ativada para mensagens recebidas",
|
||||
"vacation_configure": "Configurar",
|
||||
"no_rules_description": "Crie regras para organizar automaticamente seus e-mails recebidos",
|
||||
"edit_rule": "Editar regra",
|
||||
"new_rule": "Nova regra",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"description": "Создавайте правила для автоматической сортировки, маркировки и управления входящими письмами",
|
||||
"add_rule": "Добавить правило",
|
||||
"no_rules": "Нет правил фильтрации",
|
||||
"vacation_active": "Автоответ отпуска активен",
|
||||
"vacation_active_description": "Автоматический ответ включён для входящих сообщений",
|
||||
"vacation_configure": "Настроить",
|
||||
"no_rules_description": "Создайте правила для автоматической организации входящей почты",
|
||||
"edit_rule": "Редактировать правило",
|
||||
"new_rule": "Новое правило",
|
||||
|
||||
+100
-8
@@ -4,6 +4,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||
|
||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
|
||||
|
||||
@@ -13,6 +14,54 @@ export function isCalendarViewMode(value: unknown): value is CalendarViewMode {
|
||||
return typeof value === 'string' && CALENDAR_VIEW_MODES.includes(value as CalendarViewMode);
|
||||
}
|
||||
|
||||
function mapCalendarIdsToStoreIds(
|
||||
calendarIds: Record<string, boolean> | undefined,
|
||||
calendars: Calendar[],
|
||||
targetAccountId?: string
|
||||
): Record<string, boolean> | undefined {
|
||||
if (!calendarIds) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(calendarIds).map(([calendarId, included]) => {
|
||||
const matchedCalendar = calendars.find((calendar) =>
|
||||
(calendar.originalId || calendar.id) === calendarId
|
||||
&& (!targetAccountId || calendar.accountId === targetAccountId)
|
||||
);
|
||||
|
||||
return [matchedCalendar?.id || calendarId, included];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function mapServerEventToStoreEvent(
|
||||
event: CalendarEvent,
|
||||
calendars: Calendar[],
|
||||
targetAccountId?: string
|
||||
): CalendarEvent {
|
||||
const mappedCalendarIds = mapCalendarIdsToStoreIds(event.calendarIds, calendars, targetAccountId) || event.calendarIds;
|
||||
const matchedCalendar = Object.keys(event.calendarIds || {})
|
||||
.map((calendarId) => calendars.find((calendar) =>
|
||||
(calendar.originalId || calendar.id) === calendarId
|
||||
&& (!targetAccountId || calendar.accountId === targetAccountId)
|
||||
))
|
||||
.find((calendar): calendar is Calendar => Boolean(calendar));
|
||||
const resolvedAccountId = matchedCalendar?.accountId || targetAccountId;
|
||||
const isShared = matchedCalendar?.isShared || false;
|
||||
|
||||
return {
|
||||
...event,
|
||||
id: isShared && resolvedAccountId ? `${resolvedAccountId}:${event.id}` : event.id,
|
||||
originalId: event.id,
|
||||
originalCalendarIds: event.calendarIds,
|
||||
calendarIds: mappedCalendarIds,
|
||||
accountId: resolvedAccountId,
|
||||
accountName: matchedCalendar?.accountName,
|
||||
isShared,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ICalSubscription {
|
||||
id: string;
|
||||
url: string;
|
||||
@@ -116,6 +165,17 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
});
|
||||
// Filter out malformed events missing required 'start' field
|
||||
const events = rawEvents.filter(e => typeof e.start === 'string' && e.start);
|
||||
const droppedEvents = rawEvents.length - events.length;
|
||||
debug.log('Calendar fetchEvents completed', {
|
||||
start,
|
||||
end,
|
||||
rawCount: rawEvents.length,
|
||||
usableCount: events.length,
|
||||
droppedEvents,
|
||||
});
|
||||
if (droppedEvents > 0) {
|
||||
debug.warn('Calendar fetchEvents dropped malformed events without a start field', { droppedEvents });
|
||||
}
|
||||
set({ events, isLoadingEvents: false, dateRange: { start, end } });
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch events:', error);
|
||||
@@ -128,7 +188,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
try {
|
||||
// Resolve shared calendar context from calendarIds
|
||||
let targetAccountId = event.accountId;
|
||||
const cleanEvent = { ...event };
|
||||
const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event });
|
||||
if (event.calendarIds) {
|
||||
const remapped: Record<string, boolean> = {};
|
||||
for (const calId of Object.keys(event.calendarIds)) {
|
||||
@@ -145,9 +205,40 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
if (event.originalCalendarIds) {
|
||||
cleanEvent.calendarIds = event.originalCalendarIds;
|
||||
}
|
||||
debug.log('Calendar createEvent request', {
|
||||
title: cleanEvent.title,
|
||||
start: cleanEvent.start,
|
||||
duration: cleanEvent.duration,
|
||||
sendSchedulingMessages,
|
||||
targetAccountId,
|
||||
requestedCalendarIds: event.calendarIds,
|
||||
serverCalendarIds: cleanEvent.calendarIds,
|
||||
});
|
||||
const created = await client.createCalendarEvent(cleanEvent, sendSchedulingMessages, targetAccountId);
|
||||
set((state) => ({ events: [...state.events, created] }));
|
||||
return created;
|
||||
const mappedCreated = mapServerEventToStoreEvent(created, get().calendars, targetAccountId);
|
||||
const selectedCalendarIds = get().selectedCalendarIds;
|
||||
const createdCalendarIds = Object.keys(mappedCreated.calendarIds || {});
|
||||
const isVisible = createdCalendarIds.some((calendarId) => selectedCalendarIds.includes(calendarId));
|
||||
|
||||
debug.log('Calendar createEvent response', {
|
||||
id: mappedCreated.id,
|
||||
originalId: mappedCreated.originalId,
|
||||
accountId: mappedCreated.accountId,
|
||||
isShared: mappedCreated.isShared,
|
||||
calendarIds: mappedCreated.calendarIds,
|
||||
originalCalendarIds: mappedCreated.originalCalendarIds,
|
||||
isVisible,
|
||||
});
|
||||
|
||||
if (!isVisible) {
|
||||
debug.warn('Created event is hidden by current calendar filters', {
|
||||
selectedCalendarIds,
|
||||
createdCalendarIds,
|
||||
});
|
||||
}
|
||||
|
||||
set((state) => ({ events: [...state.events, mappedCreated] }));
|
||||
return mappedCreated;
|
||||
} catch (error) {
|
||||
debug.error('Failed to create event:', error);
|
||||
set({ error: 'Failed to create event' });
|
||||
@@ -163,7 +254,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
const realId = storeEvent?.originalId || id;
|
||||
const targetAccountId = storeEvent?.accountId;
|
||||
// Remap namespaced calendarIds back to original IDs
|
||||
const cleanUpdates = { ...updates };
|
||||
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
|
||||
if (cleanUpdates.calendarIds) {
|
||||
const remapped: Record<string, boolean> = {};
|
||||
for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) {
|
||||
@@ -211,7 +302,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
await client.updateCalendarEvent(resolvedId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||
}
|
||||
set((state) => ({
|
||||
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
||||
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@@ -219,7 +310,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
throw updateError;
|
||||
}
|
||||
set((state) => ({
|
||||
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
||||
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e),
|
||||
}));
|
||||
} catch (error) {
|
||||
debug.error('Failed to update event:', error);
|
||||
@@ -335,7 +426,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
const realCalendarId = cal?.originalId || calendarId;
|
||||
const targetAccountId = cal?.accountId;
|
||||
for (const event of events) {
|
||||
const src = event as Partial<CalendarEvent>;
|
||||
const src = sanitizeOutgoingCalendarEventData(event as Partial<CalendarEvent>);
|
||||
try {
|
||||
let cleanParticipants: Record<string, CalendarParticipant> | null = null;
|
||||
if (src.participants) {
|
||||
@@ -402,7 +493,8 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
if (v === undefined || v === null) delete (data as Record<string, unknown>)[k];
|
||||
});
|
||||
const created = await client.createCalendarEvent(data, undefined, targetAccountId);
|
||||
set((state) => ({ events: [...state.events, created] }));
|
||||
const mappedCreated = mapServerEventToStoreEvent(created, get().calendars, targetAccountId);
|
||||
set((state) => ({ events: [...state.events, mappedCreated] }));
|
||||
imported++;
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : '';
|
||||
|
||||
Reference in New Issue
Block a user