// Host side of api.compose.open and api.drafts.create. compose.open fills // the composer the way an in-app mailto: link does, and the user still has // to press Send; drafts.create saves a draft to the Drafts folder without // opening anything. Neither sends mail. import type { ComposeInput, DraftInput, InstalledPlugin } from '../plugin-types'; import type { ParsedMailto } from '../protocol-handlers/mailto'; import { requestInternalCompose } from '../protocol-handlers/mailto'; import { notifyPendingMailto, savePendingMailto } from '../protocol-handlers/session'; import { getPathPrefix } from '../browser-navigation'; import { locales } from '@/i18n/routing'; import { useIdentityStore } from '@/stores/identity-store'; import { sanitizePluginBodyHtml } from '../email-sanitization'; import { htmlToPlainText } from '../html-to-text'; import { readAddressList, readBlobRefs, readBoolean, readId, readObject, readString, sessionClient, withinRate, type PluginBlobRef, } from './host-api-input'; /** Most recipients (to, cc and bcc together) one call may set. */ export const MAX_PLUGIN_RECIPIENTS = 100; /** Most attachments one call may add. */ export const MAX_PLUGIN_ATTACHMENTS = 20; const MAX_SUBJECT = 998; const MAX_BODY = 512 * 1024; // A blob a plugin uploaded can be as big as the server lets it be; this // only keeps the number sane. const MAX_ATTACHMENT_SIZE = 2 ** 31; interface MessageFields { to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string | undefined; attachments: PluginBlobRef[]; } function readMessage(method: string, data: Record): MessageFields { const to = readAddressList(data.to, method, 'to', MAX_PLUGIN_RECIPIENTS); const cc = readAddressList(data.cc, method, 'cc', MAX_PLUGIN_RECIPIENTS); const bcc = readAddressList(data.bcc, method, 'bcc', MAX_PLUGIN_RECIPIENTS); if (to.length + cc.length + bcc.length > MAX_PLUGIN_RECIPIENTS) { throw new Error(`${method}: at most ${MAX_PLUGIN_RECIPIENTS} recipients in all`); } const subject = data.subject === undefined ? '' : readString(data.subject, method, 'subject', MAX_SUBJECT); // eslint-disable-next-line no-control-regex -- a subject is one header line if (/[\u0000-\u001f\u007f]/.test(subject)) throw new Error(`${method}: subject must be one line`); const body = data.body === undefined ? '' : readString(data.body, method, 'body', MAX_BODY); const isHtml = data.isHtml === undefined ? false : readBoolean(data.isHtml, method, 'isHtml'); const html = isHtml ? sanitizePluginBodyHtml(body) : undefined; return { to, cc, bcc, subject, text: html !== undefined ? htmlToPlainText(html) : body, html, attachments: readBlobRefs(data.attachments, method, MAX_PLUGIN_ATTACHMENTS, MAX_ATTACHMENT_SIZE), }; } // ─── compose.open ───────────────────────────────────────────── const COMPOSE_PER_MINUTE = 5; /** The mail page in the current mount and locale. */ function mailPageHref(): string { const segments = window.location.pathname.split('/').filter(Boolean); const localeIndex = segments.findIndex((s) => (locales as readonly string[]).includes(s)); if (localeIndex >= 0) return '/' + segments.slice(0, localeIndex + 1).join('/') + '/'; return getPathPrefix() + '/'; } /** * Open the composer filled in. On the mail page it opens in place (asking * first when a draft is already open); elsewhere the app goes to the mail * page, which opens it. Resolves to 'opened' or 'navigating'. */ export function doComposeOpen(plugin: InstalledPlugin, input: unknown): 'opened' | 'navigating' { const method = 'compose.open'; const data = readObject(input, method, 'message', ['to', 'cc', 'bcc', 'subject', 'body', 'isHtml', 'attachments'], true) as ComposeInput & Record; const message = readMessage(method, data); if (!withinRate('compose', plugin.id, COMPOSE_PER_MINUTE)) { throw new Error(`${method}: at most ${COMPOSE_PER_MINUTE} times a minute`); } const draft: ParsedMailto = { to: message.to, cc: message.cc, bcc: message.bcc, subject: message.subject, body: message.text, ...(message.html !== undefined ? { htmlBody: message.html } : {}), ...(message.attachments.length ? { attachments: message.attachments } : {}), }; if (requestInternalCompose(draft)) return 'opened'; // No mail page on screen: park the draft where the mail page looks for an // incoming mailto: request when it mounts, and go there. savePendingMailto(draft); notifyPendingMailto(); window.location.assign(mailPageHref()); 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; /** Save a draft in the Drafts folder, from the user's default identity. Resolves to its id. */ export async function doDraftsCreate(plugin: InstalledPlugin, input: unknown): Promise { const method = 'drafts.create'; const data = readObject(input, method, 'draft', ['to', 'cc', 'bcc', 'subject', 'body', 'isHtml', 'attachments'], true) as DraftInput & Record; const message = readMessage(method, data); const client = sessionClient(method); const { identities, preferredPrimaryId } = useIdentityStore.getState(); const identity = identities.find((i) => i.id === preferredPrimaryId) ?? identities[0]; if (!identity) throw new Error(`${method}: the account has no sender address to save the draft from`); if (!withinRate('drafts', plugin.id, DRAFTS_PER_MINUTE)) { throw new Error(`${method}: at most ${DRAFTS_PER_MINUTE} drafts a minute`); } return client.createDraft( message.to, message.subject, message.text, message.cc, message.bcc, identity.id, identity.email, undefined, message.attachments.map((a) => ({ ...a, disposition: 'attachment' as const })), identity.name || undefined, message.html, ); }