feat: calendar invitations RSVP, trust assessment, file preview

This commit is contained in:
Linus Rath
2026-03-16 22:00:11 +01:00
parent cde1d61d02
commit 0f5d030d5f
32 changed files with 21143 additions and 279 deletions
+1
View File
@@ -40,6 +40,7 @@ function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
categories: null,
locale: null,
replyTo: null,
organizerCalendarAddress: null,
participants: null,
mayInviteSelf: false,
mayInviteOthers: false,
+210
View File
@@ -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,
+1
View File
@@ -47,6 +47,7 @@ function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
categories: null,
locale: null,
replyTo: null,
organizerCalendarAddress: null,
participants: null,
mayInviteSelf: false,
mayInviteOthers: false,
+32
View File
@@ -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
View File
@@ -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) {
+66
View File
@@ -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
View File
@@ -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');
+2
View File
@@ -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';