Files
SRCmail/lib/calendar-participants.ts
T
Linus Rath 0effb97691 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
2026-03-23 16:22:15 +01:00

139 lines
4.2 KiB
TypeScript

import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
export interface ParticipantInfo {
id: string;
name: string;
email: string;
status: CalendarParticipant['participationStatus'];
isOrganizer: boolean;
}
export interface StatusCounts {
accepted: number;
declined: number;
tentative: number;
'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 && participantMatchesEmail(p, lower)
);
}
export function getUserParticipantId(event: CalendarEvent, userEmails: string[]): string | null {
if (!event.participants) return null;
const lower = userEmails.map(e => e.toLowerCase());
for (const [id, p] of Object.entries(event.participants)) {
if (participantMatchesEmail(p, lower)) return id;
}
return null;
}
export function getUserStatus(
event: CalendarEvent,
userEmails: string[]
): CalendarParticipant['participationStatus'] | null {
if (!event.participants) return null;
const lower = userEmails.map(e => e.toLowerCase());
for (const p of Object.values(event.participants)) {
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]) => {
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 {
const counts: StatusCounts = { accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 };
if (!event.participants) return counts;
for (const p of Object.values(event.participants)) {
const s = p.participationStatus || 'needs-action';
if (s in counts) counts[s as keyof StatusCounts]++;
}
return counts;
}
export function getParticipantCount(event: CalendarEvent): number {
if (!event.participants) return 0;
return Object.keys(event.participants).length;
}
export function buildParticipantMap(
organizer: { name: string; email: string },
attendees: { name: string; email: string }[]
): Record<string, Partial<CalendarParticipant>> {
const participants: Record<string, Partial<CalendarParticipant>> = {};
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,
roles: { owner: true, attendee: true },
participationStatus: 'accepted',
scheduleAgent: 'server',
sendTo: { imip: `mailto:${organizer.email}` },
expectReply: false,
kind: 'individual',
};
attendees.forEach((a) => {
participants[generateId()] = {
'@type': 'Participant',
name: a.name,
email: a.email,
roles: { attendee: true },
participationStatus: 'needs-action',
scheduleAgent: 'server',
sendTo: { imip: `mailto:${a.email}` },
expectReply: true,
kind: 'individual',
};
});
return participants;
}