mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: add plain-data views of messages, events, contacts, tasks and files for plugin hooks
This commit is contained in:
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user