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
@@ -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 {