feat: add plain-data views of messages, events, contacts, tasks and files for plugin hooks

This commit is contained in:
Linus Rath
2026-10-14 03:16:25 +02:00
parent a3cb9e630e
commit af53675191
3 changed files with 234 additions and 7 deletions
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { contactToView, emailSummary, eventToView, taskToView } from '../plugin-projection';
import type { CalendarEvent, CalendarTask, ContactCard, Email } from '../jmap/types';
describe('plugin projections', () => {
it('writes contact dates without a year as --MM-DD', () => {
const view = contactToView({
id: 'c1', addressBookIds: { ab: true },
name: { components: [{ kind: 'given', value: 'Ana' }, { kind: 'surname', value: 'Silva' }] },
anniversaries: {
a: { kind: 'birth', date: { '@type': 'PartialDate', month: 2, day: 29 } as never },
b: { kind: 'wedding', date: { '@type': 'PartialDate', year: 2010, month: 6, day: 5 } as never },
},
} as unknown as ContactCard);
expect(view.fullName).toBe('Ana Silva');
expect(view.anniversaries).toEqual([
{ kind: 'birth', date: '--02-29' },
{ kind: 'wedding', date: '2010-06-05' },
]);
});
it('leaves the body out of an email summary', () => {
const summary = emailSummary({
id: 'm1', threadId: 't', mailboxIds: { inbox: true, old: false }, keywords: { $seen: true },
from: [{ email: 'a@x' }], to: [], subject: 'S', receivedAt: 'r', preview: 'p', hasAttachment: false, size: 9,
bodyValues: { 1: { value: 'secret body' } },
} as unknown as Email);
expect(summary.mailboxIds).toEqual(['inbox']);
expect(JSON.stringify(summary)).not.toContain('secret body');
});
it('marks recurring events and completed tasks', () => {
expect(eventToView({ id: 'e', uid: 'u', calendarIds: { c: true }, recurrenceRules: [{ frequency: 'yearly' }] } as unknown as CalendarEvent).isRecurring).toBe(true);
expect(taskToView({ id: 't', uid: 'u', calendarIds: {}, progress: 'completed' } as unknown as CalendarTask).isComplete).toBe(true);
});
});
+148 -2
View File
@@ -2,8 +2,14 @@
// that plugins consume. Keeping this in one place ensures every slot/hook
// hands plugins the same shape declared in plugin-types.ts.
import type { Email } from '@/lib/jmap/types';
import type { EmailReadView } from '@/lib/plugin-types';
import type {
CalendarEvent, CalendarTask, ContactCard, Email, FileNode, Identity, VacationResponse,
} from '@/lib/jmap/types';
import type {
CalendarEventView, ContactView, EmailReadView, EmailSummary, FileResourceView, IdentityView,
TaskView, TemplateView, VacationView,
} from '@/lib/plugin-types';
import type { EmailTemplate } from '@/lib/template-types';
import { generateEmailSource } from '@/lib/email-source';
// Resolve a message's plain-text body from its JMAP body parts. Plugins that
@@ -61,3 +67,143 @@ export function emailToReadView(email: Email): EmailReadView {
auth: email.authenticationResults,
};
}
const trueKeys = (rec?: Record<string, boolean> | null): string[] =>
Object.keys(rec || {}).filter((k) => rec?.[k]);
/** Headers and flags of a message, without the body (see EmailSummary). */
export function emailSummary(email: Email): EmailSummary {
return {
id: email.id,
threadId: email.threadId,
mailboxIds: trueKeys(email.mailboxIds),
from: (email.from || []).map((a) => ({ name: a.name || '', email: a.email })),
to: (email.to || []).map((a) => ({ name: a.name || '', email: a.email })),
subject: email.subject || '',
receivedAt: email.receivedAt,
preview: email.preview || '',
keywords: trueKeys(email.keywords),
hasAttachment: !!email.hasAttachment,
size: email.size || 0,
};
}
export function eventToView(event: CalendarEvent): CalendarEventView {
const location = Object.values(event.locations || {})[0]?.name || '';
return {
id: event.id,
uid: event.uid,
calendarId: trueKeys(event.calendarIds)[0] || '',
title: event.title || '',
description: event.description || '',
start: event.start,
duration: event.duration,
timeZone: event.timeZone,
utcStart: event.utcStart,
utcEnd: event.utcEnd,
isAllDay: !!event.showWithoutTime,
location,
status: event.status,
participants: Object.values(event.participants || {}).map((p) => ({
name: p.name || '',
email: p.email || '',
role: trueKeys((p as { roles?: Record<string, boolean> }).roles).join(','),
status: (p as { participationStatus?: string }).participationStatus || '',
})),
isRecurring: !!(event.recurrenceRules && event.recurrenceRules.length > 0),
keywords: trueKeys(event.keywords),
};
}
function anniversaryDate(value: unknown): string {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
const v = value as { year?: number; month?: number; day?: number; utc?: string };
if (typeof v.utc === 'string') return v.utc.slice(0, 10);
const mm = String(v.month ?? 0).padStart(2, '0');
const dd = String(v.day ?? 0).padStart(2, '0');
return v.year ? `${String(v.year).padStart(4, '0')}-${mm}-${dd}` : `--${mm}-${dd}`;
}
return '';
}
export function contactToView(contact: ContactCard): ContactView {
const components = contact.name?.components || [];
const part = (kind: string) => components.filter((c) => c.kind === kind).map((c) => c.value).join(' ');
return {
id: contact.id,
uid: contact.uid || '',
addressBookId: trueKeys(contact.addressBookIds)[0] || '',
kind: contact.kind || 'individual',
fullName: contact.name?.full || [part('given'), part('surname')].filter(Boolean).join(' '),
firstName: part('given'),
lastName: part('surname'),
emails: Object.values(contact.emails || {}).map((e) => e.address).filter(Boolean),
phones: Object.values(contact.phones || {}).map((p) => p.number).filter(Boolean),
company: Object.values(contact.organizations || {})[0]?.name || '',
notes: Object.values(contact.notes || {}).map((n) => (n as { note?: string }).note || '').join('\n'),
anniversaries: Object.values(contact.anniversaries || {}).map((a) => ({ kind: a.kind, date: anniversaryDate(a.date) })),
};
}
export function taskToView(task: CalendarTask): TaskView {
return {
id: task.id,
uid: task.uid,
title: task.title || '',
description: task.description || '',
isComplete: task.progress === 'completed',
progress: task.progress,
dueDate: task.due,
priority: task.priority ?? 0,
calendarId: trueKeys(task.calendarIds)[0] || '',
keywords: trueKeys(task.keywords),
};
}
export function fileToView(node: FileNode, path = ''): FileResourceView {
const isDir = node.type === 'd' || node.blobId === null;
return {
id: node.id,
name: node.name,
type: isDir ? 'directory' : 'file',
size: node.size || 0,
mimeType: isDir ? '' : node.type,
path,
modified: node.modified,
};
}
export function identityToView(identity: Identity): IdentityView {
return {
id: identity.id,
name: identity.name || '',
email: identity.email,
replyTo: identity.replyTo?.map((a) => a.email).join(', ') || null,
bcc: identity.bcc?.map((a) => a.email).join(', ') || null,
htmlSignature: identity.htmlSignature || '',
textSignature: identity.textSignature || '',
};
}
export function templateToView(template: EmailTemplate): TemplateView {
return {
id: template.id,
name: template.name,
category: template.category,
subject: template.subject || '',
body: template.body || '',
isHTML: !!template.isHTML,
};
}
export function vacationToView(vacation: VacationResponse): VacationView {
return {
isEnabled: vacation.isEnabled,
subject: vacation.subject || '',
htmlBody: vacation.htmlBody || '',
textBody: vacation.textBody || '',
fromDate: vacation.fromDate,
toDate: vacation.toDate,
};
}
+48 -5
View File
@@ -451,6 +451,25 @@ export interface EmailReadView {
};
}
/**
* The light view of a message used by list-level hooks (new mail, selection,
* moves): headers and flags, no body or source. Same email:read sensitivity
* as EmailReadView.
*/
export interface EmailSummary {
id: string;
threadId: string;
mailboxIds: string[];
from: { name: string; email: string }[];
to: { name: string; email: string }[];
subject: string;
receivedAt: string;
preview: string;
keywords: string[];
hasAttachment: boolean;
size: number;
}
export interface DraftView {
to: string[];
cc: string[];
@@ -474,15 +493,26 @@ export interface MailboxView {
export interface CalendarEventView {
id: string;
/** JSCalendar uid, stable across edits and the same for every occurrence. */
uid: string;
calendarId: string;
title: string;
description: string;
/** Local start, as stored (ISO 8601 without zone, see `timeZone`). */
start: string;
end: string;
/** ISO 8601 duration, e.g. PT1H. */
duration: string;
timeZone: string | null;
/** UTC start and end when the server computed them. */
utcStart: string | null;
utcEnd: string | null;
isAllDay: boolean;
location: string;
status: string;
recurrenceRule?: string;
/** Attendee addresses, organizer included. */
participants: { name: string; email: string; role: string; status: string }[];
isRecurring: boolean;
keywords: string[];
}
export interface CalendarView {
@@ -495,13 +525,21 @@ export interface CalendarView {
export interface ContactView {
id: string;
uid: string;
addressBookId: string;
kind: string;
fullName: string;
firstName: string;
lastName: string;
emails: string[];
phones: string[];
company: string;
notes: string;
/**
* Birthdays, anniversaries and other dates, as `--MM-DD` when the year is
* unknown or `YYYY-MM-DD` when it is known.
*/
anniversaries: { kind: string; date: string }[];
}
export interface AddressBookView {
@@ -538,20 +576,25 @@ export interface IdentityView {
export interface TaskView {
id: string;
uid: string;
title: string;
description: string;
isComplete: boolean;
progress: string;
dueDate: string | null;
priority: string;
/** 0 (none) or 1 (highest) to 9 (lowest), as in JSCalendar. */
priority: number;
calendarId: string;
keywords: string[];
}
export interface TemplateView {
id: string;
name: string;
category: string;
subject: string;
htmlBody: string;
textBody: string;
body: string;
isHTML: boolean;
}
export interface FilterRuleView {