feat: add participant scheduling with iTIP invitations and inline calendar invitation banner

Add organizer/attendee UI with RSVP, contact autocomplete for participants,
scheduling messages, and inline calendar invitation banner in email viewer
with auto-detect .ics attachments, RSVP/import to calendar, and cancellation display.
This commit is contained in:
Matthieu MALVACHE
2026-02-17 01:09:57 +01:00
committed by Matthieu MALVACHE
parent 74763b384d
commit b65b2f26df
30 changed files with 2352 additions and 137 deletions
+285
View File
@@ -0,0 +1,285 @@
import { describe, it, expect } from 'vitest';
import {
findCalendarAttachment,
getInvitationMethod,
formatEventSummary,
findParticipantByEmail,
} from '../calendar-invitation';
import type { Email, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
function makeEmail(overrides: Partial<Email> = {}): Email {
return {
id: 'e1',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: {},
size: 1024,
receivedAt: '2026-02-17T10:00:00Z',
hasAttachment: false,
...overrides,
};
}
function makeParticipant(overrides: Partial<CalendarParticipant> = {}): CalendarParticipant {
return {
'@type': 'Participant',
name: 'Test',
email: 'test@example.com',
description: null,
sendTo: null,
kind: 'individual',
roles: { attendee: true },
participationStatus: 'needs-action',
participationComment: null,
expectReply: false,
scheduleAgent: 'server',
scheduleForceSend: false,
scheduleId: null,
scheduleSequence: 0,
scheduleStatus: null,
scheduleUpdated: null,
invitedBy: null,
delegatedTo: null,
delegatedFrom: null,
memberOf: null,
locationId: null,
language: null,
links: null,
...overrides,
};
}
describe('findCalendarAttachment', () => {
it('finds attachment by MIME type text/calendar', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'b1', size: 500, type: 'text/calendar', name: 'invite.ics' },
],
hasAttachment: true,
});
const result = findCalendarAttachment(email);
expect(result).toBeTruthy();
expect(result!.blobId).toBe('b1');
});
it('finds attachment by application/ics type', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'b2', size: 500, type: 'application/ics', name: 'event.ics' },
],
hasAttachment: true,
});
expect(findCalendarAttachment(email)?.blobId).toBe('b2');
});
it('finds attachment by .ics file extension', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'b3', size: 500, type: 'application/octet-stream', name: 'meeting.ics' },
],
hasAttachment: true,
});
expect(findCalendarAttachment(email)?.blobId).toBe('b3');
});
it('finds attachment by .ical file extension', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'b4', size: 500, type: 'application/octet-stream', name: 'meeting.ical' },
],
hasAttachment: true,
});
expect(findCalendarAttachment(email)?.blobId).toBe('b4');
});
it('returns null when no calendar attachment exists', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'b5', size: 500, type: 'application/pdf', name: 'doc.pdf' },
],
hasAttachment: true,
});
expect(findCalendarAttachment(email)).toBeNull();
});
it('returns null when attachments is undefined', () => {
const email = makeEmail();
expect(findCalendarAttachment(email)).toBeNull();
});
it('detects text/calendar in textBody parts', () => {
const email = makeEmail({
textBody: [
{ partId: 'p1', blobId: 'tb1', size: 300, type: 'text/calendar' },
],
});
const result = findCalendarAttachment(email);
expect(result).toBeTruthy();
expect(result!.blobId).toBe('tb1');
});
it('prioritizes attachments over textBody', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'att1', size: 500, type: 'text/calendar', name: 'invite.ics' },
],
textBody: [
{ partId: 'p1', blobId: 'tb1', size: 300, type: 'text/calendar' },
],
hasAttachment: true,
});
expect(findCalendarAttachment(email)?.blobId).toBe('att1');
});
});
describe('getInvitationMethod', () => {
it('detects cancel when status is cancelled', () => {
expect(getInvitationMethod({ status: 'cancelled' })).toBe('cancel');
});
it('detects request when participants have organizer role', () => {
const event: Partial<CalendarEvent> = {
participants: {
org: makeParticipant({ roles: { owner: true }, name: 'Organizer' }),
att: makeParticipant({ roles: { attendee: true }, name: 'Attendee' }),
},
};
expect(getInvitationMethod(event)).toBe('request');
});
it('detects request with chair role', () => {
const event: Partial<CalendarEvent> = {
participants: {
org: makeParticipant({ roles: { chair: true }, name: 'Chair' }),
},
};
expect(getInvitationMethod(event)).toBe('request');
});
it('returns unknown when no participants', () => {
expect(getInvitationMethod({})).toBe('unknown');
});
it('returns unknown when participants have no organizer', () => {
const event: Partial<CalendarEvent> = {
participants: {
att: makeParticipant({ roles: { attendee: true } }),
},
};
expect(getInvitationMethod(event)).toBe('unknown');
});
});
describe('formatEventSummary', () => {
it('extracts title from event', () => {
const summary = formatEventSummary({ title: 'Team Sync' });
expect(summary.title).toBe('Team Sync');
});
it('extracts location from event', () => {
const summary = formatEventSummary({
locations: { loc1: { '@type': 'Location', name: 'Room A', description: null, locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
});
expect(summary.location).toBe('Room A');
});
it('extracts organizer info', () => {
const summary = formatEventSummary({
participants: {
org: makeParticipant({ roles: { owner: true }, name: 'Alice', email: 'alice@example.com' }),
att: makeParticipant({ roles: { attendee: true }, name: 'Bob' }),
},
});
expect(summary.organizer).toBe('Alice');
expect(summary.organizerEmail).toBe('alice@example.com');
expect(summary.attendeeCount).toBe(1);
});
it('handles missing data gracefully', () => {
const summary = formatEventSummary({});
expect(summary.title).toBe('');
expect(summary.start).toBeNull();
expect(summary.end).toBeNull();
expect(summary.location).toBeNull();
expect(summary.organizer).toBeNull();
expect(summary.attendeeCount).toBe(0);
});
it('computes end from start + duration', () => {
const summary = formatEventSummary({
start: '2026-02-17T10:00:00',
duration: 'PT1H30M',
});
expect(summary.start).toBe('2026-02-17T10:00:00');
expect(summary.end).toBeTruthy();
const endDate = new Date(summary.end!);
expect(endDate.getHours()).toBe(new Date('2026-02-17T10:00:00').getHours() + 1);
expect(endDate.getMinutes()).toBe(new Date('2026-02-17T10:00:00').getMinutes() + 30);
});
it('uses utcStart and utcEnd when available', () => {
const summary = formatEventSummary({
utcStart: '2026-02-17T15:00:00Z',
utcEnd: '2026-02-17T16:00:00Z',
start: '2026-02-17T10:00:00',
});
expect(summary.start).toBe('2026-02-17T15:00:00Z');
expect(summary.end).toBe('2026-02-17T16:00:00Z');
});
});
describe('findParticipantByEmail', () => {
it('finds participant by direct email match', () => {
const event: Partial<CalendarEvent> = {
participants: {
p1: makeParticipant({ email: 'alice@example.com', name: 'Alice' }),
},
};
const result = findParticipantByEmail(event, 'alice@example.com');
expect(result).toBeTruthy();
expect(result!.id).toBe('p1');
expect(result!.participant.name).toBe('Alice');
});
it('matches case-insensitively', () => {
const event: Partial<CalendarEvent> = {
participants: {
p1: makeParticipant({ email: 'Alice@Example.COM' }),
},
};
expect(findParticipantByEmail(event, 'alice@example.com')).toBeTruthy();
});
it('finds participant by sendTo mailto', () => {
const event: Partial<CalendarEvent> = {
participants: {
p1: makeParticipant({ email: '', sendTo: { imip: 'mailto:bob@example.com' } }),
},
};
const result = findParticipantByEmail(event, 'bob@example.com');
expect(result).toBeTruthy();
expect(result!.id).toBe('p1');
});
it('returns null when no match', () => {
const event: Partial<CalendarEvent> = {
participants: {
p1: makeParticipant({ email: 'alice@example.com' }),
},
};
expect(findParticipantByEmail(event, 'unknown@example.com')).toBeNull();
});
it('returns null with no participants', () => {
expect(findParticipantByEmail({}, 'test@example.com')).toBeNull();
});
it('returns null with empty email', () => {
const event: Partial<CalendarEvent> = {
participants: {
p1: makeParticipant({ email: 'alice@example.com' }),
},
};
expect(findParticipantByEmail(event, '')).toBeNull();
});
});
+326
View File
@@ -0,0 +1,326 @@
import { describe, it, expect } from 'vitest';
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
import {
isOrganizer,
getUserParticipantId,
getUserStatus,
getParticipantList,
getStatusCounts,
getParticipantCount,
buildParticipantMap,
} from '@/lib/calendar-participants';
function makeEvent(participants: Record<string, Partial<CalendarParticipant>> | null = null): CalendarEvent {
return {
'@type': 'Event',
id: 'ev1',
uid: 'uid-ev1',
calendarIds: { cal1: true },
title: 'Test Event',
description: '',
descriptionContentType: 'text/plain',
start: '2026-03-01T10:00:00',
duration: 'PT1H',
timeZone: 'UTC',
showWithoutTime: false,
status: 'confirmed',
freeBusyStatus: 'busy',
privacy: 'public',
keywords: null,
categories: null,
color: null,
recurrenceId: null,
recurrenceIdTimeZone: null,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
useDefaultAlerts: false,
alerts: null,
locations: null,
virtualLocations: null,
links: null,
relatedTo: null,
utcStart: null,
utcEnd: null,
isDraft: false,
isOrigin: true,
sequence: 0,
created: '2026-03-01T09:00:00Z',
updated: '2026-03-01T09:00:00Z',
locale: null,
replyTo: null,
participants: participants as Record<string, CalendarParticipant> | null,
mayInviteSelf: false,
mayInviteOthers: false,
hideAttendees: false,
};
}
const orgParticipant: Partial<CalendarParticipant> = {
'@type': 'Participant',
name: 'Alice',
email: 'alice@example.com',
roles: { owner: true, attendee: true },
participationStatus: 'accepted',
scheduleAgent: 'server',
sendTo: { imip: 'mailto:alice@example.com' },
expectReply: false,
kind: 'individual',
};
const attendeeParticipant: Partial<CalendarParticipant> = {
'@type': 'Participant',
name: 'Bob',
email: 'bob@example.com',
roles: { attendee: true },
participationStatus: 'needs-action',
scheduleAgent: 'server',
sendTo: { imip: 'mailto:bob@example.com' },
expectReply: true,
kind: 'individual',
};
const acceptedAttendee: Partial<CalendarParticipant> = {
...attendeeParticipant,
name: 'Carol',
email: 'carol@example.com',
participationStatus: 'accepted',
};
const declinedAttendee: Partial<CalendarParticipant> = {
...attendeeParticipant,
name: 'Dave',
email: 'dave@example.com',
participationStatus: 'declined',
};
const tentativeAttendee: Partial<CalendarParticipant> = {
...attendeeParticipant,
name: 'Eve',
email: 'eve@example.com',
participationStatus: 'tentative',
};
describe('isOrganizer', () => {
it('returns true when user email matches organizer', () => {
const event = makeEvent({ org: orgParticipant });
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
});
it('returns true with case-insensitive match', () => {
const event = makeEvent({ org: orgParticipant });
expect(isOrganizer(event, ['ALICE@EXAMPLE.COM'])).toBe(true);
});
it('returns false when user is not organizer', () => {
const event = makeEvent({ org: orgParticipant });
expect(isOrganizer(event, ['bob@example.com'])).toBe(false);
});
it('returns false when no participants', () => {
const event = makeEvent(null);
expect(isOrganizer(event, ['alice@example.com'])).toBe(false);
});
it('returns true when user has multiple emails and one matches', () => {
const event = makeEvent({ org: orgParticipant });
expect(isOrganizer(event, ['other@example.com', 'alice@example.com'])).toBe(true);
});
it('returns false when empty user emails', () => {
const event = makeEvent({ org: orgParticipant });
expect(isOrganizer(event, [])).toBe(false);
});
});
describe('getUserParticipantId', () => {
it('returns the participant ID for the user', () => {
const event = makeEvent({
org: orgParticipant,
att1: attendeeParticipant,
});
expect(getUserParticipantId(event, ['bob@example.com'])).toBe('att1');
});
it('returns organizer ID when user is organizer', () => {
const event = makeEvent({ org: orgParticipant });
expect(getUserParticipantId(event, ['alice@example.com'])).toBe('org');
});
it('returns null when user not found', () => {
const event = makeEvent({ org: orgParticipant });
expect(getUserParticipantId(event, ['unknown@example.com'])).toBeNull();
});
it('returns null when no participants', () => {
const event = makeEvent(null);
expect(getUserParticipantId(event, ['alice@example.com'])).toBeNull();
});
it('matches case-insensitively', () => {
const event = makeEvent({ att1: attendeeParticipant });
expect(getUserParticipantId(event, ['BOB@EXAMPLE.COM'])).toBe('att1');
});
});
describe('getUserStatus', () => {
it('returns the participation status', () => {
const event = makeEvent({ att1: attendeeParticipant });
expect(getUserStatus(event, ['bob@example.com'])).toBe('needs-action');
});
it('returns accepted for organizer', () => {
const event = makeEvent({ org: orgParticipant });
expect(getUserStatus(event, ['alice@example.com'])).toBe('accepted');
});
it('returns null when user not found', () => {
const event = makeEvent({ org: orgParticipant });
expect(getUserStatus(event, ['unknown@example.com'])).toBeNull();
});
it('returns null when no participants', () => {
const event = makeEvent(null);
expect(getUserStatus(event, ['alice@example.com'])).toBeNull();
});
});
describe('getParticipantList', () => {
it('returns all participants as info objects', () => {
const event = makeEvent({
org: orgParticipant,
att1: attendeeParticipant,
});
const list = getParticipantList(event);
expect(list).toHaveLength(2);
expect(list.find(p => p.id === 'org')).toEqual({
id: 'org',
name: 'Alice',
email: 'alice@example.com',
status: 'accepted',
isOrganizer: true,
});
expect(list.find(p => p.id === 'att1')).toEqual({
id: 'att1',
name: 'Bob',
email: 'bob@example.com',
status: 'needs-action',
isOrganizer: false,
});
});
it('returns empty array when no participants', () => {
const event = makeEvent(null);
expect(getParticipantList(event)).toEqual([]);
});
it('defaults status to needs-action for missing status', () => {
const event = makeEvent({
att1: { ...attendeeParticipant, participationStatus: undefined },
});
const list = getParticipantList(event);
expect(list[0].status).toBe('needs-action');
});
});
describe('getStatusCounts', () => {
it('counts statuses correctly', () => {
const event = makeEvent({
org: orgParticipant,
att1: acceptedAttendee,
att2: declinedAttendee,
att3: tentativeAttendee,
att4: attendeeParticipant,
});
const counts = getStatusCounts(event);
expect(counts.accepted).toBe(2);
expect(counts.declined).toBe(1);
expect(counts.tentative).toBe(1);
expect(counts['needs-action']).toBe(1);
});
it('returns all zeros when no participants', () => {
const event = makeEvent(null);
const counts = getStatusCounts(event);
expect(counts).toEqual({ accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 });
});
});
describe('getParticipantCount', () => {
it('returns correct count', () => {
const event = makeEvent({
org: orgParticipant,
att1: attendeeParticipant,
});
expect(getParticipantCount(event)).toBe(2);
});
it('returns 0 when no participants', () => {
const event = makeEvent(null);
expect(getParticipantCount(event)).toBe(0);
});
});
describe('buildParticipantMap', () => {
it('creates organizer and attendees', () => {
const map = buildParticipantMap(
{ name: 'Alice', email: 'alice@example.com' },
[
{ name: 'Bob', email: 'bob@example.com' },
{ name: 'Carol', email: 'carol@example.com' },
]
);
expect(Object.keys(map)).toHaveLength(3);
const org = map['organizer'];
expect(org.name).toBe('Alice');
expect(org.email).toBe('alice@example.com');
expect(org.roles).toEqual({ owner: true, attendee: true });
expect(org.participationStatus).toBe('accepted');
expect(org.scheduleAgent).toBe('server');
expect(org.sendTo).toEqual({ imip: 'mailto:alice@example.com' });
expect(org.expectReply).toBe(false);
const att0 = map['attendee-0'];
expect(att0.name).toBe('Bob');
expect(att0.email).toBe('bob@example.com');
expect(att0.roles).toEqual({ attendee: true });
expect(att0.participationStatus).toBe('needs-action');
expect(att0.scheduleAgent).toBe('server');
expect(att0.expectReply).toBe(true);
const att1 = map['attendee-1'];
expect(att1.name).toBe('Carol');
expect(att1.email).toBe('carol@example.com');
});
it('creates only organizer when no attendees', () => {
const map = buildParticipantMap(
{ name: 'Alice', email: 'alice@example.com' },
[]
);
expect(Object.keys(map)).toHaveLength(1);
expect(map['organizer']).toBeDefined();
});
it('sets @type to Participant for all entries', () => {
const map = buildParticipantMap(
{ name: 'Alice', email: 'alice@example.com' },
[{ name: 'Bob', email: 'bob@example.com' }]
);
Object.values(map).forEach(p => {
expect(p['@type']).toBe('Participant');
});
});
it('sets kind to individual for all entries', () => {
const map = buildParticipantMap(
{ name: 'Alice', email: 'alice@example.com' },
[{ name: 'Bob', email: 'bob@example.com' }]
);
Object.values(map).forEach(p => {
expect(p.kind).toBe('individual');
});
});
});
+122 -13
View File
@@ -4,6 +4,7 @@ import {
getLuminance,
isDarkColor,
transformColorForDarkMode,
transformBgColorForDarkMode,
transformInlineStyles,
} from '../color-transform';
@@ -149,7 +150,7 @@ describe('isDarkColor', () => {
});
describe('transformColorForDarkMode', () => {
it('should lighten very dark colors', () => {
it('should brighten very dark colors', () => {
const original = '#111111';
const transformed = transformColorForDarkMode(original);
const originalRgb = parseColor(original)!;
@@ -160,11 +161,28 @@ describe('transformColorForDarkMode', () => {
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
});
it('should transform #333333 to a lighter color', () => {
it('should transform #333333 to a bright color', () => {
const transformed = transformColorForDarkMode('#333333');
const rgb = parseColor(transformed)!;
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
expect(luminance).toBeGreaterThan(0.4);
expect(luminance).toBeGreaterThan(0.5);
});
it('should produce readable results for Google Calendar grays', () => {
const googleGrays = ['#757575', '#5f6368', '#70757a', '#3c4043'];
for (const color of googleGrays) {
const transformed = transformColorForDarkMode(color);
const rgb = parseColor(transformed)!;
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
expect(luminance).toBeGreaterThan(0.55);
}
});
it('should preserve hue for colored text', () => {
const transformed = transformColorForDarkMode('#1a73e8');
const rgb = parseColor(transformed)!;
expect(rgb.b).toBeGreaterThan(rgb.r);
expect(rgb.b).toBeGreaterThan(rgb.g);
});
it('should preserve already light colors', () => {
@@ -197,7 +215,7 @@ describe('transformColorForDarkMode', () => {
expect(transformColorForDarkMode('inherit')).toBe('inherit');
});
it('should lighten medium darkness colors', () => {
it('should brighten medium darkness colors', () => {
const original = '#646463';
const transformed = transformColorForDarkMode(original);
const originalRgb = parseColor(original)!;
@@ -207,6 +225,67 @@ describe('transformColorForDarkMode', () => {
expect(transformedRgb.g).toBeGreaterThan(originalRgb.g);
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
});
it('should ensure minimum contrast for all dark text colors', () => {
const darkTextColors = [
'#000000', '#111111', '#222222', '#333333', '#444444',
'#555555', '#666666', '#777777', '#888888',
];
for (const color of darkTextColors) {
const transformed = transformColorForDarkMode(color);
const rgb = parseColor(transformed)!;
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
expect(luminance).toBeGreaterThan(0.4);
}
});
});
describe('transformBgColorForDarkMode', () => {
it('should darken white backgrounds', () => {
const transformed = transformBgColorForDarkMode('#ffffff');
const rgb = parseColor(transformed)!;
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
expect(luminance).toBeLessThan(0.1);
});
it('should darken light gray backgrounds', () => {
const transformed = transformBgColorForDarkMode('#f8f9fa');
const rgb = parseColor(transformed)!;
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
expect(luminance).toBeLessThan(0.1);
});
it('should preserve already dark backgrounds', () => {
const darkBgs = ['#111111', '#1a1a2e', '#0f172a'];
for (const color of darkBgs) {
expect(transformBgColorForDarkMode(color)).toBe(color);
}
});
it('should preserve nearly transparent backgrounds', () => {
const original = 'rgba(255, 255, 255, 0.05)';
expect(transformBgColorForDarkMode(original)).toBe(original);
});
it('should handle invalid colors gracefully', () => {
expect(transformBgColorForDarkMode('invalid')).toBe('invalid');
expect(transformBgColorForDarkMode('inherit')).toBe('inherit');
});
it('should handle rgba backgrounds', () => {
const transformed = transformBgColorForDarkMode('rgba(255, 255, 255, 0.9)');
expect(transformed).toContain('rgba');
expect(transformed).toContain('0.9');
const rgb = parseColor(transformed)!;
expect(rgb.r).toBeLessThan(100);
});
it('should moderately darken medium backgrounds', () => {
const transformed = transformBgColorForDarkMode('#e0e0e0');
const rgb = parseColor(transformed)!;
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
expect(luminance).toBeLessThan(0.3);
});
});
describe('transformInlineStyles', () => {
@@ -223,11 +302,23 @@ describe('transformInlineStyles', () => {
expect(transformed).toContain('rgb(');
});
it('should transform background-color property', () => {
const original = 'background-color: #111111';
it('should darken light background-color property', () => {
const original = 'background-color: #ffffff';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).not.toBe(original);
expect(transformed).toContain('background-color:');
const colorMatch = transformed.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
expect(colorMatch).not.toBeNull();
if (colorMatch) {
const [, r] = colorMatch.map(Number);
expect(r).toBeLessThan(100);
}
});
it('should preserve dark background-color unchanged', () => {
const original = 'background-color: #111111';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).toBe(original);
});
it('should preserve non-color properties', () => {
@@ -238,7 +329,7 @@ describe('transformInlineStyles', () => {
});
it('should handle multiple color properties', () => {
const original = 'color: #111111; background-color: #222222; font-weight: bold';
const original = 'color: #111111; background-color: #ffffff; font-weight: bold';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).toContain('color:');
expect(transformed).toContain('background-color:');
@@ -256,7 +347,7 @@ describe('transformInlineStyles', () => {
expect(transformInlineStyles('invalid', 'dark')).toBe('invalid');
});
it('should transform the James Clear email colors', () => {
it('should brighten text colors for dark mode readability', () => {
const original = 'color: #333333; font-family: Georgia; font-size: 16px';
const transformed = transformInlineStyles(original, 'dark');
@@ -268,14 +359,14 @@ describe('transformInlineStyles', () => {
if (colorMatch) {
const [, r, g, b] = colorMatch.map(Number);
expect(r).toBeGreaterThan(51);
expect(g).toBeGreaterThan(51);
expect(b).toBeGreaterThan(51);
expect(r).toBeGreaterThan(180);
expect(g).toBeGreaterThan(180);
expect(b).toBeGreaterThan(180);
}
});
it('should handle background shorthand with color', () => {
const original = 'background: #333333';
it('should darken background shorthand with color', () => {
const original = 'background: #ffffff';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).not.toBe(original);
expect(transformed).toContain('background:');
@@ -293,4 +384,22 @@ describe('transformInlineStyles', () => {
expect(transformed).not.toBe(original);
expect(transformed).toContain('border-color:');
});
it('should produce good contrast for Google Calendar emails', () => {
const original = 'color: #5f6368; background-color: #f8f9fa';
const transformed = transformInlineStyles(original, 'dark');
const textMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
const bgMatch = transformed.match(/background-color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
expect(textMatch).not.toBeNull();
expect(bgMatch).not.toBeNull();
if (textMatch && bgMatch) {
const textLum = getLuminance(+textMatch[1], +textMatch[2], +textMatch[3]);
const bgLum = getLuminance(+bgMatch[1], +bgMatch[2], +bgMatch[3]);
const contrast = (Math.max(textLum, bgLum) + 0.05) / (Math.min(textLum, bgLum) + 0.05);
expect(contrast).toBeGreaterThan(4.5);
}
});
});
+146
View File
@@ -0,0 +1,146 @@
import type { Email, Attachment, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
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' ||
att.name?.toLowerCase().endsWith('.ics') ||
att.name?.toLowerCase().endsWith('.ical')
) {
return att;
}
}
}
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',
};
}
}
}
return null;
}
export function getInvitationMethod(
event: Partial<CalendarEvent>
): 'request' | 'reply' | 'cancel' | 'unknown' {
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
);
if (hasOrganizer) {
return 'request';
}
}
return 'unknown';
}
export interface EventSummary {
title: string;
start: string | null;
end: string | null;
location: string | null;
organizer: string | null;
organizerEmail: string | null;
attendeeCount: number;
}
export function formatEventSummary(event: Partial<CalendarEvent>): 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 || p.email || null;
organizerEmail = p.email || null;
}
if (p.roles?.attendee) {
attendeeCount++;
}
}
}
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);
}
return {
title: event.title || '',
start: event.utcStart || event.start || null,
end,
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);
return date.toISOString();
}
export function findParticipantByEmail(
event: Partial<CalendarEvent>,
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 };
}
if (p.sendTo) {
for (const addr of Object.values(p.sendTo)) {
if (addr.replace('mailto:', '').toLowerCase() === lowerEmail) {
return { id, participant: p };
}
}
}
}
return null;
}
+106
View File
@@ -0,0 +1,106 @@
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
export interface ParticipantInfo {
id: string;
name: string;
email: string;
status: CalendarParticipant['participationStatus'];
isOrganizer: boolean;
}
export interface StatusCounts {
accepted: number;
declined: number;
tentative: number;
'needs-action': number;
}
export function isOrganizer(event: CalendarEvent, userEmails: string[]): boolean {
if (!event.participants) return false;
const lower = userEmails.map(e => e.toLowerCase());
return Object.values(event.participants).some(p =>
p.roles?.owner && lower.includes(p.email?.toLowerCase())
);
}
export function getUserParticipantId(event: CalendarEvent, userEmails: string[]): string | null {
if (!event.participants) return null;
const lower = userEmails.map(e => e.toLowerCase());
for (const [id, p] of Object.entries(event.participants)) {
if (lower.includes(p.email?.toLowerCase())) return id;
}
return null;
}
export function getUserStatus(
event: CalendarEvent,
userEmails: string[]
): CalendarParticipant['participationStatus'] | null {
if (!event.participants) return null;
const lower = userEmails.map(e => e.toLowerCase());
for (const p of Object.values(event.participants)) {
if (lower.includes(p.email?.toLowerCase())) return p.participationStatus;
}
return null;
}
export function getParticipantList(event: CalendarEvent): ParticipantInfo[] {
if (!event.participants) return [];
return Object.entries(event.participants).map(([id, p]) => ({
id,
name: p.name || '',
email: p.email || '',
status: p.participationStatus || 'needs-action',
isOrganizer: !!p.roles?.owner,
}));
}
export function getStatusCounts(event: CalendarEvent): StatusCounts {
const counts: StatusCounts = { accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 };
if (!event.participants) return counts;
for (const p of Object.values(event.participants)) {
const s = p.participationStatus || 'needs-action';
if (s in counts) counts[s as keyof StatusCounts]++;
}
return counts;
}
export function getParticipantCount(event: CalendarEvent): number {
if (!event.participants) return 0;
return Object.keys(event.participants).length;
}
export function buildParticipantMap(
organizer: { name: string; email: string },
attendees: { name: string; email: string }[]
): Record<string, Partial<CalendarParticipant>> {
const participants: Record<string, Partial<CalendarParticipant>> = {};
participants['organizer'] = {
'@type': 'Participant',
name: organizer.name,
email: organizer.email,
roles: { owner: true, attendee: true },
participationStatus: 'accepted',
scheduleAgent: 'server',
sendTo: { imip: `mailto:${organizer.email}` },
expectReply: false,
kind: 'individual',
};
attendees.forEach((a, i) => {
participants[`attendee-${i}`] = {
'@type': 'Participant',
name: a.name,
email: a.email,
roles: { attendee: true },
participationStatus: 'needs-action',
scheduleAgent: 'server',
sendTo: { imip: `mailto:${a.email}` },
expectReply: true,
kind: 'individual',
};
});
return participants;
}
+27 -19
View File
@@ -131,29 +131,37 @@ export function transformColorForDarkMode(colorString: string): string {
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
if (luminance < 0.4) {
const invR = 255 - rgb.r;
const invG = 255 - rgb.g;
const invB = 255 - rgb.b;
if (luminance >= 0.6) return colorString;
const boost = 1.3;
const r = Math.min(255, Math.round(invR * boost));
const g = Math.min(255, Math.round(invG * boost));
const b = Math.min(255, Math.round(invB * boost));
const blendFactor = 0.85 - (luminance / 0.6) * 0.55;
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
const r = Math.min(255, Math.round(rgb.r + (255 - rgb.r) * blendFactor));
const g = Math.min(255, Math.round(rgb.g + (255 - rgb.g) * blendFactor));
const b = Math.min(255, Math.round(rgb.b + (255 - rgb.b) * blendFactor));
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
}
export function transformBgColorForDarkMode(colorString: string): string {
const rgb = parseColor(colorString);
if (!rgb) return colorString;
if (rgb.a !== undefined && rgb.a < 0.1) {
return colorString;
}
if (luminance >= 0.4 && luminance < 0.6) {
const factor = 1.5;
const r = Math.min(255, Math.round(rgb.r + (255 - rgb.r) * factor * 0.4));
const g = Math.min(255, Math.round(rgb.g + (255 - rgb.g) * factor * 0.4));
const b = Math.min(255, Math.round(rgb.b + (255 - rgb.b) * factor * 0.4));
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
}
if (luminance < 0.2) return colorString;
return colorString;
const blendFactor = Math.min(0.9, (luminance - 0.2) * 1.125);
const darkR = 30, darkG = 31, darkB = 38;
const r = Math.max(0, Math.round(rgb.r + (darkR - rgb.r) * blendFactor));
const g = Math.max(0, Math.round(rgb.g + (darkG - rgb.g) * blendFactor));
const b = Math.max(0, Math.round(rgb.b + (darkB - rgb.b) * blendFactor));
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
}
export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'): string {
@@ -180,7 +188,7 @@ export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'):
if (property === 'background-color') {
const hasImportant = value.includes('!important');
const colorValue = value.replace('!important', '').trim();
const transformed = transformColorForDarkMode(colorValue);
const transformed = transformBgColorForDarkMode(colorValue);
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
}
@@ -189,7 +197,7 @@ export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'):
if (colorMatch) {
const hasImportant = value.includes('!important');
const originalColor = colorMatch[0];
const transformed = transformColorForDarkMode(originalColor);
const transformed = transformBgColorForDarkMode(originalColor);
const newValue = value.replace(originalColor, transformed);
return `${property}: ${newValue.replace('!important', '').trim()}${hasImportant ? ' !important' : ''}`;
}