feat: calendar invitations RSVP, trust assessment, file preview
This commit is contained in:
+439
-22
@@ -1,11 +1,348 @@
|
||||
import type { Email, Attachment, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPart } from '@/lib/jmap/types';
|
||||
|
||||
export type InvitationMethod =
|
||||
| 'publish'
|
||||
| 'request'
|
||||
| 'reply'
|
||||
| 'add'
|
||||
| 'cancel'
|
||||
| 'refresh'
|
||||
| 'counter'
|
||||
| 'declinecounter'
|
||||
| 'unknown';
|
||||
|
||||
export interface InvitationTrustAssessment {
|
||||
level: 'trusted' | 'caution' | 'warning';
|
||||
reason:
|
||||
| 'authentication_failed'
|
||||
| 'authentication_missing'
|
||||
| 'sender_mismatch'
|
||||
| 'sender_mismatch_unverified'
|
||||
| null;
|
||||
senderEmail: string | null;
|
||||
organizerEmail: string | null;
|
||||
}
|
||||
|
||||
export interface InvitationActorSummary {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
role: 'organizer' | 'attendee';
|
||||
participationStatus: CalendarParticipant['participationStatus'] | null;
|
||||
participationComment: string | null;
|
||||
}
|
||||
|
||||
const KNOWN_METHODS = new Set<InvitationMethod>([
|
||||
'publish',
|
||||
'request',
|
||||
'reply',
|
||||
'add',
|
||||
'cancel',
|
||||
'refresh',
|
||||
'counter',
|
||||
'declinecounter',
|
||||
]);
|
||||
|
||||
function parseContentType(value?: string | null): { mimeType: string; params: Record<string, string> } {
|
||||
if (!value) {
|
||||
return { mimeType: '', params: {} };
|
||||
}
|
||||
|
||||
const parts = value.split(';').map((part) => part.trim()).filter(Boolean);
|
||||
const [mimeType = '', ...paramParts] = parts;
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
for (const part of paramParts) {
|
||||
const separatorIndex = part.indexOf('=');
|
||||
if (separatorIndex === -1) continue;
|
||||
const key = part.slice(0, separatorIndex).trim().toLowerCase();
|
||||
const rawValue = part.slice(separatorIndex + 1).trim();
|
||||
params[key] = rawValue.replace(/^"|"$/g, '');
|
||||
}
|
||||
|
||||
return {
|
||||
mimeType: mimeType.toLowerCase(),
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function isCalendarMimeType(value?: string | null): boolean {
|
||||
const { mimeType } = parseContentType(value);
|
||||
return mimeType === 'text/calendar' || mimeType === 'application/ics' || mimeType === 'application/icalendar';
|
||||
}
|
||||
|
||||
function normalizeInvitationMethod(value?: string | null): InvitationMethod {
|
||||
if (!value) return 'unknown';
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return KNOWN_METHODS.has(normalized as InvitationMethod)
|
||||
? normalized as InvitationMethod
|
||||
: 'unknown';
|
||||
}
|
||||
|
||||
function extractMethodFromContentType(value?: string | null): InvitationMethod {
|
||||
const { params } = parseContentType(value);
|
||||
return normalizeInvitationMethod(params.method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract METHOD from raw ICS/iCalendar text content.
|
||||
* JMAP strips parameters from Content-Type (RFC 8621), so `text/calendar; method=REQUEST`
|
||||
* becomes just `text/calendar`. This function reads the METHOD property from the raw
|
||||
* VCALENDAR data as a reliable fallback.
|
||||
*/
|
||||
export function extractMethodFromRawIcs(rawText: string): InvitationMethod {
|
||||
const match = rawText.match(/^METHOD:(\S+)/m);
|
||||
return match ? normalizeInvitationMethod(match[1]) : 'unknown';
|
||||
}
|
||||
|
||||
function getHeaderValue(headers: Email['headers'] | undefined, headerName: string): string | null {
|
||||
if (!headers) return null;
|
||||
|
||||
const target = headerName.toLowerCase();
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (name.toLowerCase() !== target) continue;
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEmailAddress(value?: string | null): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
const normalized = value.trim().replace(/^mailto:/i, '').toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function getPrimaryAddressEmail(addresses?: Array<{ email?: string | null }>): string | null {
|
||||
if (!addresses) return null;
|
||||
|
||||
for (const address of addresses) {
|
||||
const normalized = normalizeEmailAddress(address.email);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getParticipantEmail(participant: CalendarParticipant): string | null {
|
||||
const directEmail = normalizeEmailAddress(participant.email);
|
||||
if (directEmail) {
|
||||
return directEmail;
|
||||
}
|
||||
|
||||
// Stalwart uses calendarAddress (mailto:...) instead of email/sendTo
|
||||
if (participant.calendarAddress) {
|
||||
const normalized = normalizeEmailAddress(participant.calendarAddress);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (!participant.sendTo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const address of Object.values(participant.sendTo)) {
|
||||
const normalized = normalizeEmailAddress(address);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getParticipantName(participant: CalendarParticipant): string | null {
|
||||
return participant.name || getParticipantEmail(participant);
|
||||
}
|
||||
|
||||
function isOrganizerParticipant(participant: CalendarParticipant): boolean {
|
||||
return Boolean(participant.roles?.owner || participant.roles?.chair);
|
||||
}
|
||||
|
||||
function getParticipantSignalScore(participant: CalendarParticipant): number {
|
||||
let score = 0;
|
||||
|
||||
if (participant.participationStatus && participant.participationStatus !== 'needs-action') {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
if (participant.participationComment) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
if (participant.scheduleStatus?.length) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function getOrganizerEmail(event: Partial<CalendarEvent>): string | null {
|
||||
if (event.participants) {
|
||||
for (const participant of Object.values(event.participants)) {
|
||||
if (isOrganizerParticipant(participant)) {
|
||||
return getParticipantEmail(participant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stalwart uses organizerCalendarAddress instead of roles.owner/chair
|
||||
if (event.organizerCalendarAddress) {
|
||||
return normalizeEmailAddress(event.organizerCalendarAddress);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasVerifiedAuthentication(email?: Pick<Email, 'authenticationResults'>): boolean {
|
||||
const authenticationResults = email?.authenticationResults;
|
||||
return Boolean(
|
||||
authenticationResults?.dmarc?.result === 'pass'
|
||||
|| authenticationResults?.dkim?.result === 'pass'
|
||||
|| authenticationResults?.spf?.result === 'pass'
|
||||
);
|
||||
}
|
||||
|
||||
function hasAuthenticationFailure(email?: Pick<Email, 'authenticationResults'>): boolean {
|
||||
const authenticationResults = email?.authenticationResults;
|
||||
return Boolean(
|
||||
authenticationResults?.dmarc?.result === 'fail'
|
||||
|| authenticationResults?.dkim?.result === 'fail'
|
||||
|| authenticationResults?.dkim?.result === 'policy'
|
||||
|| authenticationResults?.dkim?.result === 'permerror'
|
||||
|| authenticationResults?.spf?.result === 'fail'
|
||||
|| authenticationResults?.spf?.result === 'softfail'
|
||||
|| authenticationResults?.spf?.result === 'permerror'
|
||||
);
|
||||
}
|
||||
|
||||
function findCalendarBodyPart(parts?: EmailBodyPart[]): Attachment | null {
|
||||
if (!parts) return null;
|
||||
|
||||
for (const part of parts) {
|
||||
if (isCalendarMimeType(part.type) || part.name?.toLowerCase().endsWith('.ics') || part.name?.toLowerCase().endsWith('.ical')) {
|
||||
return {
|
||||
partId: part.partId,
|
||||
blobId: part.blobId,
|
||||
size: part.size,
|
||||
name: part.name || 'invite.ics',
|
||||
type: part.type,
|
||||
charset: part.charset,
|
||||
disposition: part.disposition,
|
||||
cid: part.cid,
|
||||
};
|
||||
}
|
||||
|
||||
const nested = findCalendarBodyPart(part.subParts);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function looksLikeReply(event: Partial<CalendarEvent>): boolean {
|
||||
if (!event.participants) return false;
|
||||
|
||||
const participants = Object.values(event.participants);
|
||||
const hasOrganizer = participants.some((participant) => isOrganizerParticipant(participant));
|
||||
if (hasOrganizer) return false;
|
||||
|
||||
return participants.some((participant) =>
|
||||
participant.roles?.attendee
|
||||
&& (
|
||||
participant.participationStatus !== 'needs-action'
|
||||
|| !!participant.participationComment
|
||||
|| !!participant.scheduleStatus?.length
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getInvitationActorSummary(
|
||||
event: Partial<CalendarEvent>,
|
||||
method: InvitationMethod,
|
||||
): InvitationActorSummary | null {
|
||||
if (!event.participants) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const participants = Object.values(event.participants);
|
||||
let organizer = participants.find((participant) => isOrganizerParticipant(participant)) ?? null;
|
||||
|
||||
// Stalwart uses organizerCalendarAddress instead of roles.owner/chair
|
||||
if (!organizer && event.organizerCalendarAddress) {
|
||||
organizer = participants.find(
|
||||
(p) => p.calendarAddress === event.organizerCalendarAddress
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
const attendees = participants.filter((participant) => participant !== organizer && !isOrganizerParticipant(participant));
|
||||
const respondingAttendee = [...attendees].sort((left, right) => (
|
||||
getParticipantSignalScore(right) - getParticipantSignalScore(left)
|
||||
))[0] ?? null;
|
||||
|
||||
const sourceParticipant = (() => {
|
||||
switch (method) {
|
||||
case 'reply':
|
||||
case 'counter':
|
||||
case 'refresh':
|
||||
return respondingAttendee;
|
||||
case 'declinecounter':
|
||||
case 'request':
|
||||
case 'publish':
|
||||
case 'add':
|
||||
case 'cancel':
|
||||
return organizer ?? respondingAttendee;
|
||||
default:
|
||||
return respondingAttendee ?? organizer;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!sourceParticipant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: getParticipantName(sourceParticipant),
|
||||
email: getParticipantEmail(sourceParticipant),
|
||||
role: isOrganizerParticipant(sourceParticipant) ? 'organizer' : 'attendee',
|
||||
participationStatus: sourceParticipant.participationStatus ?? null,
|
||||
participationComment: sourceParticipant.participationComment ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function getMethodFromEmail(email?: Pick<Email, 'headers' | 'attachments' | 'textBody' | 'htmlBody'>, attachment?: Pick<Attachment, 'type'> | null): InvitationMethod {
|
||||
const explicitAttachmentMethod = extractMethodFromContentType(attachment?.type);
|
||||
if (explicitAttachmentMethod !== 'unknown') {
|
||||
return explicitAttachmentMethod;
|
||||
}
|
||||
|
||||
if (email?.attachments) {
|
||||
for (const item of email.attachments) {
|
||||
if (!isCalendarMimeType(item.type)) continue;
|
||||
const method = extractMethodFromContentType(item.type);
|
||||
if (method !== 'unknown') return method;
|
||||
}
|
||||
}
|
||||
|
||||
for (const bodyPart of [findCalendarBodyPart(email?.textBody), findCalendarBodyPart(email?.htmlBody)]) {
|
||||
const method = extractMethodFromContentType(bodyPart?.type);
|
||||
if (method !== 'unknown') return method;
|
||||
}
|
||||
|
||||
return extractMethodFromContentType(getHeaderValue(email?.headers, 'Content-Type'));
|
||||
}
|
||||
|
||||
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' ||
|
||||
isCalendarMimeType(att.type) ||
|
||||
att.name?.toLowerCase().endsWith('.ics') ||
|
||||
att.name?.toLowerCase().endsWith('.ical')
|
||||
) {
|
||||
@@ -14,42 +351,100 @@ export function findCalendarAttachment(email: Email): Attachment | null {
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const inlineAttachment = findCalendarBodyPart(email.textBody) || findCalendarBodyPart(email.htmlBody);
|
||||
if (inlineAttachment) return inlineAttachment;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getInvitationMethod(
|
||||
event: Partial<CalendarEvent>
|
||||
): 'request' | 'reply' | 'cancel' | 'unknown' {
|
||||
event: Partial<CalendarEvent>,
|
||||
options?: {
|
||||
email?: Pick<Email, 'headers' | 'attachments' | 'textBody' | 'htmlBody'>;
|
||||
attachment?: Pick<Attachment, 'type'> | null;
|
||||
}
|
||||
): InvitationMethod {
|
||||
const explicitMethod = getMethodFromEmail(options?.email, options?.attachment);
|
||||
if (explicitMethod !== 'unknown') {
|
||||
return explicitMethod;
|
||||
}
|
||||
|
||||
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
|
||||
(p: CalendarParticipant) => isOrganizerParticipant(p)
|
||||
);
|
||||
if (hasOrganizer) {
|
||||
return 'request';
|
||||
}
|
||||
}
|
||||
|
||||
if (looksLikeReply(event)) {
|
||||
return 'reply';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function getInvitationTrustAssessment(
|
||||
event: Partial<CalendarEvent>,
|
||||
email?: Pick<Email, 'from' | 'replyTo' | 'authenticationResults'>,
|
||||
method: InvitationMethod = getInvitationMethod(event)
|
||||
): InvitationTrustAssessment {
|
||||
const organizerEmail = getOrganizerEmail(event);
|
||||
const senderEmail = getPrimaryAddressEmail(email?.from) || getPrimaryAddressEmail(email?.replyTo);
|
||||
const verifiedAuthentication = hasVerifiedAuthentication(email);
|
||||
const authenticationFailure = hasAuthenticationFailure(email);
|
||||
const senderMismatch = Boolean(senderEmail && organizerEmail && senderEmail !== organizerEmail);
|
||||
const expectsAuthenticatedTransport = method !== 'unknown';
|
||||
|
||||
if (senderMismatch && (authenticationFailure || !verifiedAuthentication)) {
|
||||
return {
|
||||
level: 'warning',
|
||||
reason: 'sender_mismatch_unverified',
|
||||
senderEmail,
|
||||
organizerEmail,
|
||||
};
|
||||
}
|
||||
|
||||
if (authenticationFailure) {
|
||||
return {
|
||||
level: 'warning',
|
||||
reason: 'authentication_failed',
|
||||
senderEmail,
|
||||
organizerEmail,
|
||||
};
|
||||
}
|
||||
|
||||
if (senderMismatch) {
|
||||
return {
|
||||
level: 'caution',
|
||||
reason: 'sender_mismatch',
|
||||
senderEmail,
|
||||
organizerEmail,
|
||||
};
|
||||
}
|
||||
|
||||
if (expectsAuthenticatedTransport && !verifiedAuthentication) {
|
||||
return {
|
||||
level: 'caution',
|
||||
reason: 'authentication_missing',
|
||||
senderEmail,
|
||||
organizerEmail,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
level: 'trusted',
|
||||
reason: null,
|
||||
senderEmail,
|
||||
organizerEmail,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EventSummary {
|
||||
title: string;
|
||||
start: string | null;
|
||||
@@ -76,15 +471,30 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
|
||||
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;
|
||||
organizer = p.name || getParticipantEmail(p) || null;
|
||||
organizerEmail = getParticipantEmail(p);
|
||||
}
|
||||
if (p.roles?.attendee) {
|
||||
if (p.roles?.attendee || p.roles?.required) {
|
||||
attendeeCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stalwart provides organizerCalendarAddress instead of roles.owner/chair
|
||||
if (!organizerEmail && event.organizerCalendarAddress) {
|
||||
organizerEmail = normalizeEmailAddress(event.organizerCalendarAddress);
|
||||
if (!organizer && event.participants) {
|
||||
// Find the participant matching the organizer address for their name
|
||||
for (const p of Object.values(event.participants)) {
|
||||
if (p.calendarAddress === event.organizerCalendarAddress) {
|
||||
organizer = p.name || organizerEmail;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!organizer) organizer = organizerEmail;
|
||||
}
|
||||
|
||||
let end: string | null = null;
|
||||
if (event.utcEnd) {
|
||||
end = event.utcEnd;
|
||||
@@ -134,6 +544,13 @@ export function findParticipantByEmail(
|
||||
if (p.email?.toLowerCase() === lowerEmail) {
|
||||
return { id, participant: p };
|
||||
}
|
||||
// Stalwart uses calendarAddress (mailto:...) instead of email/sendTo
|
||||
if (p.calendarAddress) {
|
||||
const addr = p.calendarAddress.replace('mailto:', '').toLowerCase();
|
||||
if (addr === lowerEmail) {
|
||||
return { id, participant: p };
|
||||
}
|
||||
}
|
||||
if (p.sendTo) {
|
||||
for (const addr of Object.values(p.sendTo)) {
|
||||
if (addr.replace('mailto:', '').toLowerCase() === lowerEmail) {
|
||||
|
||||
Reference in New Issue
Block a user