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.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user