refactor: fix bugs in calendar logic across duration parsing, RFC compliance, and event handling

- Fix buildDuration() trailing "T" producing invalid ISO 8601 durations
- Fix DURATION_RE missing week (W) support in alerts and invitation parsing
- Fix computeFireTime() end fallback when utcEnd is missing
- Fix recurrenceOverrides patch escaping per RFC 6901 (updateEvent/rsvpEvent)
- Fix layoutOverlappingEvents endMin overflow past 1440
- Fix addDurationToDate() to support weeks and use UTC methods for UTC inputs
- Fix getEffectiveAlerts() null guard on calendarIds
- Fix buildAllDayDuration() DST-safe day calculation using differenceInCalendarDays
- Fix participant matching to check calendarAddress and sendTo (not just email)
- Fix buildParticipantMap() using crypto.randomUUID() instead of hardcoded IDs
- Fix overnight preview negative endMin in week view
- Fix sendImipInvitation() to emit DURATION when utcEnd is absent
- Fix sendImipCancellation() to validate status before sending
- Fix createEvent() to remap all calendarIds for shared calendars
- Fix getCalendarTasks() to clone before mutating @type
- Fix importEvents() error matching to include 'duplicate' and 'conflict'
- Fix looksLikeReply() false positive by requiring organizer + responded attendee
- Fix alert offset regex to require T before minutes
- Fix handleDuplicate() to generate new UID
- Fix formatSnapTime() input clamping
- Replace console.log/error with debug.log/error/warn in iMIP functions
This commit is contained in:
Linus Rath
2026-03-23 16:22:15 +01:00
parent f7ee204262
commit 0effb97691
8 changed files with 153 additions and 80 deletions
+18 -9
View File
@@ -6,6 +6,7 @@ import type {
Calendar,
CalendarTask,
} from '@/lib/jmap/types';
import { parseDuration } from '@/components/calendar/event-card';
export interface PendingAlert {
eventId: string;
@@ -17,19 +18,20 @@ export interface PendingAlert {
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
const DURATION_RE = /^(-?)P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
export function parseAlertOffset(offset: string): number | null {
const match = DURATION_RE.exec(offset);
if (!match) return null;
const negative = match[1] === '-';
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);
const weeks = parseInt(match[2] || '0', 10);
const days = parseInt(match[3] || '0', 10);
const hours = parseInt(match[4] || '0', 10);
const minutes = parseInt(match[5] || '0', 10);
const seconds = parseInt(match[6] || '0', 10);
const ms = ((days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000;
const ms = ((weeks * 7 * 24 * 60 * 60) + (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000;
return negative ? -ms : ms;
}
@@ -47,9 +49,15 @@ export function computeFireTime(
let baseTime: number;
if (trigger.relativeTo === 'end') {
baseTime = event.utcEnd
? new Date(event.utcEnd).getTime()
: new Date(event.start).getTime();
if (event.utcEnd) {
baseTime = new Date(event.utcEnd).getTime();
} else {
// Compute end from start + duration
const startMs = new Date(event.start).getTime();
if (Number.isNaN(startMs)) return null;
const durationMin = parseDuration(event.duration);
baseTime = startMs + durationMin * 60000;
}
} else {
baseTime = event.utcStart
? new Date(event.utcStart).getTime()
@@ -68,6 +76,7 @@ export function getEffectiveAlerts(
return event.alerts;
}
if (!event.calendarIds) return null;
const calendarId = Object.keys(event.calendarIds)[0];
if (!calendarId) return null;
+29 -24
View File
@@ -251,10 +251,11 @@ function looksLikeReply(event: Partial<CalendarEvent>): boolean {
const participants = Object.values(event.participants);
const hasOrganizer = participants.some((participant) => isOrganizerParticipant(participant));
if (hasOrganizer) return false;
if (!hasOrganizer) return false;
return participants.some((participant) =>
participant.roles?.attendee
&& !isOrganizerParticipant(participant)
&& (
participant.participationStatus !== 'needs-action'
|| !!participant.participationComment
@@ -373,6 +374,10 @@ export function getInvitationMethod(
return 'cancel';
}
if (looksLikeReply(event)) {
return 'reply';
}
if (event.participants && Object.keys(event.participants).length > 0) {
const hasOrganizer = Object.values(event.participants).some(
(p: CalendarParticipant) => isOrganizerParticipant(p)
@@ -382,10 +387,6 @@ export function getInvitationMethod(
}
}
if (looksLikeReply(event)) {
return 'reply';
}
return 'unknown';
}
@@ -524,36 +525,40 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
}
function addDurationToDate(start: string, duration: string, _timeZone?: string | null): string | null {
const match = duration.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
const match = duration.match(/^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
if (!match) return null;
const days = parseInt(match[1] || '0');
const hours = parseInt(match[2] || '0');
const minutes = parseInt(match[3] || '0');
const seconds = parseInt(match[4] || '0');
const weeks = parseInt(match[1] || '0');
const days = parseInt(match[2] || '0') + weeks * 7;
const hours = parseInt(match[3] || '0');
const minutes = parseInt(match[4] || '0');
const seconds = parseInt(match[5] || '0');
const date = new Date(start);
if (isNaN(date.getTime())) return null;
const isUTC = start.endsWith('Z') || start.includes('+');
if (isUTC) {
date.setUTCDate(date.getUTCDate() + days);
date.setUTCHours(date.getUTCHours() + hours);
date.setUTCMinutes(date.getUTCMinutes() + minutes);
date.setUTCSeconds(date.getUTCSeconds() + seconds);
return date.toISOString();
}
date.setDate(date.getDate() + days);
date.setHours(date.getHours() + hours);
date.setMinutes(date.getMinutes() + minutes);
date.setSeconds(date.getSeconds() + seconds);
// If the input is a local datetime (no UTC 'Z' suffix), return a local
// format string so that all-day date arithmetic isn't shifted by the
// browser's UTC offset (toISOString converts to UTC).
if (!start.endsWith('Z') && !start.includes('+')) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const s = String(date.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d}T${h}:${min}:${s}`;
}
return date.toISOString();
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const s = String(date.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d}T${h}:${min}:${s}`;
}
export function findParticipantByEmail(
+45 -13
View File
@@ -15,11 +15,30 @@ export interface StatusCounts {
'needs-action': number;
}
/**
* Check if a participant matches any of the given email addresses.
* Checks p.email, p.calendarAddress (mailto:...), and p.sendTo values.
*/
function participantMatchesEmail(p: CalendarParticipant, lowerEmails: string[]): boolean {
if (p.email && lowerEmails.includes(p.email.toLowerCase())) return true;
if (p.calendarAddress) {
const addr = p.calendarAddress.replace(/^mailto:/i, '').toLowerCase();
if (addr && lowerEmails.includes(addr)) return true;
}
if (p.sendTo) {
for (const addr of Object.values(p.sendTo)) {
const normalized = addr.replace(/^mailto:/i, '').toLowerCase();
if (normalized && lowerEmails.includes(normalized)) return true;
}
}
return false;
}
export function isOrganizer(event: CalendarEvent, userEmails: string[]): boolean {
if (!event.participants) return false;
const lower = userEmails.map(e => e.toLowerCase());
return Object.values(event.participants).some(p =>
p.roles?.owner && lower.includes(p.email?.toLowerCase())
p.roles?.owner && participantMatchesEmail(p, lower)
);
}
@@ -27,7 +46,7 @@ export function getUserParticipantId(event: CalendarEvent, userEmails: string[])
if (!event.participants) return null;
const lower = userEmails.map(e => e.toLowerCase());
for (const [id, p] of Object.entries(event.participants)) {
if (lower.includes(p.email?.toLowerCase())) return id;
if (participantMatchesEmail(p, lower)) return id;
}
return null;
}
@@ -39,20 +58,29 @@ export function getUserStatus(
if (!event.participants) return null;
const lower = userEmails.map(e => e.toLowerCase());
for (const p of Object.values(event.participants)) {
if (lower.includes(p.email?.toLowerCase())) return p.participationStatus;
if (participantMatchesEmail(p, lower)) return p.participationStatus;
}
return null;
}
export function getParticipantList(event: CalendarEvent): ParticipantInfo[] {
if (!event.participants) return [];
return Object.entries(event.participants).map(([id, p]) => ({
id,
name: p.name || '',
email: p.email || '',
status: p.participationStatus || 'needs-action',
isOrganizer: !!p.roles?.owner,
}));
return Object.entries(event.participants).map(([id, p]) => {
let email = p.email || '';
if (!email && p.calendarAddress) {
email = p.calendarAddress.replace(/^mailto:/i, '');
}
if (!email && p.sendTo?.imip) {
email = p.sendTo.imip.replace(/^mailto:/i, '');
}
return {
id,
name: p.name || '',
email,
status: p.participationStatus || 'needs-action',
isOrganizer: !!p.roles?.owner,
};
});
}
export function getStatusCounts(event: CalendarEvent): StatusCounts {
@@ -76,7 +104,11 @@ export function buildParticipantMap(
): Record<string, Partial<CalendarParticipant>> {
const participants: Record<string, Partial<CalendarParticipant>> = {};
participants['organizer'] = {
const generateId = () => typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `p-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
participants[generateId()] = {
'@type': 'Participant',
name: organizer.name,
email: organizer.email,
@@ -88,8 +120,8 @@ export function buildParticipantMap(
kind: 'individual',
};
attendees.forEach((a, i) => {
participants[`attendee-${i}`] = {
attendees.forEach((a) => {
participants[generateId()] = {
'@type': 'Participant',
name: a.name,
email: a.email,
+5 -6
View File
@@ -40,9 +40,7 @@ export function normalizeAllDayDuration(duration: string | undefined): string |
}
export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
const startDay = startOfDay(start);
const endDay = startOfDay(inclusiveEnd);
const dayCount = Math.max(1, Math.round((endDay.getTime() - startDay.getTime()) / 86400000) + 1);
const dayCount = Math.max(1, differenceInCalendarDays(startOfDay(inclusiveEnd), startOfDay(start)) + 1);
return `P${dayCount}D`;
}
@@ -113,7 +111,7 @@ export function layoutOverlappingEvents(
for (const event of sorted) {
const start = parseISO(event.start);
const startMin = start.getHours() * 60 + start.getMinutes();
const endMin = startMin + Math.max(15, parseDuration(event.duration));
const endMin = Math.min(1440, startMin + Math.max(15, parseDuration(event.duration)));
let placed = false;
for (let col = 0; col < columns.length; col++) {
if (columns[col].every(e => e.end <= startMin)) {
@@ -135,8 +133,9 @@ export function layoutOverlappingEvents(
}
export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
const clamped = Math.max(0, Math.min(1440, minutes));
const h = Math.floor(clamped / 60) % 24;
const m = clamped % 60;
if (timeFormat === "12h") {
return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
}
+16 -9
View File
@@ -2,6 +2,7 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, Emai
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
import { debug } from "@/lib/debug";
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
@@ -1684,7 +1685,7 @@ export class JMAPClient implements IJMAPClient {
lines.push('END:VCALENDAR');
const icsContent = lines.join('\r\n') + '\r\n';
console.log('[iMIP DEBUG] Generated ICS:\n' + icsContent);
debug.log('[iMIP] Generated ICS:\n' + icsContent);
const statusLabels: Record<string, string> = {
ACCEPTED: 'Accepted',
@@ -1694,7 +1695,7 @@ export class JMAPClient implements IJMAPClient {
const statusLabel = statusLabels[opts.status] || opts.status;
const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
console.log('[iMIP DEBUG] identityId:', finalIdentityId);
debug.log('[iMIP] identityId:', finalIdentityId);
const emailId = `imip-reply-${Date.now()}`;
const emailCreate: Record<string, unknown> = {
@@ -1727,27 +1728,27 @@ export class JMAPClient implements IJMAPClient {
}, "1"],
];
console.log('[iMIP DEBUG] Sending JMAP request with', methodCalls.length, 'method calls');
console.log('[iMIP DEBUG] Email create payload:', JSON.stringify(emailCreate, null, 2));
debug.log('[iMIP] Sending JMAP request with', methodCalls.length, 'method calls');
debug.log('[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2));
const response = await this.request(methodCalls);
console.log('[iMIP DEBUG] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
debug.log('[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) {
if (methodName.endsWith('/error')) {
console.error('[iMIP DEBUG] method error:', methodName, result);
debug.error('[iMIP] method error:', methodName, result);
throw new Error(result.description || `iMIP reply failed: ${result.type}`);
}
if (result.notCreated) {
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
console.error('[iMIP DEBUG] create error:', JSON.stringify(result.notCreated, null, 2));
debug.error('[iMIP] create error:', JSON.stringify(result.notCreated, null, 2));
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP reply');
}
}
}
console.log('[iMIP DEBUG] sendImipReply completed successfully');
debug.log('[iMIP] sendImipReply completed successfully');
}
/**
@@ -1823,6 +1824,9 @@ export class JMAPClient implements IJMAPClient {
const formatted = formatIcalDate(event.utcEnd, event.timeZone);
lines.push(formatted.startsWith('TZID=') ? `DTEND;${formatted}` : `DTEND:${formatted}`);
}
} else if (event.duration) {
// Fallback: emit DURATION when utcEnd is absent (RFC 5545 §3.6.1)
lines.push(`DURATION:${event.duration}`);
}
if (event.title) lines.push(`SUMMARY:${event.title}`);
@@ -1907,6 +1911,9 @@ export class JMAPClient implements IJMAPClient {
*/
async sendImipCancellation(event: CalendarEvent): Promise<void> {
if (!event.participants) return;
if (event.status && event.status !== 'cancelled') {
debug.warn('sendImipCancellation called on non-cancelled event, status:', event.status);
}
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
@@ -3202,7 +3209,7 @@ export class JMAPClient implements IJMAPClient {
if (type !== 'Event' && 'progress' in obj && typeof obj.progress === 'string') return true;
return false;
}).map((e) => {
const task = e as unknown as CalendarTask;
const task = { ...e } as unknown as CalendarTask;
// Normalize @type for tasks detected by fallback heuristic
if (task['@type'] !== 'Task') {
(task as unknown as Record<string, unknown>)['@type'] = 'Task';