mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
Merge pull request #1443 from illuwa-soft/feat/upstream-composer-read-api
feat: add composer snapshots and conditional edits # Conflicts: # lib/__tests__/plugin-sandbox-runtime-host-gate.test.ts # lib/plugin-sandbox/host-api.ts
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Composer snapshots and conditional edits
|
||||
|
||||
These methods operate on a composer id handed to a plugin by a native composer action or slot. They do not discover other composers or grant permission to send a message.
|
||||
|
||||
| Method | Permission | Result |
|
||||
| --- | --- | --- |
|
||||
| `composer.getSnapshot(composerId)` | `compose:read` | Authored text, subject, To/Cc/Bcc, ready attachment metadata, pending state and an opaque revision. |
|
||||
| `composer.readAttachment(composerId, attachmentId)` | `email:blob-read` | Bytes of an attachment in that composer's snapshot, at most 25 MiB. |
|
||||
| `composer.replaceBody(composerId, content, { expectedRevision })` | `compose:write` | One native editor transaction, rejected with `composer_changed` when the revision differs. |
|
||||
|
||||
Snapshot text excludes the signature and quoted history. The opaque revision covers authored content, recipients, subject, attachment metadata, pending state, account and identity. Account and identity markers stay host-side. Snapshot arrays are copies.
|
||||
|
||||
Attachment ids are composer-local opaque handles, not server blob ids. Two logins with the same blob id do not collide. The native composer uses the attachment's owning client. Metadata and actual bytes are limited separately, and bytes are discarded if the composer closes or its revision changes during the read.
|
||||
|
||||
`expectedRevision` is optional, preserving existing unconditional edits. When supplied it is checked immediately before the synchronous editor transaction. Existing native Undo, signature, quote and inline-image behavior remains in use.
|
||||
|
||||
```js
|
||||
const draft = await api.composer.getSnapshot(composerId);
|
||||
const text = await prepareText(draft.text);
|
||||
await api.composer.replaceBody(composerId, { text }, {
|
||||
expectedRevision: draft.revision,
|
||||
});
|
||||
```
|
||||
|
||||
This change is the composer SDK portion approved in [#1369](https://github.com/bulwarkmail/webmail/issues/1369#issuecomment-6506321971). Quick-reply registration and toolbar actions are a separate change. No product route, companion authentication, service configuration or dependency is introduced. Lite's existing plugin policy is unchanged.
|
||||
|
||||
작성창의 읽기 권한과 발송 권한을 분리하고, 처리 중 변경된 초안을 덮어쓰지 않도록 리비전을 검사합니다. 제품의 모델 호출·설정·인증은 플러그인이 소유합니다.
|
||||
@@ -3,11 +3,12 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { undoDepth } from '@tiptap/pm/history';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { EmailComposer, type ComposerDraftData } from '../email-composer';
|
||||
import { buildSignatureBlock } from '../signature-block';
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from '../quoted-html';
|
||||
import { composerHandle } from '@/lib/plugin-sandbox/composer-registry';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// The real composer around the real editor, worked on by a plugin through
|
||||
// the handle it registers for api.composer: what the plugin reads as the
|
||||
@@ -215,11 +216,11 @@ const DRAFT = {
|
||||
const PLUGIN = 'assistant';
|
||||
|
||||
/** The real composer; its id comes from a click on a plugin's toolbar button, as for a plugin. */
|
||||
async function renderComposer(body = BODY) {
|
||||
async function renderComposer(body = BODY, initialData: Partial<ComposerDraftData> = {}) {
|
||||
const clicks: Record<string, unknown>[] = [];
|
||||
setActionDispatcher(async (_plugin, _surface, _id, context) => { clicks.push(context); });
|
||||
setPluginActions(PLUGIN, 'Assistant', 'composer-toolbar-actions', [{ id: 'ask', label: 'Assistant', icon: 'sparkles', shortcut: 'Ctrl+Shift+Y' }]);
|
||||
render(<EmailComposer initialData={{ ...DRAFT, body }} mode="reply" onClose={vi.fn()} />);
|
||||
render(<EmailComposer initialData={{ ...DRAFT, body, ...initialData }} mode="reply" onClose={vi.fn()} />);
|
||||
const dom = await waitFor(() => {
|
||||
const el = document.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null;
|
||||
expect(el?.editor).toBeTruthy();
|
||||
@@ -254,6 +255,47 @@ describe('plugins editing the message being written', () => {
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
it('reads live subject and recipient metadata from the registered composer', async () => {
|
||||
const { handle } = await renderComposer(BODY, { cc: 'cc@example.com', bcc: 'bcc@example.com' });
|
||||
expect(handle.readMetadata?.()).toMatchObject({ subject: 'Re: Photo', to: ['Bob Builder <bob@example.com>'], cc: ['cc@example.com'], bcc: ['bcc@example.com'], attachments: [], pending: false });
|
||||
fireEvent.change(screen.getByDisplayValue('Re: Photo'), { target: { value: 'Updated subject' } });
|
||||
expect(handle.readMetadata?.().subject).toBe('Updated subject');
|
||||
});
|
||||
|
||||
it('distinguishes equal blob ids from two logins and reads through the attachment owner', async () => {
|
||||
const fetchA = vi.fn(async () => ({ type: 'text/plain', size: 1, arrayBuffer: async () => new Uint8Array([1]).buffer }));
|
||||
const fetchB = vi.fn(async () => ({ type: 'text/plain', size: 1, arrayBuffer: async () => new Uint8Array([2]).buffer }));
|
||||
const fetchOwn = vi.fn();
|
||||
const previous = useAuthStore.getState();
|
||||
const oldClient = previous.client;
|
||||
const oldGetClient = previous.getClientForAccount;
|
||||
const owners = new Map([['login-a', { fetchBlob: fetchA }], ['login-b', { fetchBlob: fetchB }]]);
|
||||
useAuthStore.setState({
|
||||
client: { fetchBlob: fetchOwn, hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
getClientForAccount: (id: string) => owners.get(id) as never,
|
||||
});
|
||||
try {
|
||||
const { handle } = await renderComposer(BODY, {
|
||||
attachments: [
|
||||
{ blobId: 'same-blob', name: 'a.txt', type: 'text/plain', size: 1, sourceAccountId: 'login-a' },
|
||||
{ blobId: 'same-blob', name: 'b.txt', type: 'text/plain', size: 1, sourceAccountId: 'login-b' },
|
||||
],
|
||||
});
|
||||
const files = handle.readMetadata!().attachments;
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files[0].id).not.toBe(files[1].id);
|
||||
const result = await handle.readAttachment!(files[1].id);
|
||||
expect(new Uint8Array(result.data)).toEqual(new Uint8Array([2]));
|
||||
expect(fetchB).toHaveBeenCalledWith('same-blob', 'b.txt', 'text/plain');
|
||||
expect(fetchA).not.toHaveBeenCalled();
|
||||
expect(fetchOwn).not.toHaveBeenCalled();
|
||||
await expect(handle.readAttachment!('not-attached')).rejects.toThrow('attachment_unavailable');
|
||||
} finally {
|
||||
cleanup();
|
||||
useAuthStore.setState({ client: oldClient, getClientForAccount: oldGetClient });
|
||||
}
|
||||
});
|
||||
|
||||
it('reads only the user\'s own text: no signature, no quote header, no quote', async () => {
|
||||
const { handle } = await renderComposer();
|
||||
const body = handle.readBody();
|
||||
|
||||
@@ -44,6 +44,7 @@ import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarM
|
||||
import { ComposerToolbarMenuRadioItem } from "@/components/email/composer-toolbar-menu";
|
||||
import type { MessagePriority } from "@/lib/message-priority";
|
||||
import { registerComposer } from "@/lib/plugin-sandbox/composer-registry";
|
||||
import { MAX_PLUGIN_FETCH_BYTES } from "@/lib/plugin-sandbox/host-api-input";
|
||||
import { plainOwnRange, readOwnContent, readSelection as readEditorSelection, replaceOwnContent, replaceSelection as replaceEditorSelection, replaceTextareaRange, textToEditorHtml, type OwnTextOptions } from "@/lib/plugin-sandbox/composer-edit";
|
||||
import { actionsFor, runAction } from "@/lib/plugin-sandbox/actions";
|
||||
import { matchesCombo } from "@/lib/plugin-sandbox/shortcuts";
|
||||
@@ -2974,9 +2975,42 @@ export function EmailComposer({
|
||||
};
|
||||
const pluginAttachRef = useRef(handlePluginAttachmentAdd);
|
||||
pluginAttachRef.current = handlePluginAttachmentAdd;
|
||||
const pluginAttachmentIdsRef = useRef(new WeakMap<ComposerAttachment, string>());
|
||||
// Read live draft data without re-registering the composer on every edit.
|
||||
// 매번 작성창을 재등록하지 않고 최신 초안 데이터를 읽습니다.
|
||||
const pluginData = {
|
||||
readMetadata: () => {
|
||||
const draft = draftSnapshotRef.current();
|
||||
return {
|
||||
subject: draft.subject, to: draft.to, cc: draft.cc, bcc: draft.bcc,
|
||||
accountKey: composerAccountId ?? '', identityId: draft.identityId,
|
||||
pending: attachmentsRef.current.some(att => att.uploading || att.error || !att.blobId),
|
||||
attachments: attachmentsRef.current.flatMap(att => {
|
||||
if (!att.blobId || att.uploading || att.error) return [];
|
||||
const id = pluginAttachmentIdsRef.current.get(att) ?? generateUUID();
|
||||
pluginAttachmentIdsRef.current.set(att, id);
|
||||
return [{ id, name: att.name, type: att.type || 'application/octet-stream', size: att.size }];
|
||||
}),
|
||||
};
|
||||
},
|
||||
readAttachment: async (attachmentId: string) => {
|
||||
const file = attachmentsRef.current.find(att => pluginAttachmentIdsRef.current.get(att) === attachmentId && !att.uploading && !att.error);
|
||||
if (!file?.blobId) throw new Error('attachment_unavailable');
|
||||
let owner = composerClient;
|
||||
if (file.sourceAccountId && file.sourceAccountId !== composerAccountId) owner = useAuthStore.getState().getClientForAccount(file.sourceAccountId) ?? null;
|
||||
if (!owner) throw new Error('attachment_unavailable');
|
||||
const blob = await owner.fetchBlob(file.blobId, file.name, file.type);
|
||||
if (blob.size > MAX_PLUGIN_FETCH_BYTES) throw new Error('composer.readAttachment: attachment exceeds 25 MiB');
|
||||
return { name: file.name, type: file.type || blob.type, size: blob.size, data: await blob.arrayBuffer() };
|
||||
},
|
||||
};
|
||||
const pluginDataRef = useRef(pluginData);
|
||||
pluginDataRef.current = pluginData;
|
||||
useEffect(() => {
|
||||
const plainRange = (value: string) => plainOwnRange(value, pluginEditOptsRef.current);
|
||||
const { id, unregister } = registerComposer({
|
||||
readMetadata: () => pluginDataRef.current.readMetadata(),
|
||||
readAttachment: (attachmentId) => pluginDataRef.current.readAttachment(attachmentId),
|
||||
readBody: () => {
|
||||
if (plainTextModeRef.current) {
|
||||
const value = bodyRef.current?.value ?? '';
|
||||
|
||||
@@ -14,7 +14,7 @@ const { dispatchApiCall } = await import('../plugin-sandbox/host-api');
|
||||
|
||||
function plugin(perms: string[]): InstalledPlugin {
|
||||
return {
|
||||
id: 'assistant', name: 'Assistant', version: '1.0.0', author: 'test', description: '',
|
||||
id: 'text-tools', name: 'Text tools', version: '1.0.0', author: 'test', description: '',
|
||||
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
@@ -40,6 +40,87 @@ beforeEach(() => resetPluginRateLimits());
|
||||
afterEach(() => resetComposers());
|
||||
|
||||
describe('api.composer', () => {
|
||||
it('reads a stable snapshot with compose:read, without send permission or plugin configuration', async () => {
|
||||
const handle = fakeHandle();
|
||||
handle.readMetadata = () => ({ subject: 'Subject', to: ['to@example.com'], cc: ['cc@example.com'], bcc: ['bcc@example.com'], attachments: [], pending: false });
|
||||
const { id } = registerComposer(handle);
|
||||
const snapshot = await call(READ, 'composer.getSnapshot', id) as { revision: string };
|
||||
expect(snapshot).toMatchObject({ text: 'Hello', subject: 'Subject', to: ['to@example.com'], cc: ['cc@example.com'], bcc: ['bcc@example.com'], attachments: [], pending: false });
|
||||
expect(await call(READ, 'composer.getSnapshot', id)).toEqual(snapshot);
|
||||
await expect(call(WRITE, 'composer.getSnapshot', id)).rejects.toThrow(/lacks permission "compose:read"/);
|
||||
await expect(call(plugin(['email:send']), 'composer.getSnapshot', id)).rejects.toThrow(/lacks permission "compose:read"/);
|
||||
});
|
||||
|
||||
it('rejects an obsolete body revision and accepts the current snapshot', async () => {
|
||||
const handle = fakeHandle();
|
||||
let text = 'First draft';
|
||||
handle.readBody = () => ({ text, html: `<p>${text}</p>` });
|
||||
const { id } = registerComposer(handle);
|
||||
const original = await call(READ, 'composer.getSnapshot', id) as { revision: string };
|
||||
text = 'Edited draft';
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { text: 'Replacement' }, { expectedRevision: original.revision })).rejects.toThrow('composer_changed');
|
||||
expect(handle.calls).toEqual([]);
|
||||
const current = await call(READ, 'composer.getSnapshot', id) as { revision: string };
|
||||
expect(current.revision).not.toBe(original.revision);
|
||||
expect(await call(WRITE, 'composer.replaceBody', id, { text: 'Replacement' }, { expectedRevision: current.revision })).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['subject', 'to', 'cc', 'bcc', 'attachments', 'accountKey', 'identityId', 'pending'] as const)('invalidates the snapshot when %s changes', async (field) => {
|
||||
const handle = fakeHandle();
|
||||
let metadata = { subject: 'Subject', to: ['to@example.com'], cc: [] as string[], bcc: [] as string[], attachments: [] as { id: string; name: string; type: string; size: number }[], accountKey: 'account-one', identityId: 'identity-one', pending: false };
|
||||
handle.readMetadata = () => metadata;
|
||||
const { id } = registerComposer(handle);
|
||||
const original = await call(READ, 'composer.getSnapshot', id) as { revision: string };
|
||||
const changes = { subject: 'New subject', to: ['other@example.com'], cc: ['cc@example.com'], bcc: ['bcc@example.com'], attachments: [{ id: 'file', name: 'file.txt', type: 'text/plain', size: 1 }], accountKey: 'account-two', identityId: 'identity-two', pending: true };
|
||||
metadata = { ...metadata, [field]: changes[field] };
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { text: 'Replacement' }, { expectedRevision: original.revision })).rejects.toThrow('composer_changed');
|
||||
expect(handle.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('limits attachment reads to the selected composer and the blob-read permission', async () => {
|
||||
const handle = fakeHandle();
|
||||
handle.readMetadata = () => ({ subject: '', to: [], attachments: [{ id: 'attached', name: 'file.bin', type: 'application/octet-stream', size: 3 }] });
|
||||
handle.readAttachment = vi.fn(async () => ({ name: 'file.bin', type: 'application/octet-stream', size: 3, data: new Uint8Array([1, 2, 3]).buffer }));
|
||||
const { id } = registerComposer(handle);
|
||||
const reader = plugin(['email:blob-read']);
|
||||
await expect(call(READ, 'composer.readAttachment', id, 'attached')).rejects.toThrow(/lacks permission "email:blob-read"/);
|
||||
await expect(call(reader, 'composer.readAttachment', id, 'unattached')).rejects.toThrow('attachment_not_in_composer');
|
||||
expect(handle.readAttachment).not.toHaveBeenCalled();
|
||||
expect(await call(reader, 'composer.readAttachment', id, 'attached')).toMatchObject({ name: 'file.bin', size: 3 });
|
||||
expect(handle.readAttachment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an attachment read if the account changes while awaiting its bytes', async () => {
|
||||
const handle = fakeHandle();
|
||||
let accountKey = 'account-one';
|
||||
handle.readMetadata = () => ({ subject: '', to: [], accountKey, attachments: [{ id: 'attached', name: 'file.bin', type: 'application/octet-stream', size: 1 }] });
|
||||
handle.readAttachment = async () => { accountKey = 'account-two'; return { name: 'file.bin', type: 'application/octet-stream', size: 1, data: new ArrayBuffer(1) }; };
|
||||
const { id } = registerComposer(handle);
|
||||
await expect(call(plugin(['email:blob-read']), 'composer.readAttachment', id, 'attached')).rejects.toThrow('composer_changed');
|
||||
});
|
||||
|
||||
it('discards attachment bytes if their composer closed during the read', async () => {
|
||||
const handle = fakeHandle();
|
||||
handle.readMetadata = () => ({ subject: '', to: [], attachments: [{ id: 'attached', name: 'file.txt', type: 'text/plain', size: 1 }] });
|
||||
const { id, unregister } = registerComposer(handle);
|
||||
handle.readAttachment = async () => { unregister(); return { name: 'file.txt', type: 'text/plain', size: 1, data: new ArrayBuffer(1) }; };
|
||||
await expect(call(plugin(['email:blob-read']), 'composer.readAttachment', id, 'attached')).rejects.toThrow('composer_changed');
|
||||
});
|
||||
|
||||
it('rejects oversized attachment metadata before fetching and oversized actual bytes afterward', async () => {
|
||||
const handle = fakeHandle();
|
||||
let size = 25 * 1024 * 1024 + 1;
|
||||
handle.readMetadata = () => ({ subject: '', to: [], attachments: [{ id: 'attached', name: 'file.bin', type: 'application/octet-stream', size }] });
|
||||
handle.readAttachment = vi.fn(async () => ({ name: 'file.bin', type: 'application/octet-stream', size: 1, data: new ArrayBuffer(25 * 1024 * 1024 + 1) }));
|
||||
const { id } = registerComposer(handle);
|
||||
const reader = plugin(['email:blob-read']);
|
||||
await expect(call(reader, 'composer.readAttachment', id, 'attached')).rejects.toThrow('25 MiB');
|
||||
expect(handle.readAttachment).not.toHaveBeenCalled();
|
||||
size = 1;
|
||||
await expect(call(reader, 'composer.readAttachment', id, 'attached')).rejects.toThrow('25 MiB');
|
||||
expect(handle.readAttachment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads the open message with compose:read only', async () => {
|
||||
const { id } = registerComposer(fakeHandle());
|
||||
expect(await call(READ, 'composer.getBody', id)).toEqual({ html: '<p>Hello</p>', text: 'Hello' });
|
||||
@@ -92,6 +173,9 @@ describe('api.composer', () => {
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { text: 'a', html: '<p>a</p>' })).rejects.toThrow(/either text or html/);
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { markdown: '*a*' })).rejects.toThrow(/unknown property "markdown"/);
|
||||
await expect(call(WRITE, 'composer.replaceSelection', id, { text: 'a' }, { at: 'end' })).rejects.toThrow(/unknown property "at"/);
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { text: 'a' }, { revision: 'unknown' })).rejects.toThrow(/unknown property "revision"/);
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { text: 'a' }, { expectedRevision: '' })).rejects.toThrow(/expectedRevision/);
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { text: 'a' }, { expectedRevision: 1 })).rejects.toThrow(/expectedRevision/);
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { text: 'x'.repeat(512 * 1024 + 1) })).rejects.toThrow(/at most/);
|
||||
});
|
||||
|
||||
|
||||
@@ -146,6 +146,26 @@ describe('plugin sandbox runtime host gate (GHSA-96cx-gx36-3g79)', () => {
|
||||
expect(postedTypes().some(([type]) => type === 'init-done')).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards composer reads and the conditional edit revision through RPC', async () => {
|
||||
framed = true;
|
||||
await mountRuntime();
|
||||
const message = initMessage();
|
||||
message.payload.code = 'module.exports = { activate: async (api) => { const draft = await api.composer.getSnapshot("composer"); await api.composer.readAttachment("composer", "attachment"); await api.composer.replaceBody("composer", { text: "Edited" }, { expectedRevision: draft.revision }); } };';
|
||||
post(hostWindow, HOST_ORIGIN, message);
|
||||
await flush();
|
||||
const requests = () => hostWindow.postMessage.mock.calls.map(([msg]) => msg).filter((msg) => msg.type === 'api-request');
|
||||
expect(requests()[0]).toMatchObject({ method: 'composer.getSnapshot', args: ['composer'] });
|
||||
post(hostWindow, HOST_ORIGIN, { type: 'api-response', id: requests()[0].id, ok: true, result: { revision: 'revision-one' } });
|
||||
await flush();
|
||||
expect(requests()[1]).toMatchObject({ method: 'composer.readAttachment', args: ['composer', 'attachment'] });
|
||||
post(hostWindow, HOST_ORIGIN, { type: 'api-response', id: requests()[1].id, ok: true, result: { data: new ArrayBuffer(1) } });
|
||||
await flush();
|
||||
expect(requests()[2]).toMatchObject({ method: 'composer.replaceBody', args: ['composer', { text: 'Edited' }, { expectedRevision: 'revision-one' }] });
|
||||
post(hostWindow, HOST_ORIGIN, { type: 'api-response', id: requests()[2].id, ok: true, result: true });
|
||||
await flush();
|
||||
expect(postedTypes().some(([type]) => type === 'init-done')).toBe(true);
|
||||
});
|
||||
|
||||
it('boots the bundle for the framing window on its own origin and reports back to it', async () => {
|
||||
framed = true;
|
||||
await mountRuntime();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// at, never one it found by itself.
|
||||
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import type { ComposerSnapshot, ComposerAttachmentData } from '@/lib/plugin-types';
|
||||
|
||||
export interface ComposerContent {
|
||||
html: string;
|
||||
@@ -20,7 +21,23 @@ export interface ComposerAttachment {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type ComposerMetadata = {
|
||||
subject: string;
|
||||
to: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
pending?: boolean;
|
||||
attachments: ComposerSnapshot['attachments'];
|
||||
accountKey?: string;
|
||||
identityId?: string;
|
||||
};
|
||||
|
||||
export interface ComposerHandle {
|
||||
/** Read this composer's metadata; account and identity stay host-side.
|
||||
* 이 작성창의 메타데이터만 읽으며 계정과 identity는 호스트에 남습니다. */
|
||||
readMetadata?(): ComposerMetadata;
|
||||
/** Read a file currently attached to this composer. 현재 첨부된 파일만 읽습니다. */
|
||||
readAttachment?(id: string): Promise<ComposerAttachmentData>;
|
||||
/** The user's own text, without the signature and the quote. */
|
||||
readBody(): ComposerContent;
|
||||
readSelection(): ComposerContent & { empty: boolean };
|
||||
@@ -33,12 +50,38 @@ export interface ComposerHandle {
|
||||
}
|
||||
|
||||
const handles = new Map<string, ComposerHandle>();
|
||||
const snapshots = new Map<string, { fingerprint: string; revision: string }>();
|
||||
|
||||
/** Bind an opaque revision to the current body, recipients, files and account.
|
||||
* 현재 본문·수신인·첨부·계정에 불투명 리비전을 결합합니다. */
|
||||
export function captureComposer(id: string): ComposerSnapshot {
|
||||
const handle = handles.get(id);
|
||||
if (!handle) throw new Error('composer is not open any more');
|
||||
const body = handle.readBody();
|
||||
const metadata = handle.readMetadata?.() ?? { subject: '', to: [], attachments: [] };
|
||||
const fingerprint = JSON.stringify({ body, metadata });
|
||||
let current = snapshots.get(id);
|
||||
if (!current || current.fingerprint !== fingerprint) {
|
||||
current = { fingerprint, revision: generateUUID() };
|
||||
snapshots.set(id, current);
|
||||
}
|
||||
return {
|
||||
revision: current.revision,
|
||||
subject: metadata.subject,
|
||||
text: body.text,
|
||||
to: [...metadata.to],
|
||||
cc: [...(metadata.cc ?? [])],
|
||||
bcc: [...(metadata.bcc ?? [])],
|
||||
attachments: metadata.attachments.map(file => ({ ...file })),
|
||||
pending: metadata.pending ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Register an open composer. Returns its id and a function that unregisters it. */
|
||||
export function registerComposer(handle: ComposerHandle): { id: string; unregister: () => void } {
|
||||
const id = generateUUID();
|
||||
handles.set(id, handle);
|
||||
return { id, unregister: () => { handles.delete(id); } };
|
||||
return { id, unregister: () => { handles.delete(id); snapshots.delete(id); } };
|
||||
}
|
||||
|
||||
export function composerHandle(id: unknown): ComposerHandle | null {
|
||||
@@ -48,4 +91,5 @@ export function composerHandle(id: unknown): ComposerHandle | null {
|
||||
/** Test helper. */
|
||||
export function resetComposers(): void {
|
||||
handles.clear();
|
||||
snapshots.clear();
|
||||
}
|
||||
|
||||
@@ -4,21 +4,23 @@
|
||||
// composer-registry.ts).
|
||||
//
|
||||
// composer.getBody(id) compose:read
|
||||
// composer.getSnapshot(id) compose:read
|
||||
// composer.readAttachment(id, attachmentId) email:blob-read
|
||||
// composer.getSelection(id) compose:read
|
||||
// composer.replaceSelection(id, content, opts) compose:write
|
||||
// composer.replaceBody(id, content) compose:write
|
||||
// composer.replaceBody(id, content, opts) compose:write
|
||||
// composer.attach(id, file) email:blob-write
|
||||
//
|
||||
// `content` is { text } or { html }. HTML goes through the same sanitiser as
|
||||
// compose.open bodies. Edits are single editor transactions (one Ctrl+Z) and
|
||||
// leave the signature, the quote and inline images in place.
|
||||
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import type { ComposerAttachmentData, ComposerSnapshot, InstalledPlugin } from '../plugin-types';
|
||||
import { sanitizePluginBodyHtml } from '../email-sanitization';
|
||||
import { htmlToPlainText } from '../html-to-text';
|
||||
import { composerHandle, type ComposerHandle } from './composer-registry';
|
||||
import { captureComposer, composerHandle, type ComposerHandle } from './composer-registry';
|
||||
import { textToEditorHtml } from './composer-edit';
|
||||
import { readBoolean, readId, readObject, readString, withinRate } from './host-api-input';
|
||||
import { MAX_PLUGIN_FETCH_BYTES, readBoolean, readId, readObject, readString, withinRate } from './host-api-input';
|
||||
|
||||
const MAX_CONTENT = 512 * 1024;
|
||||
const EDITS_PER_MINUTE = 60;
|
||||
@@ -64,14 +66,46 @@ export function doComposerReplaceSelection(plugin: InstalledPlugin, id: unknown,
|
||||
return handle.replaceSelection(html, text, { below });
|
||||
}
|
||||
|
||||
export function doComposerReplaceBody(plugin: InstalledPlugin, id: unknown, content: unknown): boolean {
|
||||
/** Optionally reject a stale revision before the synchronous editor transaction.
|
||||
* 선택적으로 오래된 리비전을 거부한 뒤 동기 편집 트랜잭션을 실행합니다. */
|
||||
export function doComposerReplaceBody(plugin: InstalledPlugin, id: unknown, content: unknown, options?: unknown): boolean {
|
||||
const method = 'composer.replaceBody';
|
||||
const handle = handleFor(method, id);
|
||||
const { html, text } = readContent(method, content);
|
||||
const opts = readObject(options, method, 'options', ['expectedRevision'], true);
|
||||
if (opts.expectedRevision !== undefined) {
|
||||
const expected = readId(opts.expectedRevision, method, 'options.expectedRevision');
|
||||
if (captureComposer(readId(id, method, 'composerId')).revision !== expected) throw new Error('composer_changed');
|
||||
}
|
||||
rateLimited(method, plugin);
|
||||
return handle.replaceBody(html, text);
|
||||
}
|
||||
|
||||
/** Read a coherent draft snapshot without granting permission to send it.
|
||||
* 발송 권한 없이 일관된 작성창 스냅샷을 읽습니다. */
|
||||
export function doComposerGetSnapshot(_plugin: InstalledPlugin, id: unknown): ComposerSnapshot {
|
||||
const composerId = readId(id, 'composer.getSnapshot', 'composerId');
|
||||
handleFor('composer.getSnapshot', composerId);
|
||||
return captureComposer(composerId);
|
||||
}
|
||||
|
||||
/** Bound bytes and membership before and after reading a composer's attachment.
|
||||
* 작성창 첨부를 읽기 전후에 바이트 크기와 소속을 확인합니다. */
|
||||
export async function doComposerReadAttachment(_plugin: InstalledPlugin, id: unknown, attachmentId: unknown): Promise<ComposerAttachmentData> {
|
||||
const method = 'composer.readAttachment';
|
||||
const composerId = readId(id, method, 'composerId');
|
||||
const fileId = readId(attachmentId, method, 'attachmentId');
|
||||
const handle = handleFor(method, composerId);
|
||||
const snapshot = captureComposer(composerId);
|
||||
const file = snapshot.attachments.find(item => item.id === fileId);
|
||||
if (!file || !handle.readAttachment) throw new Error('attachment_not_in_composer');
|
||||
if (file.size > MAX_PLUGIN_FETCH_BYTES) throw new Error(`${method}: attachment exceeds 25 MiB`);
|
||||
const result = await handle.readAttachment(fileId);
|
||||
if (composerHandle(composerId) !== handle || captureComposer(composerId).revision !== snapshot.revision) throw new Error('composer_changed');
|
||||
if (result.data.byteLength > MAX_PLUGIN_FETCH_BYTES) throw new Error(`${method}: attachment exceeds 25 MiB`);
|
||||
return { name: file.name, type: file.type, size: result.data.byteLength, data: result.data };
|
||||
}
|
||||
|
||||
export async function doComposerAttach(plugin: InstalledPlugin, id: unknown, file: unknown): Promise<boolean> {
|
||||
const method = 'composer.attach';
|
||||
const handle = handleFor(method, id);
|
||||
|
||||
@@ -70,7 +70,7 @@ import { setSectionTitle } from './section-titles';
|
||||
import { requestPanel } from './panel-requests';
|
||||
import { requestPageNavigation } from './page-navigation';
|
||||
import { requestOpenEmail } from './open-email';
|
||||
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
|
||||
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerGetSnapshot, doComposerReadAttachment, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
|
||||
import { wkdAllowed } from '../outgoing-hosts';
|
||||
import { noThirdPartyTraffic } from '../third-party-mode';
|
||||
import { openExternalLink } from '../plugin-hook-result';
|
||||
@@ -318,6 +318,8 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
'notifications.show': 'ui:notifications',
|
||||
// The message being written, in a composer that handed the plugin its id.
|
||||
'composer.getBody': 'compose:read',
|
||||
'composer.getSnapshot': 'compose:read',
|
||||
'composer.readAttachment': 'email:blob-read',
|
||||
'composer.getSelection': 'compose:read',
|
||||
'composer.replaceSelection': 'compose:write',
|
||||
'composer.replaceBody': 'compose:write',
|
||||
@@ -2351,9 +2353,11 @@ export async function dispatchApiCall(
|
||||
case 'notifications.show': return doNotificationsShow(plugin, args[0]);
|
||||
|
||||
case 'composer.getBody': return doComposerGetBody(plugin, args[0]);
|
||||
case 'composer.getSnapshot': return doComposerGetSnapshot(plugin, args[0]);
|
||||
case 'composer.readAttachment': return doComposerReadAttachment(plugin, args[0], args[1]);
|
||||
case 'composer.getSelection': return doComposerGetSelection(plugin, args[0]);
|
||||
case 'composer.replaceSelection': return doComposerReplaceSelection(plugin, args[0], args[1], args[2]);
|
||||
case 'composer.replaceBody': return doComposerReplaceBody(plugin, args[0], args[1]);
|
||||
case 'composer.replaceBody': return doComposerReplaceBody(plugin, args[0], args[1], args[2]);
|
||||
case 'composer.attach': return doComposerAttach(plugin, args[0], args[1]);
|
||||
|
||||
default:
|
||||
|
||||
@@ -35,7 +35,7 @@ import { isFillableSlot, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layo
|
||||
import type {
|
||||
AppInfoView, AppSettingsView, HostFeaturesView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailForwardInput, EmailForwardResult, EmailReadView,
|
||||
EmailSummary, IdentityPatch, IdentityView, MailboxView, NotificationInput, SlotName, TaskInput,
|
||||
TaskView, TemplateInput, TemplateView, VacationInput, VacationView,
|
||||
TaskView, TemplateInput, TemplateView, VacationInput, VacationView, ComposerSnapshot, ComposerAttachmentData,
|
||||
} from '../plugin-types';
|
||||
import { AddressBook, ContactCard } from '../jmap/types';
|
||||
import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store';
|
||||
@@ -619,6 +619,12 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
// The message being written. `composerId` comes from the composer: the
|
||||
// context of a click on a composer action, or a composer slot's props.
|
||||
composer: {
|
||||
/** Capture own text and metadata for a conditional edit. Needs compose:read.
|
||||
* 조건부 편집용 본문·메타데이터를 읽습니다. compose:read 권한이 필요합니다. */
|
||||
getSnapshot: (composerId: string) => callApi('composer.getSnapshot', [composerId]) as Promise<ComposerSnapshot>,
|
||||
/** Read a currently attached file. Needs email:blob-read.
|
||||
* 현재 첨부된 파일을 읽습니다. email:blob-read 권한이 필요합니다. */
|
||||
readAttachment: (composerId: string, attachmentId: string) => callApi('composer.readAttachment', [composerId, attachmentId]) as Promise<ComposerAttachmentData>,
|
||||
/** The user's own text (no signature, no quote) as { html, text }. Needs compose:read. */
|
||||
getBody: (composerId: string) =>
|
||||
callApi('composer.getBody', [composerId]) as Promise<{ html: string; text: string }>,
|
||||
@@ -632,9 +638,10 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
*/
|
||||
replaceSelection: (composerId: string, content: { text: string } | { html: string }, options?: { below?: boolean }) =>
|
||||
callApi('composer.replaceSelection', [composerId, content, options]) as Promise<boolean>,
|
||||
/** Replace the user's own text; the signature and the quote stay. Needs compose:write. */
|
||||
replaceBody: (composerId: string, content: { text: string } | { html: string }) =>
|
||||
callApi('composer.replaceBody', [composerId, content]) as Promise<boolean>,
|
||||
/** Replace own text, optionally only at expectedRevision; signature and quote stay. Needs compose:write.
|
||||
* 선택적으로 expectedRevision을 확인한 뒤 본문만 수정하며 서명·인용은 보존합니다. compose:write 권한이 필요합니다. */
|
||||
replaceBody: (composerId: string, content: { text: string } | { html: string }, options?: { expectedRevision: string }) =>
|
||||
callApi('composer.replaceBody', [composerId, content, options]) as Promise<boolean>,
|
||||
/** Attach a blob from jmap.uploadBlob. Needs email:blob-write. */
|
||||
attach: (composerId: string, file: { blobId: string; name: string; type?: string; size: number }) =>
|
||||
callApi('composer.attach', [composerId, file]) as Promise<boolean>,
|
||||
|
||||
+23
-1
@@ -1344,6 +1344,28 @@ export interface PluginAttachmentUpload {
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** Own text and metadata of one open composer, with an opaque edit revision.
|
||||
* 열린 작성창 하나의 본문·메타데이터와 조건부 편집용 불투명 리비전입니다. */
|
||||
export type ComposerSnapshot = {
|
||||
revision: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
attachments: { id: string; name: string; type: string; size: number }[];
|
||||
pending: boolean;
|
||||
};
|
||||
|
||||
/** Bytes of a file still attached to the selected composer.
|
||||
* 선택한 작성창에 계속 첨부되어 있는 파일의 바이트입니다. */
|
||||
export type ComposerAttachmentData = {
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
data: ArrayBuffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initial value passed to the onAttachmentPreview transform hook. A handler
|
||||
* may return a different `previewUrl` (e.g. a proxied/sanitised URL) or a
|
||||
@@ -1529,7 +1551,7 @@ export const ALL_PERMISSIONS = [
|
||||
// Open the composer filled in (api.compose.open). Low risk: the user still
|
||||
// has to press Send.
|
||||
'compose:open',
|
||||
// Read the message being written (api.composer.getBody/getSelection), in
|
||||
// Read the message being written (api.composer.getBody/getSelection/getSnapshot), in
|
||||
// a composer that handed the plugin its id.
|
||||
'compose:read',
|
||||
// Change the message being written (api.composer.replaceSelection/replaceBody).
|
||||
|
||||
Reference in New Issue
Block a user