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([ 'publish', 'request', 'reply', 'add', 'cancel', 'refresh', 'counter', 'declinecounter', ]); function parseContentType(value?: string | null): { mimeType: string; params: Record } { if (!value) { return { mimeType: '', params: {} }; } const parts = value.split(';').map((part) => part.trim()).filter(Boolean); const [mimeType = '', ...paramParts] = parts; const params: Record = {}; 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): 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): boolean { const authenticationResults = email?.authenticationResults; return Boolean( authenticationResults?.dmarc?.result === 'pass' || authenticationResults?.dkim?.result === 'pass' || authenticationResults?.spf?.result === 'pass' ); } function hasAuthenticationFailure(email?: Pick): 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): 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, 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, attachment?: Pick | 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 ( isCalendarMimeType(att.type) || att.name?.toLowerCase().endsWith('.ics') || att.name?.toLowerCase().endsWith('.ical') ) { return att; } } } const inlineAttachment = findCalendarBodyPart(email.textBody) || findCalendarBodyPart(email.htmlBody); if (inlineAttachment) return inlineAttachment; return null; } export function getInvitationMethod( event: Partial, options?: { email?: Pick; attachment?: Pick | 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) => isOrganizerParticipant(p) ); if (hasOrganizer) { return 'request'; } } if (looksLikeReply(event)) { return 'reply'; } return 'unknown'; } export function getInvitationTrustAssessment( event: Partial, email?: Pick, 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; end: string | null; isAllDay: boolean; location: string | null; organizer: string | null; organizerEmail: string | null; attendeeCount: number; } export function formatEventSummary(event: Partial): 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 || getParticipantEmail(p) || null; organizerEmail = getParticipantEmail(p); } 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; } const isAllDay = event.showWithoutTime ?? false; 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); } // For all-day events, prefer the local start (no timezone) to avoid // UTC conversion shifting the displayed date in non-UTC timezones. const start = isAllDay ? (event.start || null) : (event.utcStart || event.start || null); return { title: event.title || '', start, end, isAllDay, 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); // If the input is a local datetime (no UTC 'Z' suffix), return a local // format string so that all-day date arithmetic isn't shifted by the // browser's UTC offset (toISOString converts to UTC). if (!start.endsWith('Z') && !start.includes('+')) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); const h = String(date.getHours()).padStart(2, '0'); const min = String(date.getMinutes()).padStart(2, '0'); const s = String(date.getSeconds()).padStart(2, '0'); return `${y}-${m}-${d}T${h}:${min}:${s}`; } return date.toISOString(); } export function findParticipantByEmail( event: Partial, 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 }; } // 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) { return { id, participant: p }; } } } } return null; }