feat: add calendar event normalization and sanitization functions with tests

This commit is contained in:
Linus Rath
2026-03-27 18:46:20 +01:00
parent 42734f16c3
commit b868ad591d
17 changed files with 424 additions and 47 deletions
+53 -4
View File
@@ -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,
});
});
});
+10
View File
@@ -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',
+39
View File
@@ -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');
});
});
});
+101
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -1178,6 +1178,9 @@
"description": "受信メールを自動で振り分け、ラベル付け、管理するルールを作成します",
"add_rule": "ルールを追加",
"no_rules": "フィルタールールなし",
"vacation_active": "不在応答が有効です",
"vacation_active_description": "受信メッセージへの自動返信が有効になっています",
"vacation_configure": "設定",
"no_rules_description": "受信メールを自動で整理するルールを作成してください",
"edit_rule": "ルールを編集",
"new_rule": "新しいルール",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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
View File
@@ -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 : '';