mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
133 lines
5.9 KiB
TypeScript
133 lines
5.9 KiB
TypeScript
// 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, 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<string, unknown>): 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<string, unknown>;
|
|
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';
|
|
}
|
|
|
|
// ─── 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<string> {
|
|
const method = 'drafts.create';
|
|
const data = readObject(input, method, 'draft', ['to', 'cc', 'bcc', 'subject', 'body', 'isHtml', 'attachments'], true) as DraftInput & Record<string, unknown>;
|
|
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,
|
|
);
|
|
}
|