Files
SRCmail/lib/calendar-invitation.ts
T
Matthieu MALVACHEandMatthieu MALVACHE b65b2f26df feat: add participant scheduling with iTIP invitations and inline calendar invitation banner
Add organizer/attendee UI with RSVP, contact autocomplete for participants,
scheduling messages, and inline calendar invitation banner in email viewer
with auto-detect .ics attachments, RSVP/import to calendar, and cancellation display.
2026-02-17 01:09:57 +01:00

147 lines
3.8 KiB
TypeScript

import type { Email, Attachment, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
export function findCalendarAttachment(email: Email): Attachment | null {
if (email.attachments) {
for (const att of email.attachments) {
if (
att.type === 'text/calendar' ||
att.type === 'application/ics' ||
att.name?.toLowerCase().endsWith('.ics') ||
att.name?.toLowerCase().endsWith('.ical')
) {
return att;
}
}
}
if (email.textBody) {
for (const part of email.textBody) {
if (part.type === 'text/calendar' && part.blobId) {
return {
partId: part.partId,
blobId: part.blobId,
size: part.size,
name: part.name || 'invite.ics',
type: 'text/calendar',
};
}
}
}
return null;
}
export function getInvitationMethod(
event: Partial<CalendarEvent>
): 'request' | 'reply' | 'cancel' | 'unknown' {
if (event.status === 'cancelled') {
return 'cancel';
}
if (event.participants && Object.keys(event.participants).length > 0) {
const hasOrganizer = Object.values(event.participants).some(
(p: CalendarParticipant) => p.roles?.owner || p.roles?.chair
);
if (hasOrganizer) {
return 'request';
}
}
return 'unknown';
}
export interface EventSummary {
title: string;
start: string | null;
end: string | null;
location: string | null;
organizer: string | null;
organizerEmail: string | null;
attendeeCount: number;
}
export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary {
let location: string | null = null;
if (event.locations) {
const firstLocation = Object.values(event.locations)[0];
if (firstLocation?.name) {
location = firstLocation.name;
}
}
let organizer: string | null = null;
let organizerEmail: string | null = null;
let attendeeCount = 0;
if (event.participants) {
for (const p of Object.values(event.participants)) {
if (p.roles?.owner || p.roles?.chair) {
organizer = p.name || p.email || null;
organizerEmail = p.email || null;
}
if (p.roles?.attendee) {
attendeeCount++;
}
}
}
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);
}
return {
title: event.title || '',
start: event.utcStart || event.start || null,
end,
location,
organizer,
organizerEmail,
attendeeCount,
};
}
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)?)?$/);
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 date = new Date(start);
if (isNaN(date.getTime())) return null;
date.setDate(date.getDate() + days);
date.setHours(date.getHours() + hours);
date.setMinutes(date.getMinutes() + minutes);
date.setSeconds(date.getSeconds() + seconds);
return date.toISOString();
}
export function findParticipantByEmail(
event: Partial<CalendarEvent>,
email: string
): { id: string; participant: CalendarParticipant } | null {
if (!event.participants || !email) return null;
const lowerEmail = email.toLowerCase();
for (const [id, p] of Object.entries(event.participants)) {
if (p.email?.toLowerCase() === lowerEmail) {
return { id, participant: p };
}
if (p.sendTo) {
for (const addr of Object.values(p.sendTo)) {
if (addr.replace('mailto:', '').toLowerCase() === lowerEmail) {
return { id, participant: p };
}
}
}
}
return null;
}