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);
});
});