diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 759afa034..e7d7d8c51 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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).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 }; diff --git a/lib/__tests__/plugin-host-compose-drafts.test.ts b/lib/__tests__/plugin-host-compose-drafts.test.ts index b6844e7bf..0bcca4b02 100644 --- a/lib/__tests__/plugin-host-compose-drafts.test.ts +++ b/lib/__tests__/plugin-host-compose-drafts.test.ts @@ -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).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; diff --git a/lib/plugin-sandbox/host-api-compose.ts b/lib/plugin-sandbox/host-api-compose.ts index 981a6a351..25a50a190 100644 --- a/lib/plugin-sandbox/host-api-compose.ts +++ b/lib/plugin-sandbox/host-api-compose.ts @@ -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(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; diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 2fb2c01d8..aa48b2d32 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -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> = { '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]); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 78e5d2fdb..f71f72d4f 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -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, }, // Drafts (permission: email:write). drafts: {