// Host side of api.email.forward: a plugin forwards a message of the account, // with its attachments, without opening the composer. The e-invoice plugin // uses it to send an invoice to the accounting address in one click. // // Sending in the user's name is the risky part, so: // - the first forward to an address asks the user in a dialog the host // draws (naming the plugin, the message and the address); "Allow" is // remembered for that plugin, address and account, and Settings > Plugins // lists and removes what was allowed; // - the forward goes through the same pre-send checks and finalisers as a // quick reply (external recipients, the organisation's disclaimer) and // other plugins' onBeforeEmailSend, and is held for the user's undo delay; // - the copy lands in Sent and the original is marked $forwarded, as after // a forward from the composer. import type { EmailForwardResult, InstalledPlugin, OutgoingEmail } from '../plugin-types'; import type { Attachment, Email } from '../jmap/types'; import { useIdentityStore } from '@/stores/identity-store'; import { useEmailStore } from '@/stores/email-store'; import { useSettingsStore } from '@/stores/settings-store'; import { useLocaleStore } from '@/stores/locale-store'; import { detectBrowserLocale } from '@/i18n/detect-locale'; import { emailHooks } from '../plugin-hooks'; import { announceSent } from '../plugin-after-send'; import { buildForwardSubject } from '../subject-prefix'; import { finalizeOutgoing, runPreSendChecks } from '../outgoing/send-pipeline'; // Registers the organisation signature check and the disclaimer. import '@/lib/org/outgoing'; import { plainTextFromEmail } from '../plugin-projection'; import { getByPath } from '../settings-search'; import { readNamedEmail } from './host-api-mail'; import { messagesFor } from './host-api-app'; import { approvalAccount, forwardApprovals, saveForwardApprovals } from './forward-approvals'; import { addressOf, readAddressList, readId, readObject, readString, sessionClient, withinRate } from './host-api-input'; /** Most recipients of one forward. */ export const MAX_FORWARD_RECIPIENTS = 10; /** Forwards a plugin may send per minute. */ export const MAX_FORWARDS_PER_MINUTE = 10; const MAX_NOTE = 64 * 1024; const MAX_SELECTED_ATTACHMENTS = 50; // ─── Text in the user's language ────────────────────────────── type Messages = Record; async function hostMessages(): Promise { return messagesFor(useLocaleStore.getState().locale || detectBrowserLocale('en')); } function text(messages: Messages, path: string, vars: Record = {}): string { const value = getByPath(messages, path); const template = typeof value === 'string' ? value : path; return template.replace(/\{(\w+)\}/g, (whole, name: string) => vars[name] ?? whole); } // ─── The forward ────────────────────────────────────────────── export interface ForwardDeps { /** Shows a confirm the host draws for the plugin; resolves to the user's answer. */ confirm: (input: { title: string; message: string; confirmLabel: string; cancelLabel: string }) => Promise; /** Sets keywords on the original the way api.email.updateKeywords does. */ markForwarded: (emailId: string, accountId: string | undefined) => Promise; } function formatAddress(entry: { name?: string | null; email?: string | null } | undefined): string { if (!entry?.email) return ''; return entry.name ? `${entry.name} <${entry.email}>` : entry.email; } function attachmentsOf(email: Email): Attachment[] { return (email.attachments ?? []).filter((a) => !!a.blobId); } /** An inline image of the body, which a forward of the text leaves out. */ function isInline(part: Attachment): boolean { return part.disposition === 'inline' && !!part.cid; } function pickAttachments(email: Email, selection: unknown, method: string): Attachment[] { const all = attachmentsOf(email); if (selection === undefined) return all.filter((a) => !isInline(a)); if (!Array.isArray(selection)) throw new Error(`${method}: attachments must be an array of blobIds`); if (selection.length > MAX_SELECTED_ATTACHMENTS) throw new Error(`${method}: at most ${MAX_SELECTED_ATTACHMENTS} attachments`); return [...new Set(selection.map((id, i) => readId(id, method, `attachments[${i}]`)))].map((blobId) => { const part = all.find((a) => a.blobId === blobId); if (!part) throw new Error(`${method}: ${blobId} is not an attachment of this message`); return part; }); } /** The forwarded message as text: the note, the original's header lines and its text. */ export function forwardBody(email: Email, note: string, messages: Messages, locale: string): string { const label = (key: string) => text(messages, `quote_header.${key}`); const date = email.receivedAt ? new Date(email.receivedAt) : null; const lines = [ label('forwarded_separator'), `${label('from_label')}: ${(email.from ?? []).map(formatAddress).join(', ')}`, `${label('date_label')}: ${date && !Number.isNaN(date.getTime()) ? date.toLocaleString(locale) : ''}`, `${label('subject_label')}: ${email.subject ?? ''}`, ]; const to = (email.to ?? []).map(formatAddress).filter(Boolean).join(', '); if (to) lines.push(`${text(messages, 'email_viewer.to')}: ${to}`); const head = note.trim() ? `${note.trim()}\n\n` : ''; return `${head}${lines.join('\n')}\n\n${plainTextFromEmail(email)}`; } /** * Forward `emailId` to `input.to` with the original's attachments. Resolves * to what happened; throws on bad input, a missing message or a failed send. */ export async function doEmailForward( plugin: InstalledPlugin, emailIdArg: unknown, inputArg: unknown, deps: ForwardDeps, ): Promise { const method = 'email.forward'; const emailId = readId(emailIdArg, method, 'emailId'); const input = readObject(inputArg, method, 'forward', ['to', 'text', 'attachments', 'accountId']); const to = readAddressList(input.to, method, 'to', MAX_FORWARD_RECIPIENTS); if (to.length === 0) throw new Error(`${method}: to must name at least one address`); const note = input.text === undefined ? '' : readString(input.text, method, 'text', MAX_NOTE); if (!withinRate(method, plugin.id, MAX_FORWARDS_PER_MINUTE)) { throw new Error(`${method}: at most ${MAX_FORWARDS_PER_MINUTE} forwards a minute`); } const client = sessionClient(method); const found = await readNamedEmail(client, emailId, method, input.accountId); if (!found) throw new Error(`${method}: no message with id "${emailId}"`); const { email, accountId: sourceAccountId } = found; const attachments = pickAttachments(email, input.attachments, 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 forward from`); const locale = useLocaleStore.getState().locale || detectBrowserLocale('en'); const messages = await hostMessages(); const subject = buildForwardSubject(email.subject || text(messages, 'email_viewer.no_subject'), text(messages, 'email_composer.prefix.forward')); // Ask before the first forward to an address. const addresses = to.map((entry) => addressOf(entry).toLowerCase()); const account = approvalAccount(); const approved = new Set(forwardApprovals(plugin.id, account)); const unknown = [...new Set(addresses.filter((a) => !approved.has(a)))]; if (unknown.length > 0) { const vars = { plugin: plugin.name || plugin.id, addresses: unknown.join(', '), subject: email.subject || text(messages, 'email_viewer.no_subject') }; const allowed = await deps.confirm({ title: text(messages, 'plugins.forward_approval.title', vars), message: text(messages, 'plugins.forward_approval.message', vars), confirmLabel: text(messages, 'plugins.forward_approval.allow'), cancelLabel: text(messages, 'plugins.dialog.cancel'), }); if (!allowed) return { sent: false, reason: 'declined' }; saveForwardApprovals(plugin.id, account, [...approved, ...unknown]); } const context = { surface: 'plugin-forward' as const, to: addresses, cc: [], bcc: [], fromEmail: identity.email, identityId: identity.id, sender: { name: identity.name, email: identity.email }, mode: 'forward' as const, plainText: true, forwardedSeparator: text(messages, 'quote_header.forwarded_separator'), }; if (!(await runPreSendChecks(context))) return { sent: false, reason: 'stopped' }; // No plugin step in between, as for a quick reply: both stages at once. const body = finalizeOutgoing(finalizeOutgoing({ text: forwardBody(email, note, messages, locale) }, context), context, 'last').text; if (emailHooks.onBeforeEmailSend.size > 0) { const outgoing: OutgoingEmail = { to, cc: [], bcc: [], subject, htmlBody: '', textBody: body, identityId: identity.id, fromEmail: identity.email, attachments: attachments.map((a) => ({ name: a.name || 'attachment', type: a.type || 'application/octet-stream', size: a.size })), }; if ((await emailHooks.onBeforeEmailSend.interceptOutcome(outgoing)) !== 'allowed') return { sent: false, reason: 'stopped' }; } // The attachments are blobs of the account the original is in; a message // of a team or shared mailbox goes out from the user's own account. const sendAccountId = (await client.resolveSendAccountId?.(identity.email)) ?? client.getAccountId(); const fromAccountId = sourceAccountId ?? client.getAccountId(); let blobIds = attachments.map((a) => a.blobId); if (blobIds.length && fromAccountId !== sendAccountId) { const copied = await client.copyBlobs(fromAccountId, blobIds, sendAccountId); blobIds = blobIds.map((id) => { const copy = copied[id]; if (!copy) throw new Error(`${method}: the attachments could not be copied to the account that sends`); return copy; }); } const delaySeconds = useSettingsStore.getState().sendDelaySeconds; const delayedUntil = delaySeconds > 0 && client.hasDelayedSend() ? new Date(Date.now() + delaySeconds * 1000).toISOString() : undefined; const result = await useEmailStore.getState().sendEmail( client, to, subject, body, undefined, undefined, identity.id, identity.email, undefined, identity.name || undefined, undefined, attachments.map((a, i) => ({ blobId: blobIds[i], name: a.name || 'attachment', type: a.type || 'application/octet-stream', size: a.size, disposition: 'attachment' as const, })), undefined, undefined, delayedUntil, ); announceSent(result, { to: addresses, cc: [], bcc: [], subject }); try { await deps.markForwarded(emailId, sourceAccountId); } catch { // The forward went out; a missing $forwarded mark is not worth failing it. } return { sent: true, emailId: result.emailId ?? null, to: addresses, scheduledFor: result.scheduled && result.sendAt ? result.sendAt : null, }; }