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:
Matthieu MALVACHE
2026-02-17 01:09:57 +01:00
committed by Matthieu MALVACHE
parent 74763b384d
commit b65b2f26df
30 changed files with 2352 additions and 137 deletions
@@ -0,0 +1,389 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Calendar,
CalendarCheck,
CalendarX,
Clock,
MapPin,
Users,
Loader2,
Check,
HelpCircle,
X,
AlertCircle,
ChevronDown,
} from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import type { Email, CalendarEvent } from '@/lib/jmap/types';
import {
findCalendarAttachment,
getInvitationMethod,
formatEventSummary,
findParticipantByEmail,
} from '@/lib/calendar-invitation';
import { cn } from '@/lib/utils';
import { sanitizeColor } from '@/components/calendar/event-card';
interface CalendarInvitationBannerProps {
email: Email;
}
type BannerState = 'loading' | 'parsed' | 'rsvp-sent' | 'imported' | 'error';
export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProps) {
const t = useTranslations('email_viewer.calendar_invitation');
const format = useFormatter();
const client = useAuthStore((s) => s.client);
const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email);
const { calendars, supportsCalendar, importEvents, rsvpEvent, events: storeEvents } = useCalendarStore();
const [state, setState] = useState<BannerState>('loading');
const [parsedEvent, setParsedEvent] = useState<Partial<CalendarEvent> | null>(null);
const [rsvpStatus, setRsvpStatus] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const attachment = findCalendarAttachment(email);
const parseEvent = useCallback(async () => {
if (!client || !attachment) return;
setState('loading');
try {
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId);
if (events.length > 0) {
const parsed = events[0];
setParsedEvent(parsed);
if (parsed.uid && supportsCalendar) {
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
if (!storeHasIt) {
try {
const serverEvents = await client.queryCalendarEvents({});
const matching = serverEvents.filter((e) => e.uid === parsed.uid);
if (matching.length > 0) {
useCalendarStore.setState((s) => {
const existingIds = new Set(s.events.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
});
}
} catch { /* ignore lookup failure */ }
}
}
setState('parsed');
} else {
setState('error');
}
} catch {
setState('error');
}
}, [client, attachment, supportsCalendar]);
useEffect(() => {
if (attachment) {
parseEvent();
}
}, [email.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (calendars.length > 0 && !selectedCalendarId) {
const defaultCal = calendars.find((c) => c.isDefault) || calendars[0];
setSelectedCalendarId(defaultCal.id);
}
}, [calendars, selectedCalendarId]);
if (!attachment) return null;
const method = parsedEvent ? getInvitationMethod(parsedEvent) : 'unknown';
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
const isCancellation = method === 'cancel';
const existingEvent = parsedEvent?.uid
? storeEvents.find((e) => e.uid === parsedEvent.uid)
: null;
const myParticipantParsed = parsedEvent && currentUserEmail
? findParticipantByEmail(parsedEvent, currentUserEmail)
: null;
const myParticipantServer = existingEvent && currentUserEmail
? findParticipantByEmail(existingEvent, currentUserEmail)
: null;
const myParticipant = myParticipantServer || myParticipantParsed;
const currentRsvp = rsvpStatus
|| myParticipantServer?.participant.participationStatus
|| myParticipantParsed?.participant.participationStatus
|| null;
const handleRsvp = async (status: 'accepted' | 'tentative' | 'declined') => {
if (!client || !parsedEvent || isProcessing) return;
const calId = selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
setIsProcessing(true);
try {
if (existingEvent && myParticipant) {
await rsvpEvent(client, existingEvent.id, myParticipant.id, status);
setRsvpStatus(status);
setState('rsvp-sent');
} else if (calId) {
const imported = await importEvents(client, [parsedEvent], calId);
if (imported > 0) {
const newEvent = useCalendarStore.getState().events.find(
(e) => e.uid === parsedEvent.uid
);
const participant = myParticipant
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
if (newEvent && participant) {
await rsvpEvent(client, newEvent.id, participant.id, status);
}
setRsvpStatus(status);
setState('rsvp-sent');
} else {
setState('error');
}
} else {
setState('error');
}
} catch {
setState('error');
} finally {
setIsProcessing(false);
}
};
const handleImport = async (calendarId?: string) => {
const calId = calendarId || selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
if (!client || !parsedEvent || !calId || isProcessing) {
if (!calId) setState('error');
return;
}
setIsProcessing(true);
try {
const count = await importEvents(client, [parsedEvent], calId);
if (count > 0) {
setState('imported');
} else {
setState('error');
}
} catch {
setState('error');
} finally {
setIsProcessing(false);
}
};
const formatDateTime = (dateStr: string | null) => {
if (!dateStr) return '';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
return format.dateTime(date, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};
if (state === 'loading') {
return (
<div className="flex items-center gap-2">
<Calendar className="w-3.5 h-3.5 text-primary" />
<Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">{t('loading')}</span>
</div>
);
}
if (state === 'error') {
return (
<div className="flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">{t('parse_error')}</span>
</div>
);
}
if (state === 'imported') {
return (
<div className="flex items-center gap-2">
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground">{t('added')}</span>
</div>
);
}
if (state === 'rsvp-sent') {
return (
<div className="flex items-center gap-2">
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground">{t('rsvp_sent')}</span>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{/* Event info row */}
<div className="flex items-start gap-2 flex-wrap">
{isCancellation ? (
<CalendarX className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
) : (
<Calendar className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className={cn(
"text-sm font-medium",
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
)}>
{isCancellation ? t('cancelled_title') : t('title')}
{summary?.title && `: ${summary.title}`}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
{summary?.start && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
)}
{summary?.location && (
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{summary.location}
</span>
)}
{summary?.organizer && (
<span>{t('organizer', { name: summary.organizer })}</span>
)}
{summary && summary.attendeeCount > 0 && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{t('attendees', { count: summary.attendeeCount })}
</span>
)}
</div>
</div>
</div>
{/* Action row */}
{!isCancellation && (
<div className="flex items-center gap-2 ml-6 flex-wrap">
{supportsCalendar && myParticipant && (
<>
<button
onClick={() => handleRsvp('accepted')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'accepted'}
className={cn(
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
currentRsvp === 'accepted'
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<Check className="w-3.5 h-3.5" />
{t('accept')}
</button>
<button
onClick={() => handleRsvp('tentative')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'tentative'}
className={cn(
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
currentRsvp === 'tentative'
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<HelpCircle className="w-3.5 h-3.5" />
{t('maybe')}
</button>
<button
onClick={() => handleRsvp('declined')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'declined'}
className={cn(
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
currentRsvp === 'declined'
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<X className="w-3.5 h-3.5" />
{t('decline')}
</button>
<div className="w-px h-4 bg-border" />
</>
)}
{supportsCalendar && existingEvent && !myParticipant && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<CalendarCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
{t('already_in_calendar')}
</span>
)}
{supportsCalendar && !existingEvent && (
<div className="relative">
<button
onClick={() => {
if (calendars.length <= 1) {
handleImport();
} else {
setShowCalendarPicker(!showCalendarPicker);
}
}}
disabled={isProcessing}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground hover:bg-muted px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50"
>
<CalendarCheck className="w-3.5 h-3.5" />
{t('add_to_calendar')}
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
</button>
{showCalendarPicker && calendars.length > 1 && (
<div className="absolute left-0 top-full mt-1 w-52 bg-background rounded-md shadow-lg border border-border z-10 py-1">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t('select_calendar')}
</div>
{calendars.map((cal) => (
<button
key={cal.id}
onClick={() => {
setShowCalendarPicker(false);
handleImport(cal.id);
}}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: sanitizeColor(cal.color) }}
/>
<span className="truncate text-foreground">{cal.name}</span>
</button>
))}
</div>
)}
</div>
)}
{!supportsCalendar && (
<span className="text-xs text-muted-foreground italic">{t('no_calendar')}</span>
)}
</div>
)}
</div>
);
}
+55 -35
View File
@@ -54,9 +54,11 @@ import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { transformInlineStyles } from "@/lib/color-transform";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { findCalendarAttachment } from "@/lib/calendar-invitation";
interface EmailViewerProps {
email: Email | null;
@@ -198,7 +200,7 @@ export function EmailViewer({
const { isTablet } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities } = useAuthStore();
const theme = useThemeStore((state) => state.theme);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [allowExternalContent, setAllowExternalContent] = useState(false);
const [hasBlockedContent, setHasBlockedContent] = useState(false);
@@ -425,24 +427,23 @@ export function EmailViewer({
if (shouldBlockExternal) {
sanitizeConfig.FORBID_TAGS.push('link');
sanitizeConfig.FORBID_ATTR.push('background');
}
// Hook to modify src attributes
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
const htmlNode = node as HTMLElement;
// Block external images
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
const htmlNode = node as HTMLElement;
if (shouldBlockExternal) {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
node.setAttribute('data-blocked-src', src);
// Use a subtle transparent placeholder
node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
node.setAttribute('alt', '');
htmlNode.style.display = 'none'; // Hide blocked images completely for cleaner look
htmlNode.style.display = 'none';
blockedExternalContent = true;
}
}
// Block external stylesheets and resources in style attributes
if (htmlNode.style) {
const style = htmlNode.style.cssText;
if (style && style.includes('url(')) {
@@ -452,18 +453,29 @@ export function EmailViewer({
blockedExternalContent = true;
}
}
}
}
// Transform inline color styles for dark mode readability
if (theme === 'dark') {
const originalStyles = htmlNode.style.cssText;
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
if (transformedStyles !== originalStyles) {
htmlNode.style.cssText = transformedStyles;
}
if (resolvedTheme === 'dark') {
if (htmlNode.style) {
const originalStyles = htmlNode.style.cssText;
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
if (transformedStyles !== originalStyles) {
htmlNode.style.cssText = transformedStyles;
}
}
});
}
const colorAttr = node.getAttribute('color');
if (colorAttr) {
node.setAttribute('color', transformColorForDarkMode(colorAttr));
}
const bgcolorAttr = node.getAttribute('bgcolor');
if (bgcolorAttr) {
node.setAttribute('bgcolor', transformBgColorForDarkMode(bgcolorAttr));
}
}
});
// Sanitize HTML to prevent XSS
let cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig);
@@ -529,7 +541,7 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false
};
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, theme]);
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme]);
// Detect List-Unsubscribe header for newsletter banners
const listHeaders = useMemo(() => {
@@ -541,6 +553,8 @@ export function EmailViewer({
listHeaders?.listUnsubscribe?.preferred &&
!dismissedUnsubBanners.has(email?.messageId || '');
const hasCalendarInvitation = email ? !!findCalendarAttachment(email) : false;
// Show loading skeleton while email is being fetched
if (isLoading && !email) {
return (
@@ -1292,16 +1306,16 @@ export function EmailViewer({
</div>
</div>
{/* Unified Notification Banner - External Content + Unsubscribe */}
{/* Unified Notification Banner - External Content + Unsubscribe + Calendar Invitation */}
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe)) && (
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe) ||
hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-4xl mx-auto px-6 py-1.5">
<div className="flex flex-col md:flex-row md:items-center md:justify-center gap-3 isolate">
<div className="flex flex-col gap-3 isolate">
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-center gap-3 flex-wrap">
{/* Load images button - only in 'ask' mode */}
<div className="flex items-center gap-3 flex-wrap md:justify-center">
{externalContentPolicy === 'ask' && (
<button
onClick={() => setAllowExternalContent(true)}
@@ -1311,7 +1325,6 @@ export function EmailViewer({
{t('load_external_content')}
</button>
)}
{/* Trust sender button - in both 'ask' and 'block' modes */}
{email.from?.[0]?.email && (
<button
onClick={() => {
@@ -1331,16 +1344,23 @@ export function EmailViewer({
{/* Unsubscribe Controls */}
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}}
/>
<div className="flex items-center md:justify-center">
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}}
/>
</div>
)}
{/* Calendar Invitation Banner */}
{hasCalendarInvitation && (
<CalendarInvitationBanner email={email} />
)}
</div>
</div>
+30 -5
View File
@@ -4,6 +4,8 @@ import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn } from "@/lib/utils";
@@ -217,6 +219,7 @@ function EmailCard({
onMarkAsRead,
}: EmailCardProps) {
const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const sender = email.from?.[0];
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
@@ -271,8 +274,10 @@ function EmailCard({
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
if (!allowExternal) {
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
const htmlNode = node as HTMLElement;
if (!allowExternal) {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
@@ -290,8 +295,28 @@ function EmailCard({
blockedExternalContent = true;
}
}
});
}
}
if (resolvedTheme === 'dark') {
if (htmlNode.style) {
const originalStyles = htmlNode.style.cssText;
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
if (transformedStyles !== originalStyles) {
htmlNode.style.cssText = transformedStyles;
}
}
const colorAttr = node.getAttribute('color');
if (colorAttr) {
node.setAttribute('color', transformColorForDarkMode(colorAttr));
}
const bgcolorAttr = node.getAttribute('bgcolor');
if (bgcolorAttr) {
node.setAttribute('bgcolor', transformBgColorForDarkMode(bgcolorAttr));
}
}
});
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
DOMPurify.removeHook('afterSanitizeAttributes');
@@ -324,7 +349,7 @@ function EmailCard({
}
return { html: "", isHtml: false };
}, [email, allowExternal]);
}, [email, allowExternal, resolvedTheme]);
return (
<div className={cn(