feat: calendar invitations RSVP, trust assessment, file preview
This commit is contained in:
@@ -40,6 +40,7 @@ function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
categories: null,
|
||||
locale: null,
|
||||
replyTo: null,
|
||||
organizerCalendarAddress: null,
|
||||
participants: null,
|
||||
mayInviteSelf: false,
|
||||
mayInviteOthers: false,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findCalendarAttachment,
|
||||
getInvitationActorSummary,
|
||||
getInvitationMethod,
|
||||
getInvitationTrustAssessment,
|
||||
formatEventSummary,
|
||||
findParticipantByEmail,
|
||||
} from '../calendar-invitation';
|
||||
@@ -25,6 +27,7 @@ function makeParticipant(overrides: Partial<CalendarParticipant> = {}): Calendar
|
||||
'@type': 'Participant',
|
||||
name: 'Test',
|
||||
email: 'test@example.com',
|
||||
calendarAddress: null,
|
||||
description: null,
|
||||
sendTo: null,
|
||||
kind: 'individual',
|
||||
@@ -107,6 +110,16 @@ describe('findCalendarAttachment', () => {
|
||||
expect(findCalendarAttachment(email)).toBeNull();
|
||||
});
|
||||
|
||||
it('finds attachment when text/calendar includes MIME parameters', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b6', size: 500, type: 'text/calendar; method=REQUEST; charset=UTF-8' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('b6');
|
||||
});
|
||||
|
||||
it('detects text/calendar in textBody parts', () => {
|
||||
const email = makeEmail({
|
||||
textBody: [
|
||||
@@ -118,6 +131,24 @@ describe('findCalendarAttachment', () => {
|
||||
expect(result!.blobId).toBe('tb1');
|
||||
});
|
||||
|
||||
it('detects nested text/calendar body parts inside multipart structures', () => {
|
||||
const email = makeEmail({
|
||||
textBody: [
|
||||
{
|
||||
partId: 'root',
|
||||
blobId: 'rootBlob',
|
||||
size: 100,
|
||||
type: 'multipart/alternative',
|
||||
subParts: [
|
||||
{ partId: 'plain', blobId: 'plainBlob', size: 50, type: 'text/plain' },
|
||||
{ partId: 'ical', blobId: 'tbNested', size: 300, type: 'text/calendar; method=REQUEST; charset=UTF-8' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('tbNested');
|
||||
});
|
||||
|
||||
it('prioritizes attachments over textBody', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
@@ -137,6 +168,46 @@ describe('getInvitationMethod', () => {
|
||||
expect(getInvitationMethod({ status: 'cancelled' })).toBe('cancel');
|
||||
});
|
||||
|
||||
it('detects request from MIME Content-Type method parameter', () => {
|
||||
const event: Partial<CalendarEvent> = {};
|
||||
const email = makeEmail({
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; method=REQUEST; charset=UTF-8',
|
||||
},
|
||||
});
|
||||
expect(getInvitationMethod(event, { email })).toBe('request');
|
||||
});
|
||||
|
||||
it('detects reply from attachment MIME method parameter', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
att: makeParticipant({ participationStatus: 'accepted' }),
|
||||
},
|
||||
};
|
||||
expect(getInvitationMethod(event, {
|
||||
attachment: { type: 'text/calendar; method=REPLY; charset=UTF-8' },
|
||||
})).toBe('reply');
|
||||
});
|
||||
|
||||
it('detects publish, add, refresh, counter, and declinecounter from MIME parameters', () => {
|
||||
const event: Partial<CalendarEvent> = {};
|
||||
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=PUBLISH' } })).toBe('publish');
|
||||
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=ADD' } })).toBe('add');
|
||||
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=REFRESH' } })).toBe('refresh');
|
||||
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=COUNTER' } })).toBe('counter');
|
||||
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=DECLINECOUNTER' } })).toBe('declinecounter');
|
||||
});
|
||||
|
||||
it('does not treat text/calendar without method as iMIP metadata', () => {
|
||||
const event: Partial<CalendarEvent> = {};
|
||||
const email = makeEmail({
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=UTF-8',
|
||||
},
|
||||
});
|
||||
expect(getInvitationMethod(event, { email })).toBe('unknown');
|
||||
});
|
||||
|
||||
it('detects request when participants have organizer role', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
@@ -168,6 +239,59 @@ describe('getInvitationMethod', () => {
|
||||
};
|
||||
expect(getInvitationMethod(event)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('infers reply when attendee status is present without an organizer', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
att: makeParticipant({ roles: { attendee: true }, participationStatus: 'accepted' }),
|
||||
},
|
||||
};
|
||||
expect(getInvitationMethod(event)).toBe('reply');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInvitationActorSummary', () => {
|
||||
it('picks the attendee as actor for reply-like methods', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com', name: 'Organizer' }),
|
||||
att: makeParticipant({
|
||||
roles: { attendee: true },
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
participationStatus: 'accepted',
|
||||
participationComment: 'Works for me',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
expect(getInvitationActorSummary(event, 'reply')).toMatchObject({
|
||||
role: 'attendee',
|
||||
name: 'Alice',
|
||||
participationStatus: 'accepted',
|
||||
participationComment: 'Works for me',
|
||||
});
|
||||
});
|
||||
|
||||
it('picks the organizer as actor for declinecounter', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com', name: 'Organizer' }),
|
||||
att: makeParticipant({
|
||||
roles: { attendee: true },
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
participationStatus: 'tentative',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
expect(getInvitationActorSummary(event, 'declinecounter')).toMatchObject({
|
||||
role: 'organizer',
|
||||
name: 'Organizer',
|
||||
email: 'organizer@example.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEventSummary', () => {
|
||||
@@ -228,6 +352,92 @@ describe('formatEventSummary', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInvitationTrustAssessment', () => {
|
||||
it('warns when mail authentication fails', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInvitationTrustAssessment(event, makeEmail({
|
||||
from: [{ email: 'organizer@example.com' }],
|
||||
authenticationResults: {
|
||||
dmarc: { result: 'fail', domain: 'example.com', policy: 'reject' },
|
||||
},
|
||||
}), 'request');
|
||||
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.reason).toBe('authentication_failed');
|
||||
});
|
||||
|
||||
it('warns when sender and organizer differ without verified authentication', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInvitationTrustAssessment(event, makeEmail({
|
||||
from: [{ email: 'calendar-bot@example.net' }],
|
||||
}), 'request');
|
||||
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.reason).toBe('sender_mismatch_unverified');
|
||||
});
|
||||
|
||||
it('shows caution when sender and organizer differ but authentication passed', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInvitationTrustAssessment(event, makeEmail({
|
||||
from: [{ email: 'assistant@example.com' }],
|
||||
authenticationResults: {
|
||||
dkim: { result: 'pass', domain: 'example.com', selector: 'mail' },
|
||||
},
|
||||
}), 'request');
|
||||
|
||||
expect(result.level).toBe('caution');
|
||||
expect(result.reason).toBe('sender_mismatch');
|
||||
});
|
||||
|
||||
it('shows caution when an invitation has no verified authentication', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInvitationTrustAssessment(event, makeEmail({
|
||||
from: [{ email: 'organizer@example.com' }],
|
||||
}), 'request');
|
||||
|
||||
expect(result.level).toBe('caution');
|
||||
expect(result.reason).toBe('authentication_missing');
|
||||
});
|
||||
|
||||
it('returns trusted when sender matches organizer and authentication passed', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInvitationTrustAssessment(event, makeEmail({
|
||||
from: [{ email: 'organizer@example.com' }],
|
||||
authenticationResults: {
|
||||
spf: { result: 'pass', domain: 'example.com', ip: '203.0.113.5' },
|
||||
},
|
||||
}), 'request');
|
||||
|
||||
expect(result.level).toBe('trusted');
|
||||
expect(result.reason).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findParticipantByEmail', () => {
|
||||
it('finds participant by direct email match', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
|
||||
@@ -49,6 +49,7 @@ function makeEvent(participants: Record<string, Partial<CalendarParticipant>> |
|
||||
updated: '2026-03-01T09:00:00Z',
|
||||
locale: null,
|
||||
replyTo: null,
|
||||
organizerCalendarAddress: null,
|
||||
participants: participants as Record<string, CalendarParticipant> | null,
|
||||
mayInviteSelf: false,
|
||||
mayInviteOthers: false,
|
||||
|
||||
@@ -47,6 +47,7 @@ function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
categories: null,
|
||||
locale: null,
|
||||
replyTo: null,
|
||||
organizerCalendarAddress: null,
|
||||
participants: null,
|
||||
mayInviteSelf: false,
|
||||
mayInviteOthers: false,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getFilePreviewKind, isFilePreviewable } from '../file-preview';
|
||||
|
||||
describe('file preview detection', () => {
|
||||
it('detects browser-renderable image attachments', () => {
|
||||
expect(getFilePreviewKind('photo.avif', 'image/avif')).toBe('image');
|
||||
expect(getFilePreviewKind('vector.svg')).toBe('image');
|
||||
});
|
||||
|
||||
it('detects html attachments', () => {
|
||||
expect(getFilePreviewKind('message.html', 'text/html; charset=utf-8')).toBe('html');
|
||||
expect(getFilePreviewKind('index.htm')).toBe('html');
|
||||
});
|
||||
|
||||
it('detects text and markdown attachments', () => {
|
||||
expect(getFilePreviewKind('notes.txt', 'text/plain')).toBe('text');
|
||||
expect(getFilePreviewKind('README.md', 'text/markdown')).toBe('markdown');
|
||||
expect(getFilePreviewKind('payload.json', 'application/json')).toBe('text');
|
||||
});
|
||||
|
||||
it('detects pdf, audio, and video attachments', () => {
|
||||
expect(getFilePreviewKind('doc.pdf')).toBe('pdf');
|
||||
expect(getFilePreviewKind('audio.m4a')).toBe('audio');
|
||||
expect(getFilePreviewKind('movie.webm')).toBe('video');
|
||||
});
|
||||
|
||||
it('rejects unsupported attachment types', () => {
|
||||
expect(getFilePreviewKind('archive.zip', 'application/zip')).toBe('unsupported');
|
||||
expect(isFilePreviewable('archive.zip', 'application/zip')).toBe(false);
|
||||
});
|
||||
});
|
||||
+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) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
export type FilePreviewKind = 'image' | 'html' | 'text' | 'markdown' | 'pdf' | 'audio' | 'video' | 'unsupported';
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico']);
|
||||
const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus']);
|
||||
const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogv', 'mov', 'm4v', 'avi', 'mkv']);
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'txt', 'text', 'log', 'csv', 'json', 'xml', 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
|
||||
'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'sql', 'graphql', 'html', 'htm',
|
||||
'md', 'markdown',
|
||||
]);
|
||||
const TEXT_MIME_TYPES = new Set([
|
||||
'application/json',
|
||||
'application/ld+json',
|
||||
'application/xml',
|
||||
'application/javascript',
|
||||
'application/x-javascript',
|
||||
'application/typescript',
|
||||
]);
|
||||
|
||||
function normalizeMimeType(type?: string): string {
|
||||
return type?.split(';')[0]?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
function getExtension(name?: string): string {
|
||||
const parts = name?.toLowerCase().split('.') || [];
|
||||
return parts.length > 1 ? parts.pop() || '' : '';
|
||||
}
|
||||
|
||||
export function getFilePreviewKind(name?: string, type?: string): FilePreviewKind {
|
||||
const ext = getExtension(name);
|
||||
const mimeType = normalizeMimeType(type);
|
||||
|
||||
if (mimeType.startsWith('image/') || IMAGE_EXTENSIONS.has(ext)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (mimeType === 'text/html' || mimeType === 'application/xhtml+xml' || ext === 'html' || ext === 'htm') {
|
||||
return 'html';
|
||||
}
|
||||
|
||||
if (mimeType === 'application/pdf' || ext === 'pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
if (mimeType.startsWith('audio/') || AUDIO_EXTENSIONS.has(ext)) {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
if (mimeType.startsWith('video/') || VIDEO_EXTENSIONS.has(ext)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (ext === 'md' || ext === 'markdown') {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
if (mimeType.startsWith('text/') || TEXT_MIME_TYPES.has(mimeType) || TEXT_EXTENSIONS.has(ext)) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
export function isFilePreviewable(name?: string, type?: string): boolean {
|
||||
return getFilePreviewKind(name, type) !== 'unsupported';
|
||||
}
|
||||
+170
-10
@@ -1476,6 +1476,168 @@ export class JMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an iMIP (RFC 6047) REPLY email to the organizer after an RSVP.
|
||||
* This is needed when the server does not handle sendSchedulingMessages.
|
||||
*/
|
||||
async sendImipReply(opts: {
|
||||
organizerEmail: string;
|
||||
organizerName?: string;
|
||||
attendeeEmail: string;
|
||||
attendeeName?: string;
|
||||
uid: string;
|
||||
summary?: string;
|
||||
dtStart?: string;
|
||||
dtEnd?: string;
|
||||
timeZone?: string;
|
||||
sequence?: number;
|
||||
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
||||
identityId?: string;
|
||||
}): Promise<void> {
|
||||
const mailboxes = await this.getMailboxes();
|
||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||
if (!sentMailbox) {
|
||||
throw new Error('No sent mailbox found');
|
||||
}
|
||||
|
||||
let finalIdentityId = opts.identityId;
|
||||
if (!finalIdentityId) {
|
||||
const identityResponse = await this.request([
|
||||
["Identity/get", { accountId: this.accountId }, "0"]
|
||||
]);
|
||||
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
||||
const match = identities.find((id) => id.email === opts.attendeeEmail);
|
||||
finalIdentityId = match?.id || identities[0]?.id || this.accountId;
|
||||
} else {
|
||||
finalIdentityId = this.accountId;
|
||||
}
|
||||
}
|
||||
|
||||
// Build iCalendar REPLY (RFC 5546 §3.2.3)
|
||||
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
|
||||
// Format a JSCalendar date string into iCalendar format
|
||||
const formatIcalDate = (dateStr: string, tz?: string): string => {
|
||||
// If it's an ISO UTC string (ends with Z), convert to iCalendar UTC format
|
||||
if (dateStr.endsWith('Z')) {
|
||||
return dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
}
|
||||
// Local date-time: strip punctuation, keep as-is for TZID parameter
|
||||
const basic = dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
if (tz) {
|
||||
return `TZID=${tz}:${basic}`;
|
||||
}
|
||||
return basic;
|
||||
};
|
||||
|
||||
const lines: string[] = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'PRODID:-//JMAP-Webmail//EN',
|
||||
'VERSION:2.0',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:REPLY',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${opts.uid}`,
|
||||
`DTSTAMP:${now}`,
|
||||
];
|
||||
if (opts.dtStart) {
|
||||
const formatted = formatIcalDate(opts.dtStart, opts.timeZone);
|
||||
// If TZID is included, it's a parameter on the property
|
||||
if (formatted.startsWith('TZID=')) {
|
||||
lines.push(`DTSTART;${formatted}`);
|
||||
} else {
|
||||
lines.push(`DTSTART:${formatted}`);
|
||||
}
|
||||
}
|
||||
if (opts.dtEnd) {
|
||||
const formatted = formatIcalDate(opts.dtEnd, opts.timeZone);
|
||||
if (formatted.startsWith('TZID=')) {
|
||||
lines.push(`DTEND;${formatted}`);
|
||||
} else {
|
||||
lines.push(`DTEND:${formatted}`);
|
||||
}
|
||||
}
|
||||
if (opts.summary) {
|
||||
lines.push(`SUMMARY:${opts.summary}`);
|
||||
}
|
||||
if (opts.sequence != null) {
|
||||
lines.push(`SEQUENCE:${opts.sequence}`);
|
||||
}
|
||||
const orgCn = opts.organizerName ? `;CN=${opts.organizerName}` : '';
|
||||
lines.push(`ORGANIZER${orgCn}:mailto:${opts.organizerEmail}`);
|
||||
const attCn = opts.attendeeName ? `;CN=${opts.attendeeName}` : '';
|
||||
lines.push(`ATTENDEE;PARTSTAT=${opts.status}${attCn}:mailto:${opts.attendeeEmail}`);
|
||||
lines.push('END:VEVENT');
|
||||
lines.push('END:VCALENDAR');
|
||||
const icsContent = lines.join('\r\n') + '\r\n';
|
||||
|
||||
console.log('[iMIP DEBUG] Generated ICS:\n' + icsContent);
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
ACCEPTED: 'Accepted',
|
||||
TENTATIVE: 'Tentative',
|
||||
DECLINED: 'Declined',
|
||||
};
|
||||
const statusLabel = statusLabels[opts.status] || opts.status;
|
||||
const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
|
||||
|
||||
console.log('[iMIP DEBUG] identityId:', finalIdentityId);
|
||||
|
||||
const emailId = `imip-reply-${Date.now()}`;
|
||||
const emailCreate: Record<string, unknown> = {
|
||||
from: [{ name: opts.attendeeName || undefined, email: opts.attendeeEmail }],
|
||||
to: [{ name: opts.organizerName || undefined, email: opts.organizerEmail }],
|
||||
subject,
|
||||
keywords: { "$seen": true },
|
||||
mailboxIds: { [sentMailbox.id]: true },
|
||||
bodyStructure: {
|
||||
type: 'multipart/alternative',
|
||||
subParts: [
|
||||
{ partId: 'text', type: 'text/plain' },
|
||||
{ partId: 'cal', type: 'text/calendar; method=REPLY' },
|
||||
],
|
||||
},
|
||||
bodyValues: {
|
||||
text: { value: `${opts.attendeeName || opts.attendeeEmail} has ${statusLabel.toLowerCase()} the invitation to: ${opts.summary || 'Event'}` },
|
||||
cal: { value: icsContent },
|
||||
},
|
||||
};
|
||||
|
||||
const methodCalls: JMAPMethodCall[] = [
|
||||
["Email/set", {
|
||||
accountId: this.accountId,
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "0"],
|
||||
["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
||||
}, "1"],
|
||||
];
|
||||
|
||||
console.log('[iMIP DEBUG] Sending JMAP request with', methodCalls.length, 'method calls');
|
||||
console.log('[iMIP DEBUG] Email create payload:', JSON.stringify(emailCreate, null, 2));
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
|
||||
console.log('[iMIP DEBUG] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
|
||||
|
||||
if (response.methodResponses) {
|
||||
for (const [methodName, result] of response.methodResponses) {
|
||||
if (methodName.endsWith('/error')) {
|
||||
console.error('[iMIP DEBUG] method error:', methodName, result);
|
||||
throw new Error(result.description || `iMIP reply failed: ${result.type}`);
|
||||
}
|
||||
if (result.notCreated) {
|
||||
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
||||
console.error('[iMIP DEBUG] create error:', JSON.stringify(result.notCreated, null, 2));
|
||||
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP reply');
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('[iMIP DEBUG] sendImipReply completed successfully');
|
||||
}
|
||||
|
||||
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
||||
if (!this.session) {
|
||||
throw new Error('Not connected. Call connect() first.');
|
||||
@@ -1541,13 +1703,17 @@ export class JMAPClient {
|
||||
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
||||
}
|
||||
|
||||
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string> {
|
||||
async fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
const response = await this.authenticatedFetch(url, {});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string> {
|
||||
const blob = await this.fetchBlob(blobId, name, type);
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
@@ -2282,6 +2448,7 @@ export class JMAPClient {
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/parse") {
|
||||
const result = response.methodResponses[0][1];
|
||||
console.log('[PARSE DEBUG] CalendarEvent/parse raw result:', JSON.stringify(result, null, 2));
|
||||
|
||||
if (result.notParsable && result.notParsable.includes(blobId)) {
|
||||
throw new Error("Invalid calendar file format");
|
||||
@@ -2614,14 +2781,7 @@ export class JMAPClient {
|
||||
}
|
||||
|
||||
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
const response = await this.authenticatedFetch(url, {});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download attachment: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blob = await this.fetchBlob(blobId, name, type);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
|
||||
@@ -417,6 +417,7 @@ export interface CalendarEvent {
|
||||
categories: Record<string, boolean> | null;
|
||||
locale: string | null;
|
||||
replyTo: Record<string, string> | null;
|
||||
organizerCalendarAddress: string | null;
|
||||
participants: Record<string, CalendarParticipant> | null;
|
||||
mayInviteSelf: boolean;
|
||||
mayInviteOthers: boolean;
|
||||
@@ -438,6 +439,7 @@ export interface CalendarParticipant {
|
||||
'@type': 'Participant';
|
||||
name: string;
|
||||
email: string;
|
||||
calendarAddress: string | null;
|
||||
description: string | null;
|
||||
sendTo: Record<string, string> | null;
|
||||
kind: 'individual' | 'group' | 'location' | 'resource';
|
||||
|
||||
Reference in New Issue
Block a user