mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
feat: let plugins manage templates, sender signatures and the out-of-office reply
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import type { IJMAPClient } from '../jmap/client-interface';
|
||||
import type { EmailTemplate } from '../template-types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
import { dispatchApiCall } from '../plugin-sandbox/host-api';
|
||||
|
||||
function plugin(perms: string[]): InstalledPlugin {
|
||||
return {
|
||||
id: 'helper', name: 'Helper', version: '1.0.0', author: 'test', description: '',
|
||||
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
const ALL = plugin(['templates:read', 'templates:write', 'identity:write', 'vacation:read', 'vacation:write']);
|
||||
const READ_ONLY = plugin(['templates:read', 'identity:read', 'vacation:read']);
|
||||
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
|
||||
|
||||
const greeting: EmailTemplate = {
|
||||
id: 'tpl-1', name: 'Greeting', subject: 'Hello', body: 'Hi {{recipient_name}}', isHTML: false,
|
||||
category: 'Sales', isFavorite: true, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
let client: Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = {
|
||||
updateIdentity: vi.fn(async () => undefined),
|
||||
supportsVacationResponse: vi.fn(() => true),
|
||||
supportsSieve: vi.fn(() => false),
|
||||
getVacationResponse: vi.fn(async () => ({
|
||||
id: 'singleton', isEnabled: true, fromDate: null, toDate: '2026-06-01T00:00:00Z',
|
||||
subject: 'Away', textBody: 'Back in June', htmlBody: null,
|
||||
})),
|
||||
setVacationResponse: vi.fn(async () => undefined),
|
||||
};
|
||||
useAuthStore.setState({ client: client as unknown as IJMAPClient });
|
||||
useTemplateStore.setState({ templates: [greeting], recentTemplateIds: [], deletedTemplateIds: {} });
|
||||
useIdentityStore.setState({
|
||||
identities: [{ id: 'id1', name: 'Ann', email: 'ann@example.com', replyTo: null, bcc: null, textSignature: '', htmlSignature: '', mayDelete: true }] as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
useTemplateStore.setState({ templates: [], deletedTemplateIds: {} });
|
||||
useIdentityStore.setState({ identities: [] });
|
||||
useVacationStore.getState().clearState();
|
||||
});
|
||||
|
||||
describe('api.templates', () => {
|
||||
it('lists and gets templates as views', async () => {
|
||||
expect(await call(READ_ONLY, 'templates.list')).toEqual([
|
||||
{ id: 'tpl-1', name: 'Greeting', category: 'Sales', subject: 'Hello', body: 'Hi {{recipient_name}}', isHTML: false },
|
||||
]);
|
||||
expect(await call(READ_ONLY, 'templates.get', 'tpl-1')).toEqual(expect.objectContaining({ name: 'Greeting' }));
|
||||
expect(await call(READ_ONLY, 'templates.get', 'nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('needs templates:read and templates:write', async () => {
|
||||
await expect(call(plugin([]), 'templates.list')).rejects.toThrow(/lacks permission "templates:read"/);
|
||||
await expect(call(READ_ONLY, 'templates.create', { name: 'x' })).rejects.toThrow(/lacks permission "templates:write"/);
|
||||
await expect(call(READ_ONLY, 'templates.delete', 'tpl-1')).rejects.toThrow(/lacks permission "templates:write"/);
|
||||
expect(useTemplateStore.getState().templates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates, updates and deletes through the store, sanitising HTML bodies', async () => {
|
||||
const created = await call(ALL, 'templates.create', {
|
||||
name: 'Rich', subject: 'S', body: '<p onclick="x()">Hi</p><script>alert(1)</script>', isHTML: true,
|
||||
}) as { id: string; body: string };
|
||||
expect(created.body).toContain('<p>Hi</p>');
|
||||
expect(created.body).not.toMatch(/script|onclick/);
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Greeting', 'Rich']);
|
||||
|
||||
expect(await call(ALL, 'templates.update', 'tpl-1', { subject: 'Hi there' }))
|
||||
.toEqual(expect.objectContaining({ id: 'tpl-1', subject: 'Hi there', body: 'Hi {{recipient_name}}' }));
|
||||
|
||||
await call(ALL, 'templates.delete', created.id);
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.id)).toEqual(['tpl-1']);
|
||||
expect(useTemplateStore.getState().deletedTemplateIds).toHaveProperty(created.id);
|
||||
});
|
||||
|
||||
it('refuses bad template input', async () => {
|
||||
await expect(call(ALL, 'templates.create', { subject: 'no name' })).rejects.toThrow(/name is required/);
|
||||
await expect(call(ALL, 'templates.create', { name: 'x', isFavorite: true })).rejects.toThrow(/unknown property "isFavorite"/);
|
||||
await expect(call(ALL, 'templates.create', { name: 'x', body: 'a'.repeat(100_001) })).rejects.toThrow(/at most 100000/);
|
||||
await expect(call(ALL, 'templates.update', 'nope', { name: 'x' })).rejects.toThrow(/no template with id "nope"/);
|
||||
useTemplateStore.setState({ templates: Array.from({ length: 500 }, (_, i) => ({ ...greeting, id: `t${i}` })) });
|
||||
await expect(call(ALL, 'templates.create', { name: 'one more' })).rejects.toThrow(/already 500 templates/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.identities.update', () => {
|
||||
it('changes name, reply-to and signatures and never the address', async () => {
|
||||
const view = await call(ALL, 'identities.update', 'id1', {
|
||||
name: 'Ann Smith',
|
||||
replyTo: ['Desk <desk@example.com>'],
|
||||
htmlSignature: '<b>Ann</b><img src=x onerror="steal()">',
|
||||
});
|
||||
const [id, updates] = client.updateIdentity.mock.calls[0];
|
||||
expect(id).toBe('id1');
|
||||
expect(updates).toEqual(expect.objectContaining({
|
||||
name: 'Ann Smith', replyTo: [{ name: 'Desk', email: 'desk@example.com' }],
|
||||
}));
|
||||
expect(updates.htmlSignature).toContain('<b>Ann</b>');
|
||||
expect(updates.htmlSignature).not.toContain('onerror');
|
||||
expect(updates).not.toHaveProperty('email');
|
||||
expect(view).toEqual(expect.objectContaining({ id: 'id1', name: 'Ann Smith', email: 'ann@example.com', replyTo: 'desk@example.com' }));
|
||||
expect(useIdentityStore.getState().identities[0].name).toBe('Ann Smith');
|
||||
});
|
||||
|
||||
it('refuses the address, bad addresses and unknown identities', async () => {
|
||||
await expect(call(ALL, 'identities.update', 'id1', { email: 'evil@example.com' })).rejects.toThrow(/can't be changed/);
|
||||
await expect(call(ALL, 'identities.update', 'id1', { replyTo: ['not an address'] })).rejects.toThrow(/not a valid address/);
|
||||
await expect(call(ALL, 'identities.update', 'id1', { replyTo: ['a@example.com\r\nBcc: x@example.com'] })).rejects.toThrow(/not a valid address/);
|
||||
await expect(call(ALL, 'identities.update', 'other', { name: 'x' })).rejects.toThrow(/no identity with id "other"/);
|
||||
expect(client.updateIdentity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('needs identity:write, which the consent dialog marks', async () => {
|
||||
await expect(call(READ_ONLY, 'identities.update', 'id1', { name: 'x' })).rejects.toThrow(/lacks permission "identity:write"/);
|
||||
expect(client.updateIdentity).not.toHaveBeenCalled();
|
||||
expect(HIGH_RISK_PERMISSIONS.has('identity:write')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.vacation', () => {
|
||||
it('reads the reply', async () => {
|
||||
expect(await call(READ_ONLY, 'vacation.get')).toEqual({
|
||||
isEnabled: true, subject: 'Away', textBody: 'Back in June', htmlBody: '', fromDate: null, toDate: '2026-06-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the reply through the store, sanitising HTML and deriving the text', async () => {
|
||||
const saved = await call(ALL, 'vacation.set', {
|
||||
isEnabled: true, subject: 'Out', htmlBody: '<p>Back <b>Monday</b></p><script>x()</script>',
|
||||
fromDate: '2026-05-01T08:00:00+02:00', toDate: null,
|
||||
});
|
||||
const [updates] = client.setVacationResponse.mock.calls[0];
|
||||
expect(updates).toEqual(expect.objectContaining({ isEnabled: true, subject: 'Out', fromDate: '2026-05-01T06:00:00.000Z', toDate: null }));
|
||||
expect(updates.htmlBody).toContain('<b>Monday</b>');
|
||||
expect(updates.htmlBody).not.toContain('script');
|
||||
expect(updates.textBody).toMatch(/Back Monday/);
|
||||
expect(saved).toEqual(expect.objectContaining({ isEnabled: true, subject: 'Out' }));
|
||||
expect(useVacationStore.getState().subject).toBe('Out');
|
||||
});
|
||||
|
||||
it('refuses bad input and needs vacation:write', async () => {
|
||||
await expect(call(ALL, 'vacation.set', { subject: 'x' })).rejects.toThrow(/isEnabled must be true or false/);
|
||||
await expect(call(ALL, 'vacation.set', { isEnabled: true, fromDate: 'next week' })).rejects.toThrow(/fromDate must be a date/);
|
||||
await expect(call(ALL, 'vacation.set', { isEnabled: true, fromDate: '2026-06-01T00:00:00Z', toDate: '2026-05-01T00:00:00Z' }))
|
||||
.rejects.toThrow(/toDate must be after fromDate/);
|
||||
await expect(call(READ_ONLY, 'vacation.set', { isEnabled: false })).rejects.toThrow(/lacks permission "vacation:write"/);
|
||||
expect(client.setVacationResponse).not.toHaveBeenCalled();
|
||||
expect(HIGH_RISK_PERMISSIONS.has('vacation:write')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
// Host side of api.templates, api.identities and api.vacation: the message
|
||||
// templates (kept in the browser and synced with the settings), the sender
|
||||
// identities and the out-of-office reply. Changes go through the same stores
|
||||
// and the same HTML sanitising as Settings, so what a plugin saves is held to
|
||||
// the rules a user's own edit is.
|
||||
|
||||
import type {
|
||||
IdentityView, TemplateInput, VacationInput, VacationView, TemplateView,
|
||||
} from '../plugin-types';
|
||||
import type { EmailAddress, Identity } from '../jmap/types';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { identityToView, templateToView, vacationToView } from '../plugin-projection';
|
||||
import { sanitizeEmailHtml, sanitizePluginBodyHtml, sanitizeSignatureHtml } from '../email-sanitization';
|
||||
import { htmlToPlainText } from '../html-to-text';
|
||||
import { isPeriodBoundary } from '../sieve/period';
|
||||
import {
|
||||
addressOf, nameOf, readAddressList, readBoolean, readId, readObject, readString, sessionClient,
|
||||
} from './host-api-input';
|
||||
|
||||
// ─── Templates ────────────────────────────────────────────────
|
||||
|
||||
/** Templates a plugin may leave in the store; they all sync to the server. */
|
||||
export const MAX_PLUGIN_TEMPLATES = 500;
|
||||
const MAX_TEMPLATE_NAME = 200;
|
||||
const MAX_TEMPLATE_CATEGORY = 100;
|
||||
const MAX_TEMPLATE_SUBJECT = 998;
|
||||
const MAX_TEMPLATE_BODY = 100_000;
|
||||
const TEMPLATE_FIELDS = ['name', 'subject', 'body', 'isHTML', 'category'] as const;
|
||||
|
||||
export function doTemplatesList(): TemplateView[] {
|
||||
return useTemplateStore.getState().templates.map(templateToView);
|
||||
}
|
||||
|
||||
export function doTemplatesGet(id: unknown): TemplateView | null {
|
||||
const templateId = readId(id, 'templates.get');
|
||||
const template = useTemplateStore.getState().templates.find((t) => t.id === templateId);
|
||||
return template ? templateToView(template) : null;
|
||||
}
|
||||
|
||||
/** Template fields from a plugin; an HTML body is sanitised like a plugin-supplied body anywhere else. */
|
||||
function templateFields(method: string, data: Record<string, unknown>, isHTML: boolean) {
|
||||
const out: { name?: string; subject?: string; body?: string; isHTML?: boolean; category?: string } = {};
|
||||
if (data.name !== undefined) out.name = readString(data.name, method, 'name', MAX_TEMPLATE_NAME, { min: 1, trim: true });
|
||||
if (data.subject !== undefined) out.subject = readString(data.subject, method, 'subject', MAX_TEMPLATE_SUBJECT);
|
||||
if (data.category !== undefined) {
|
||||
out.category = readString(data.category, method, 'category', MAX_TEMPLATE_CATEGORY, { trim: true });
|
||||
}
|
||||
if (data.isHTML !== undefined) out.isHTML = readBoolean(data.isHTML, method, 'isHTML');
|
||||
if (data.body !== undefined) {
|
||||
const body = readString(data.body, method, 'body', MAX_TEMPLATE_BODY);
|
||||
out.body = isHTML ? sanitizePluginBodyHtml(body) : body;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function doTemplatesCreate(input: unknown): TemplateView {
|
||||
const method = 'templates.create';
|
||||
const data = readObject(input, method, 'template', TEMPLATE_FIELDS) as TemplateInput & Record<string, unknown>;
|
||||
if (data.name === undefined) throw new Error(`${method}: name is required`);
|
||||
const store = useTemplateStore.getState();
|
||||
if (store.templates.length >= MAX_PLUGIN_TEMPLATES) {
|
||||
throw new Error(`${method}: there are already ${MAX_PLUGIN_TEMPLATES} templates`);
|
||||
}
|
||||
const isHTML = data.isHTML === undefined ? false : readBoolean(data.isHTML, method, 'isHTML');
|
||||
const fields = templateFields(method, data, isHTML);
|
||||
const created = store.addTemplate({
|
||||
name: fields.name!,
|
||||
subject: fields.subject ?? '',
|
||||
body: fields.body ?? '',
|
||||
isHTML,
|
||||
category: fields.category ?? '',
|
||||
isFavorite: false,
|
||||
});
|
||||
return templateToView(created);
|
||||
}
|
||||
|
||||
export function doTemplatesUpdate(id: unknown, patch: unknown): TemplateView {
|
||||
const method = 'templates.update';
|
||||
const templateId = readId(id, method);
|
||||
const data = readObject(patch, method, 'patch', TEMPLATE_FIELDS);
|
||||
if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`);
|
||||
const existing = useTemplateStore.getState().templates.find((t) => t.id === templateId);
|
||||
if (!existing) throw new Error(`${method}: no template with id "${templateId}"`);
|
||||
const isHTML = data.isHTML === undefined ? !!existing.isHTML : readBoolean(data.isHTML, method, 'isHTML');
|
||||
const fields = templateFields(method, data, isHTML);
|
||||
// Turning an existing body into HTML must not let unsanitised text through.
|
||||
if (isHTML && !existing.isHTML && fields.body === undefined) fields.body = sanitizePluginBodyHtml(existing.body || '');
|
||||
useTemplateStore.getState().updateTemplate(templateId, fields);
|
||||
return templateToView(useTemplateStore.getState().templates.find((t) => t.id === templateId)!);
|
||||
}
|
||||
|
||||
export function doTemplatesDelete(id: unknown): void {
|
||||
const method = 'templates.delete';
|
||||
const templateId = readId(id, method);
|
||||
if (!useTemplateStore.getState().templates.some((t) => t.id === templateId)) {
|
||||
throw new Error(`${method}: no template with id "${templateId}"`);
|
||||
}
|
||||
useTemplateStore.getState().deleteTemplate(templateId);
|
||||
}
|
||||
|
||||
// ─── Identities ───────────────────────────────────────────────
|
||||
|
||||
const MAX_IDENTITY_NAME = 256;
|
||||
const MAX_REPLY_TO = 10;
|
||||
const MAX_TEXT_SIGNATURE = 20_000;
|
||||
const MAX_HTML_SIGNATURE = 100_000;
|
||||
|
||||
/**
|
||||
* Change one sender identity's name, reply-to addresses or signatures. The
|
||||
* address itself can't be changed; an HTML signature is sanitised exactly
|
||||
* like one typed into Settings > Identities.
|
||||
*/
|
||||
export async function doIdentitiesUpdate(id: unknown, patch: unknown): Promise<IdentityView> {
|
||||
const method = 'identities.update';
|
||||
const identityId = readId(id, method);
|
||||
if (patch && typeof patch === 'object' && 'email' in patch) {
|
||||
throw new Error(`${method}: the address of an identity can't be changed`);
|
||||
}
|
||||
const data = readObject(patch, method, 'patch', ['name', 'replyTo', 'textSignature', 'htmlSignature']);
|
||||
if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`);
|
||||
const identity = useIdentityStore.getState().identities.find((i) => i.id === identityId);
|
||||
if (!identity) throw new Error(`${method}: no identity with id "${identityId}"`);
|
||||
|
||||
const updates: { name?: string; replyTo?: EmailAddress[] | null; textSignature?: string | null; htmlSignature?: string | null } = {};
|
||||
if (data.name !== undefined) updates.name = readString(data.name, method, 'name', MAX_IDENTITY_NAME, { trim: true });
|
||||
if (data.replyTo !== undefined) {
|
||||
const list = data.replyTo === null ? [] : readAddressList(data.replyTo, method, 'replyTo', MAX_REPLY_TO);
|
||||
updates.replyTo = list.length === 0
|
||||
? null
|
||||
: list.map((entry) => ({ ...(nameOf(entry) ? { name: nameOf(entry) } : {}), email: addressOf(entry) }));
|
||||
}
|
||||
if (data.textSignature !== undefined) {
|
||||
const text = data.textSignature === null ? '' : readString(data.textSignature, method, 'textSignature', MAX_TEXT_SIGNATURE);
|
||||
// JMAP clears a field with null; an empty string would be stored as such.
|
||||
updates.textSignature = text.trim() ? text : null;
|
||||
}
|
||||
if (data.htmlSignature !== undefined) {
|
||||
const html = data.htmlSignature === null ? '' : readString(data.htmlSignature, method, 'htmlSignature', MAX_HTML_SIGNATURE);
|
||||
updates.htmlSignature = html.trim() ? sanitizeSignatureHtml(html) : null;
|
||||
}
|
||||
|
||||
await sessionClient(method).updateIdentity(identityId, updates);
|
||||
useIdentityStore.getState().updateIdentityLocal(identityId, updates as Partial<Identity>);
|
||||
return identityToView({ ...identity, ...updates } as Identity);
|
||||
}
|
||||
|
||||
// ─── Vacation ─────────────────────────────────────────────────
|
||||
|
||||
const MAX_VACATION_SUBJECT = 500;
|
||||
const MAX_VACATION_BODY = 64_000;
|
||||
|
||||
function vacationClient(method: string) {
|
||||
const client = sessionClient(method);
|
||||
if (!client.supportsVacationResponse()) throw new Error(`${method}: the mail server has no out-of-office reply`);
|
||||
return client;
|
||||
}
|
||||
|
||||
function vacationViewFromStore(): VacationView {
|
||||
const s = useVacationStore.getState();
|
||||
return vacationToView({
|
||||
id: 'singleton',
|
||||
isEnabled: s.isEnabled,
|
||||
fromDate: s.fromDate,
|
||||
toDate: s.toDate,
|
||||
subject: s.subject,
|
||||
textBody: s.textBody,
|
||||
htmlBody: s.htmlBody,
|
||||
});
|
||||
}
|
||||
|
||||
export async function doVacationGet(): Promise<VacationView> {
|
||||
const method = 'vacation.get';
|
||||
const client = vacationClient(method);
|
||||
await useVacationStore.getState().fetchVacationResponse(client);
|
||||
const { error } = useVacationStore.getState();
|
||||
if (error) throw new Error(`${method}: ${error}`);
|
||||
return vacationViewFromStore();
|
||||
}
|
||||
|
||||
function readMoment(value: unknown, method: string, field: string): string | null {
|
||||
if (value === null) return null;
|
||||
if (!isPeriodBoundary(value)) {
|
||||
throw new Error(`${method}: ${field} must be a date and time with a zone, such as 2026-05-01T00:00:00Z, or null`);
|
||||
}
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the out-of-office reply on or off and set its text and dates. The
|
||||
* HTML body is sanitised like the one typed into Settings; without a text
|
||||
* body the text is derived from it. Resolves to the reply as saved, or null
|
||||
* when another plugin stopped the change.
|
||||
*/
|
||||
export async function doVacationSet(input: unknown): Promise<VacationView | null> {
|
||||
const method = 'vacation.set';
|
||||
const data = readObject(input, method, 'vacation', ['isEnabled', 'subject', 'textBody', 'htmlBody', 'fromDate', 'toDate']) as VacationInput & Record<string, unknown>;
|
||||
const updates: {
|
||||
isEnabled: boolean; subject?: string; textBody?: string; htmlBody?: string | null;
|
||||
fromDate?: string | null; toDate?: string | null;
|
||||
} = { isEnabled: readBoolean(data.isEnabled, method, 'isEnabled') };
|
||||
if (data.subject !== undefined) updates.subject = readString(data.subject, method, 'subject', MAX_VACATION_SUBJECT);
|
||||
if (data.textBody !== undefined) updates.textBody = readString(data.textBody, method, 'textBody', MAX_VACATION_BODY);
|
||||
if (data.htmlBody !== undefined) {
|
||||
const html = data.htmlBody === null ? '' : readString(data.htmlBody, method, 'htmlBody', MAX_VACATION_BODY);
|
||||
updates.htmlBody = html && htmlToPlainText(html).trim() ? sanitizeEmailHtml(html) : null;
|
||||
if (updates.htmlBody && updates.textBody === undefined) {
|
||||
updates.textBody = htmlToPlainText(updates.htmlBody, { paragraphSpacing: true });
|
||||
}
|
||||
}
|
||||
if (data.fromDate !== undefined) updates.fromDate = readMoment(data.fromDate, method, 'fromDate');
|
||||
if (data.toDate !== undefined) updates.toDate = readMoment(data.toDate, method, 'toDate');
|
||||
if (updates.fromDate && updates.toDate && updates.toDate <= updates.fromDate) {
|
||||
throw new Error(`${method}: toDate must be after fromDate`);
|
||||
}
|
||||
|
||||
const client = vacationClient(method);
|
||||
// Returns false once the onBeforeVacationUpdate hook is wired into the
|
||||
// store and a plugin said no; nothing (undefined) before that.
|
||||
const outcome: unknown = await useVacationStore.getState().updateVacationResponse(client, updates);
|
||||
if (outcome === false) return null;
|
||||
return vacationViewFromStore();
|
||||
}
|
||||
@@ -37,6 +37,10 @@ import {
|
||||
doCalendarListCalendars, doCalendarUpdateEvent, doTasksCreate, doTasksDelete, doTasksGet,
|
||||
doTasksList, doTasksSetComplete, doTasksUpdate,
|
||||
} from './host-api-calendar';
|
||||
import {
|
||||
doIdentitiesUpdate, doTemplatesCreate, doTemplatesDelete, doTemplatesGet, doTemplatesList,
|
||||
doTemplatesUpdate, doVacationGet, doVacationSet,
|
||||
} from './host-api-settings';
|
||||
|
||||
/**
|
||||
* Methods only callable from the privileged (same-origin) tier. These expose
|
||||
@@ -194,6 +198,17 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
'tasks.update': 'tasks:write',
|
||||
'tasks.setComplete': 'tasks:write',
|
||||
'tasks.delete': 'tasks:write',
|
||||
// message templates
|
||||
'templates.list': 'templates:read',
|
||||
'templates.get': 'templates:read',
|
||||
'templates.create': 'templates:write',
|
||||
'templates.update': 'templates:write',
|
||||
'templates.delete': 'templates:write',
|
||||
// sender identities: name, reply-to and signatures, never the address
|
||||
'identities.update': 'identity:write',
|
||||
// out-of-office reply
|
||||
'vacation.get': 'vacation:read',
|
||||
'vacation.set': 'vacation:write',
|
||||
};
|
||||
|
||||
// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
|
||||
@@ -1869,6 +1884,17 @@ export async function dispatchApiCall(
|
||||
case 'tasks.setComplete': return doTasksSetComplete(args[0], args[1]);
|
||||
case 'tasks.delete': return doTasksDelete(args[0]);
|
||||
|
||||
case 'templates.list': return doTemplatesList();
|
||||
case 'templates.get': return doTemplatesGet(args[0]);
|
||||
case 'templates.create': return doTemplatesCreate(args[0]);
|
||||
case 'templates.update': return doTemplatesUpdate(args[0], args[1]);
|
||||
case 'templates.delete': doTemplatesDelete(args[0]); return undefined;
|
||||
|
||||
case 'identities.update': return doIdentitiesUpdate(args[0], args[1]);
|
||||
|
||||
case 'vacation.get': return doVacationGet();
|
||||
case 'vacation.set': return doVacationSet(args[0]);
|
||||
|
||||
default:
|
||||
throw new Error(`Unhandled method "${method}"`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ export const HIGH_RISK_PERMISSIONS: ReadonlySet<string> = new Set([
|
||||
'http:fetch',
|
||||
'http:post',
|
||||
'filters:write',
|
||||
// A plugin can point the reply-to of a sender address elsewhere, so
|
||||
// answers to the user's mail go to someone else.
|
||||
'identity:write',
|
||||
// The out-of-office reply goes out to everyone who writes, in the user's
|
||||
// name, with whatever text the plugin set.
|
||||
'vacation:write',
|
||||
]);
|
||||
|
||||
/** Locale key segment for a permission: `email:read` → `email_read`. */
|
||||
|
||||
@@ -31,7 +31,8 @@ import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
|
||||
import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n';
|
||||
import { isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
|
||||
import type {
|
||||
CalendarEventInput, CalendarEventView, CalendarView, SlotName, TaskInput, TaskView,
|
||||
CalendarEventInput, CalendarEventView, CalendarView, IdentityPatch, IdentityView, SlotName,
|
||||
TaskInput, TaskView, TemplateInput, TemplateView, VacationInput, VacationView,
|
||||
} from '../plugin-types';
|
||||
import { AddressBook, ContactCard } from '../jmap/types';
|
||||
import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store';
|
||||
@@ -530,6 +531,35 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
setComplete: (id: string, complete: boolean) => callApi('tasks.setComplete', [id, complete]) as Promise<void>,
|
||||
delete: (id: string) => callApi('tasks.delete', [id]) as Promise<void>,
|
||||
},
|
||||
// Message templates (permissions: templates:read / templates:write).
|
||||
// They live with the user's settings and sync across devices.
|
||||
templates: {
|
||||
list: () => callApi('templates.list', []) as Promise<TemplateView[]>,
|
||||
/** One template, or null. */
|
||||
get: (id: string) => callApi('templates.get', [id]) as Promise<TemplateView | null>,
|
||||
/** At most 500 templates in all. An HTML body is sanitised. */
|
||||
create: (template: TemplateInput) => callApi('templates.create', [template]) as Promise<TemplateView>,
|
||||
update: (id: string, patch: Partial<TemplateInput>) =>
|
||||
callApi('templates.update', [id, patch]) as Promise<TemplateView>,
|
||||
delete: (id: string) => callApi('templates.delete', [id]) as Promise<void>,
|
||||
},
|
||||
// Sender identities. Read them with api.user.getIdentities()
|
||||
// (identity:read); change one with identity:write.
|
||||
identities: {
|
||||
/**
|
||||
* Change the name, reply-to addresses or signatures of one identity.
|
||||
* The address can't be changed. An HTML signature is sanitised like
|
||||
* one typed into Settings. Resolves to the identity as changed.
|
||||
*/
|
||||
update: (id: string, patch: IdentityPatch) =>
|
||||
callApi('identities.update', [id, patch]) as Promise<IdentityView>,
|
||||
},
|
||||
// Out-of-office reply (permissions: vacation:read / vacation:write).
|
||||
vacation: {
|
||||
get: () => callApi('vacation.get', []) as Promise<VacationView>,
|
||||
/** Resolves to the reply as saved, or null when another plugin stopped the change. */
|
||||
set: (vacation: VacationInput) => callApi('vacation.set', [vacation]) as Promise<VacationView | null>,
|
||||
},
|
||||
// OAuth with a third party (permission: http:fetch). Put createState()'s
|
||||
// value in the authorize URL; the code comes back through the
|
||||
// onOAuthCallback hook, to this plugin only.
|
||||
|
||||
@@ -646,6 +646,46 @@ export interface TemplateView {
|
||||
isHTML: boolean;
|
||||
}
|
||||
|
||||
/** What a plugin passes to `api.templates.create` (and, every field optional, to `update`). */
|
||||
export interface TemplateInput {
|
||||
/** 1-200 characters. */
|
||||
name: string;
|
||||
/** Up to 998 characters. */
|
||||
subject?: string;
|
||||
/** Up to 100,000 characters. When isHTML, sanitised like any HTML a plugin hands the host. */
|
||||
body?: string;
|
||||
isHTML?: boolean;
|
||||
/** Up to 100 characters; '' for none. */
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/** What a plugin may change on a sender identity with `api.identities.update`. The address can't be changed. */
|
||||
export interface IdentityPatch {
|
||||
/** Up to 256 characters. */
|
||||
name?: string;
|
||||
/** At most 10 addresses (`a@example.com` or `Name <a@example.com>`); null or [] removes them. */
|
||||
replyTo?: string[] | null;
|
||||
/** Up to 20,000 characters; null or '' removes it. */
|
||||
textSignature?: string | null;
|
||||
/** Up to 100,000 characters, sanitised like a signature typed into Settings; null or '' removes it. */
|
||||
htmlSignature?: string | null;
|
||||
}
|
||||
|
||||
/** What a plugin passes to `api.vacation.set`. Fields left out keep their value. */
|
||||
export interface VacationInput {
|
||||
isEnabled: boolean;
|
||||
/** Up to 500 characters. */
|
||||
subject?: string;
|
||||
/** Up to 64,000 characters. Derived from htmlBody when that is given and this is not. */
|
||||
textBody?: string;
|
||||
/** Up to 64,000 characters, sanitised like the HTML typed into Settings; null removes it. */
|
||||
htmlBody?: string | null;
|
||||
/** When the reply starts, with a zone (`2026-05-01T00:00:00Z`), or null for now. */
|
||||
fromDate?: string | null;
|
||||
/** When it ends, or null for open-ended. */
|
||||
toDate?: string | null;
|
||||
}
|
||||
|
||||
export interface FilterRuleView {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user