From b868ad591dd9ef541752d2c8bc9443b3c3205a61 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:46:20 +0100 Subject: [PATCH] feat: add calendar event normalization and sanitization functions with tests --- app/[locale]/calendar/page.tsx | 57 ++++++++- .../calendar-event-normalization.test.ts | 48 ++++++++ lib/__tests__/calendar-invitation.test.ts | 10 ++ lib/__tests__/sieve-generator.test.ts | 39 +++++++ lib/calendar-event-normalization.ts | 101 ++++++++++++++++ lib/calendar-invitation.ts | 39 +++---- lib/jmap/client.ts | 12 +- lib/sieve/parser.ts | 33 ++++-- locales/de/common.json | 3 + locales/es/common.json | 3 + locales/fr/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/nl/common.json | 3 + locales/pt/common.json | 3 + locales/ru/common.json | 3 + stores/calendar-store.ts | 108 ++++++++++++++++-- 17 files changed, 424 insertions(+), 47 deletions(-) create mode 100644 lib/__tests__/calendar-event-normalization.test.ts create mode 100644 lib/calendar-event-normalization.ts diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 241139c6..3f73ad0c 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -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) => { + 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, 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) => { 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 = { + const data = sanitizeOutgoingCalendarEventData>({ 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 = () => { diff --git a/lib/__tests__/calendar-event-normalization.test.ts b/lib/__tests__/calendar-event-normalization.test.ts new file mode 100644 index 00000000..084a8a32 --- /dev/null +++ b/lib/__tests__/calendar-event-normalization.test.ts @@ -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 = {}): Partial { + 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, + }); + }); +}); \ No newline at end of file diff --git a/lib/__tests__/calendar-invitation.test.ts b/lib/__tests__/calendar-invitation.test.ts index 43a63f0b..68813a27 100644 --- a/lib/__tests__/calendar-invitation.test.ts +++ b/lib/__tests__/calendar-invitation.test.ts @@ -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', diff --git a/lib/__tests__/sieve-generator.test.ts b/lib/__tests__/sieve-generator.test.ts index 6f1feb8e..da4fe6cd 100644 --- a/lib/__tests__/sieve-generator.test.ts +++ b/lib/__tests__/sieve-generator.test.ts @@ -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'); + }); + }); }); diff --git a/lib/calendar-event-normalization.ts b/lib/calendar-event-normalization.ts new file mode 100644 index 00000000..d9899ed0 --- /dev/null +++ b/lib/calendar-event-normalization.ts @@ -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, '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>(event: T): T { + if (!isAllDayEventLike(event)) { + return event; + } + + return { + ...event, + showWithoutTime: true, + duration: normalizeAllDayDurationValue(event.duration), + } as T; +} + +export function sanitizeOutgoingCalendarEventData>(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; +} \ No newline at end of file diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts index b1dbad26..9eb3c7ca 100644 --- a/lib/calendar-invitation.ts +++ b/lib/calendar-invitation.ts @@ -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): 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): 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): 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): 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): 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, diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 2ff209b8..7435323e 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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)); } return []; diff --git a/lib/sieve/parser.ts b/lib/sieve/parser.ts index 4807dd3b..381510c3 100644 --- a/lib/sieve/parser.ts +++ b/lib/sieve/parser.ts @@ -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 { diff --git a/locales/de/common.json b/locales/de/common.json index 08435688..17357647 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index 5686dfaa..9ad532e0 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/fr/common.json b/locales/fr/common.json index 97a74802..ebb8fb7b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index 438cc68e..a730d4e3 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 288a6d02..59d679cb 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1178,6 +1178,9 @@ "description": "受信メールを自動で振り分け、ラベル付け、管理するルールを作成します", "add_rule": "ルールを追加", "no_rules": "フィルタールールなし", + "vacation_active": "不在応答が有効です", + "vacation_active_description": "受信メッセージへの自動返信が有効になっています", + "vacation_configure": "設定", "no_rules_description": "受信メールを自動で整理するルールを作成してください", "edit_rule": "ルールを編集", "new_rule": "新しいルール", diff --git a/locales/nl/common.json b/locales/nl/common.json index dc3ac6e4..d24c8279 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 794112fa..16ef3c07 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index 389acd06..651a43f8 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1178,6 +1178,9 @@ "description": "Создавайте правила для автоматической сортировки, маркировки и управления входящими письмами", "add_rule": "Добавить правило", "no_rules": "Нет правил фильтрации", + "vacation_active": "Автоответ отпуска активен", + "vacation_active_description": "Автоматический ответ включён для входящих сообщений", + "vacation_configure": "Настроить", "no_rules_description": "Создайте правила для автоматической организации входящей почты", "edit_rule": "Редактировать правило", "new_rule": "Новое правило", diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 33942a4e..b9344b97 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -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 | undefined, + calendars: Calendar[], + targetAccountId?: string +): Record | 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()( }); // 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()( try { // Resolve shared calendar context from calendarIds let targetAccountId = event.accountId; - const cleanEvent = { ...event }; + const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event }); if (event.calendarIds) { const remapped: Record = {}; for (const calId of Object.keys(event.calendarIds)) { @@ -145,9 +205,40 @@ export const useCalendarStore = create()( 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()( 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 = {}; for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) { @@ -211,7 +302,7 @@ export const useCalendarStore = create()( 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()( 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()( const realCalendarId = cal?.originalId || calendarId; const targetAccountId = cal?.accountId; for (const event of events) { - const src = event as Partial; + const src = sanitizeOutgoingCalendarEventData(event as Partial); try { let cleanParticipants: Record | null = null; if (src.participants) { @@ -402,7 +493,8 @@ export const useCalendarStore = create()( if (v === undefined || v === null) delete (data as Record)[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 : '';