// Copyright (c) 2026 Doforu // Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-only). // See the LICENSE file or https://www.gnu.org/licenses/agpl-3.0.txt /** * "Copy the whole email" helpers. * * Why this exists * --------------- * The message body is rendered inside a sandboxed iframe without * `allow-same-origin` (see `app/components/EmailIframe.tsx`), so the body lives * in its own opaque-origin document. Browser selection and Ctrl+A are * per-document, which means: * * - with focus inside the body, Ctrl+A selects the *iframe* document only — * the user gets the message text but never the subject / sender / date; * - with focus outside the body, Ctrl+A selects the parent page but the * iframe's content cannot be reached at all, because a cross-origin * document's DOM is unreadable from the parent. * * There is no keyboard gesture that can span two documents, so "select all the * visible text" is simply not expressible. Instead we assemble the same content * as plain text from the data we already hold and hand it to the clipboard. * * Everything here is client-only (`htmlToPlainText` parses HTML through the * DOM), and the produced text is plain text — never HTML — so pasting it into * another message cannot re-inject markup. */ import { formatSenderWithAddress } from "shared/participants"; import type { Locale } from "shared/i18n/types"; import { formatDetailDate, htmlToPlainText } from "~/lib/utils"; import type { Email } from "~/types"; /** Localized field labels for the header block, supplied by the caller (i18n). */ export interface CopyEmailLabels { subject: string; from: string; to: string; cc: string; date: string; } /** Separator drawn between messages when more than one is copied. */ const MESSAGE_DIVIDER = "----------------------------------------"; /** * Flatten the whitespace an HTML template leaves behind. * * `htmlToPlainText` turns `
` into a blank line and `` into a newline, * so a nested marketing template (divs inside divs inside tables) comes out as * long runs of empty lines and trailing spaces. That reads as broken once * pasted into a document or a reply, so collapse runs of three or more * newlines and drop trailing spaces. Paragraph structure survives; the air * does not. */ function normalizeBody(text: string): string { return text .replace(/\u00a0/g, " ") .replace(/[ \t]+$/gm, "") .replace(/^[ \t]+/gm, "") .replace(/\n{3,}/g, "\n\n") .trim(); } /** * Render one message as a `Label: value` header block followed by its body. * * Header values are inserted verbatim (not escaped): the result is plain text * destined for the clipboard, so there is no markup context to escape for. The * body goes through `htmlToPlainText`, which both sanitises and flattens it. */ function formatMessage(email: Email, labels: CopyEmailLabels, locale?: Locale): string { const lines = [ `${labels.subject}: ${email.subject || ""}`, `${labels.from}: ${formatSenderWithAddress(email.sender_name, email.sender)}`, ]; const recipient = email.recipient?.trim(); if (recipient) lines.push(`${labels.to}: ${recipient}`); const cc = email.cc?.trim(); if (cc) lines.push(`${labels.cc}: ${cc}`); if (email.date) lines.push(`${labels.date}: ${formatDetailDate(email.date, locale)}`); const body = email.body ? normalizeBody(htmlToPlainText(email.body)) : ""; return body ? `${lines.join("\n")}\n\n${body}` : lines.join("\n"); } /** * Build the clipboard payload for a conversation. * * `emails` must already be in display order (newest first in the panel); the * order is preserved as-is so the copied text matches what is on screen. * Returns an empty string when there is nothing to copy. */ export function buildEmailClipboardText( emails: Email[], labels: CopyEmailLabels, locale?: Locale, ): string { const blocks = emails .filter(Boolean) .map((email) => formatMessage(email, labels, locale)) .filter((block) => block.trim().length > 0); return blocks.join(`\n\n${MESSAGE_DIVIDER}\n\n`); } /** * Write text to the clipboard, with a fallback for contexts where the async * Clipboard API is unavailable (insecure origin, older browser, denied * permission). * * The fallback uses a hidden, off-screen `