diff --git a/lib/__tests__/plugin-projection.test.ts b/lib/__tests__/plugin-projection.test.ts new file mode 100644 index 000000000..abae6ccbf --- /dev/null +++ b/lib/__tests__/plugin-projection.test.ts @@ -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); + }); +}); diff --git a/lib/plugin-projection.ts b/lib/plugin-projection.ts index 865df43e3..923758fe3 100644 --- a/lib/plugin-projection.ts +++ b/lib/plugin-projection.ts @@ -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 | 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 }).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, + }; +} diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 08f0949e7..2cb987675 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -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 {