feat: add calendar event normalization and sanitization functions with tests
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user