Files
webmail/lib/plugin-sandbox/host-api-forward.ts
T

231 lines
11 KiB
TypeScript

// 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<string, unknown>;
async function hostMessages(): Promise<Messages> {
return messagesFor(useLocaleStore.getState().locale || detectBrowserLocale('en'));
}
function text(messages: Messages, path: string, vars: Record<string, string> = {}): 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<boolean>;
/** Sets keywords on the original the way api.email.updateKeywords does. */
markForwarded: (emailId: string, accountId: string | undefined) => Promise<unknown>;
}
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<EmailForwardResult> {
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,
};
}