mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
feat: let plugins open a reply to the open message with text to start from
This commit is contained in:
@@ -116,6 +116,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose";
|
||||
import { mailtoComposerDraft } from "./mailto-draft";
|
||||
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
@@ -2268,7 +2269,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t]);
|
||||
|
||||
const handleReplyAll = async () => {
|
||||
const handleReplyAll = async (draftText?: string) => {
|
||||
if (selectedEmail) {
|
||||
const formerSelected = { ...selectedEmail };
|
||||
const enrichedEmail = await emailHooks.onBeforeComposeOpenToReplyAll.transform(selectedEmail);
|
||||
@@ -2286,14 +2287,31 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
await withComposeGate('replyAll', { source: selectedEmail }, () => {
|
||||
const text = typeof draftText === 'string' ? draftText : '';
|
||||
await withComposeGate('replyAll', { source: selectedEmail, ...(text ? { body: text } : {}) }, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerDraftText(text);
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
};
|
||||
|
||||
// A plugin's "reply with this" (api.compose.reply), for the open message only.
|
||||
const pluginReplyRef = useRef({ handleReply, handleReplyAll, openId: selectedEmail?.id ?? null });
|
||||
pluginReplyRef.current = { handleReply, handleReplyAll, openId: selectedEmail?.id ?? null };
|
||||
useEffect(() => {
|
||||
const onPluginReply = (event: Event) => {
|
||||
const req = (event as CustomEvent<PluginReplyRequest>).detail;
|
||||
const { openId, handleReply: reply, handleReplyAll: replyAll } = pluginReplyRef.current;
|
||||
if (!req || !openId || req.emailId !== openId) return;
|
||||
event.preventDefault();
|
||||
void (req.all ? replyAll(req.text) : reply(req.text));
|
||||
};
|
||||
window.addEventListener(PLUGIN_REPLY_EVENT, onPluginReply);
|
||||
return () => window.removeEventListener(PLUGIN_REPLY_EVENT, onPluginReply);
|
||||
}, []);
|
||||
|
||||
const handleForward = async () => {
|
||||
if (selectedEmail) {
|
||||
const formerSelected = { ...selectedEmail };
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { IJMAPClient } from '../jmap/client-interface';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from '../protocol-handlers/mailto';
|
||||
import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from '../plugin-sandbox/host-api-compose';
|
||||
import { consumePendingMailto } from '../protocol-handlers/session';
|
||||
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
|
||||
import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
|
||||
@@ -96,6 +97,39 @@ describe('api.compose.open', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.compose.reply', () => {
|
||||
// The mail page, with message E1 open in the reader.
|
||||
let taken: PluginReplyRequest[];
|
||||
const mailPage = (event: Event) => {
|
||||
const req = (event as CustomEvent<PluginReplyRequest>).detail;
|
||||
if (req.emailId !== 'E1') return;
|
||||
event.preventDefault();
|
||||
taken.push(req);
|
||||
};
|
||||
beforeEach(() => {
|
||||
taken = [];
|
||||
resetPluginRateLimits();
|
||||
window.addEventListener(PLUGIN_REPLY_EVENT, mailPage);
|
||||
});
|
||||
afterEach(() => window.removeEventListener(PLUGIN_REPLY_EVENT, mailPage));
|
||||
|
||||
it('asks the mail page to reply to the open message with the text', async () => {
|
||||
expect(await call(COMPOSER, 'compose.reply', 'E1', { text: 'Thursday works for me.' })).toBe(true);
|
||||
expect(await call(COMPOSER, 'compose.reply', 'E1', { text: 'Fine by all of us.', all: true })).toBe(true);
|
||||
expect(taken).toEqual([
|
||||
{ emailId: 'E1', text: 'Thursday works for me.', all: false },
|
||||
{ emailId: 'E1', text: 'Fine by all of us.', all: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a message that is not open, and needs compose:open', async () => {
|
||||
await expect(call(COMPOSER, 'compose.reply', 'E2', { text: 'x' })).rejects.toThrow(/not open in the reader/);
|
||||
await expect(call(DRAFTER, 'compose.reply', 'E1', { text: 'x' })).rejects.toThrow(/lacks permission "compose:open"/);
|
||||
await expect(call(COMPOSER, 'compose.reply', 'E1', { text: 'x', to: ['eve@example.com'] })).rejects.toThrow(/unknown property "to"/);
|
||||
expect(taken).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.drafts.create', () => {
|
||||
let createDraft: ReturnType<typeof vi.fn>;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { sanitizePluginBodyHtml } from '../email-sanitization';
|
||||
import { htmlToPlainText } from '../html-to-text';
|
||||
import {
|
||||
readAddressList, readBlobRefs, readBoolean, readObject, readString, sessionClient, withinRate,
|
||||
readAddressList, readBlobRefs, readBoolean, readId, readObject, readString, sessionClient, withinRate,
|
||||
type PluginBlobRef,
|
||||
} from './host-api-input';
|
||||
|
||||
@@ -100,6 +100,40 @@ export function doComposeOpen(plugin: InstalledPlugin, input: unknown): 'opened'
|
||||
return 'navigating';
|
||||
}
|
||||
|
||||
// ─── compose.reply ────────────────────────────────────────────
|
||||
|
||||
/** Asks the mail page to reply to the open message (components/mail/mail-app.tsx). */
|
||||
export const PLUGIN_REPLY_EVENT = 'bulwark:plugin-reply';
|
||||
|
||||
export interface PluginReplyRequest {
|
||||
emailId: string;
|
||||
text: string;
|
||||
all: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply (or reply to all) to the message open in the reader, with `text` as
|
||||
* the start of the reply, the way the reader's own quick reply does. Only
|
||||
* the open message: a plugin can't start replies to mail the user isn't
|
||||
* looking at. Resolves to true when the composer opened.
|
||||
*/
|
||||
export function doComposeReply(plugin: InstalledPlugin, emailId: unknown, input: unknown): boolean {
|
||||
const method = 'compose.reply';
|
||||
const id = readId(emailId, method, 'emailId');
|
||||
const data = readObject(input, method, 'reply', ['text', 'all'], true);
|
||||
const text = data.text === undefined ? '' : readString(data.text, method, 'text', MAX_BODY);
|
||||
const all = data.all === undefined ? false : readBoolean(data.all, method, 'all');
|
||||
if (!withinRate('compose', plugin.id, COMPOSE_PER_MINUTE)) {
|
||||
throw new Error(`${method}: at most ${COMPOSE_PER_MINUTE} times a minute`);
|
||||
}
|
||||
const uncancelled = window.dispatchEvent(new CustomEvent<PluginReplyRequest>(PLUGIN_REPLY_EVENT, {
|
||||
detail: { emailId: id, text, all },
|
||||
cancelable: true,
|
||||
}));
|
||||
if (uncancelled) throw new Error(`${method}: the message is not open in the reader`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── drafts.create ────────────────────────────────────────────
|
||||
|
||||
const DRAFTS_PER_MINUTE = 10;
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
doEmailArchive, doEmailGet, doEmailGetAttachment, doEmailGetRaw, doEmailList, doEmailMove, doMailboxesList,
|
||||
} from './host-api-mail';
|
||||
import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input';
|
||||
import { doComposeOpen, doDraftsCreate } from './host-api-compose';
|
||||
import { doComposeOpen, doComposeReply, doDraftsCreate } from './host-api-compose';
|
||||
import { doNotificationsShow } from './host-api-notify';
|
||||
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
|
||||
|
||||
@@ -253,6 +253,8 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
'email.archive': 'email:write',
|
||||
// Open the composer filled in; the user still decides whether to send.
|
||||
'compose.open': 'compose:open',
|
||||
// Reply to the message open in the reader, with text to start from.
|
||||
'compose.reply': 'compose:open',
|
||||
// Save a draft without opening the composer. Nothing is sent.
|
||||
'drafts.create': 'email:write',
|
||||
// A system notification (or a toast), naming the plugin, 5 a minute.
|
||||
@@ -2013,6 +2015,7 @@ export async function dispatchApiCall(
|
||||
case 'email.archive': return doEmailArchive(plugin.id, args[0]);
|
||||
|
||||
case 'compose.open': return doComposeOpen(plugin, args[0]);
|
||||
case 'compose.reply': return doComposeReply(plugin, args[0], args[1]);
|
||||
case 'drafts.create': return doDraftsCreate(plugin, args[0]);
|
||||
|
||||
case 'notifications.show': return doNotificationsShow(plugin, args[0]);
|
||||
|
||||
@@ -604,6 +604,13 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
* At most 5 times a minute.
|
||||
*/
|
||||
open: (message: ComposeInput) => callApi('compose.open', [message]) as Promise<'opened' | 'navigating'>,
|
||||
/**
|
||||
* Reply to the message open in the reader (`all` for reply to all),
|
||||
* starting the reply with `text`. Only the open message; resolves to
|
||||
* true when the composer opened. Shares compose.open's 5 a minute.
|
||||
*/
|
||||
reply: (emailId: string, reply?: { text?: string; all?: boolean }) =>
|
||||
callApi('compose.reply', [emailId, reply]) as Promise<boolean>,
|
||||
},
|
||||
// Drafts (permission: email:write).
|
||||
drafts: {
|
||||
|
||||
Reference in New Issue
Block a user