mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
api.tasks.create and update take links by id (an http(s) URL, a mailto: address or a path of this Bulwark, stored as its full URL; at most ten) and relatedTo (by a mail's Message-ID or another object's UID, with its relation types; at most ten). They merge on update as alerts do: an entry given is set, one given null is removed, and entries the plugin did not name, such as an attachment's link another app wrote, stay as the server holds them. TaskView returns both as any app wrote them. Lengths, ids and URL schemes are checked as the rest of the calendar API checks them. Task reads now ask the server for links too.
244 lines
9.1 KiB
TypeScript
244 lines
9.1 KiB
TypeScript
// Projection helpers - convert host-internal types into the read-only views
|
|
// that plugins consume. Keeping this in one place ensures every slot/hook
|
|
// hands plugins the same shape declared in plugin-types.ts.
|
|
|
|
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';
|
|
import { isServerSetKeyword } from '@/lib/jmap/server-keywords';
|
|
|
|
// Resolve a message's plain-text body from its JMAP body parts. Plugins that
|
|
// translate or scan content need the real body, not just the short `preview`
|
|
// snippet. Prefers text/plain parts; falls back to stripped HTML, then preview.
|
|
export function plainTextFromEmail(email: Email): string {
|
|
const values = email.bodyValues || {};
|
|
const collect = (parts?: { partId: string }[]) =>
|
|
(parts || [])
|
|
.map((p) => values[p.partId]?.value)
|
|
.filter((v): v is string => typeof v === 'string' && v.length > 0)
|
|
.join('\n')
|
|
.trim();
|
|
|
|
const text = collect(email.textBody);
|
|
if (text) return text;
|
|
|
|
const html = collect(email.htmlBody);
|
|
if (html) {
|
|
return html
|
|
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /gi, ' ')
|
|
.replace(/&/gi, '&')
|
|
.replace(/</gi, '<')
|
|
.replace(/>/gi, '>')
|
|
.replace(/'|'/gi, "'")
|
|
.replace(/"/gi, '"')
|
|
.replace(/[ \t]{2,}/g, ' ')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim();
|
|
}
|
|
|
|
return (email.preview || '').trim();
|
|
}
|
|
|
|
/**
|
|
* `accountId` names the JMAP account the message is in; rows of merged views
|
|
* carry it, others only the caller knows (see EmailReadView.accountId).
|
|
*/
|
|
export function emailToReadView(email: Email, accountId = email.sourceAccountId): EmailReadView {
|
|
return {
|
|
id: email.id,
|
|
...(accountId ? { accountId } : {}),
|
|
threadId: email.threadId,
|
|
mailboxIds: Object.keys(email.mailboxIds || {}).filter(k => email.mailboxIds[k]),
|
|
from: (email.from || []).map(a => ({ name: a.name || '', email: a.email })),
|
|
to: (email.to || []).map(a => ({ name: a.name || '', email: a.email })),
|
|
cc: (email.cc || []).map(a => ({ name: a.name || '', email: a.email })),
|
|
subject: email.subject || '',
|
|
receivedAt: email.receivedAt,
|
|
isRead: !!email.keywords?.['$seen'],
|
|
isFlagged: !!email.keywords?.['$flagged'],
|
|
hasAttachment: email.hasAttachment,
|
|
attachments: (email.attachments || [])
|
|
.filter((part) => !!part.blobId)
|
|
.map((part) => ({
|
|
blobId: part.blobId as string,
|
|
name: part.name || '',
|
|
type: part.type || 'application/octet-stream',
|
|
size: part.size || 0,
|
|
inline: part.disposition === 'inline' || !!part.cid,
|
|
})),
|
|
preview: email.preview || '',
|
|
text: plainTextFromEmail(email),
|
|
headers: email.headers,
|
|
source: generateEmailSource(email),
|
|
// Stalwart 1.0's attachment keywords are what `hasAttachment` says.
|
|
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k] && !isServerSetKeyword(k)),
|
|
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, accountId = email.sourceAccountId): EmailSummary {
|
|
return {
|
|
id: email.id,
|
|
...(accountId ? { accountId } : {}),
|
|
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),
|
|
alerts: Object.fromEntries(Object.entries(task.alerts || {}).filter(([, alert]) => alert?.trigger).map(([id, alert]) => [id, {
|
|
trigger: alert.trigger['@type'] === 'AbsoluteTrigger'
|
|
? { at: alert.trigger.when }
|
|
: { offset: alert.trigger.offset, relativeTo: alert.trigger.relativeTo === 'end' ? 'due' as const : 'start' as const },
|
|
action: alert.action === 'email' ? 'email' as const : 'display' as const,
|
|
acknowledged: alert.acknowledged || null,
|
|
}])),
|
|
useDefaultAlerts: !!task.useDefaultAlerts,
|
|
links: Object.fromEntries(Object.entries(task.links || {}).map(([id, link]) => [id, {
|
|
href: link.href,
|
|
rel: link.rel || null,
|
|
title: link.title || null,
|
|
contentType: link.contentType || null,
|
|
}])),
|
|
relatedTo: Object.fromEntries(Object.entries(task.relatedTo || {}).map(([uid, relation]) => [uid, {
|
|
relation: trueKeys(relation?.relation),
|
|
}])),
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|