import { sanitizeSignatureHtml } from "@/lib/email-sanitization"; import { htmlToPlainText } from "@/lib/html-to-text"; import { plainTextToComposerBody } from "@/lib/email-composer-utils"; import { appendPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature, } from "@/lib/signature-utils"; import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; import { buildSignatureBlock, SIGNATURE_RANGE_MARKER } from "@/components/email/signature-block"; /** * Helpers for the composer's per-message HTML <-> plain-text switch (#1022). * * Both directions preserve the one invariant the send and draft-save paths * rely on: whether the body *carries* the signature. An HTML body carries it * as a marker-bracketed range (`containsEmbeddedSignature`), a plain-text * body by ending with the signature text (`plainTextBodyHasSignature`). The * converters strip the signature in the source format and re-append it in * the target format, so a switch never produces a doubled or lost signature. */ export type SignatureIdentityLike = { htmlSignature?: string; textSignature?: string; } | null | undefined; // Render the embedded signature. Bracketed with `data-signature-block` marker // paragraphs so we can swap the inner content when the user switches identity // without losing the surrounding draft or quoted message. The markers are // preserved through TipTap by the StyledParagraph extension. The HTML // signature itself is wrapped in a SignatureBlock atom node so its inline // styling survives the editor (see signature-block.ts) instead of being // flattened by the schema. export function buildEmbeddedSignatureHtml( identity: SignatureIdentityLike, options: { embed: boolean; separator: boolean } ): string { if (!options.embed) return ''; const startMarker = wantsSignatureSeparator(options.separator, identity) ? `
--
` : ``; const endMarker = ``; if (identity?.htmlSignature) { return `${startMarker}${buildSignatureBlock(sanitizeSignatureHtml(identity.htmlSignature))}${endMarker}`; } if (identity?.textSignature) { const escaped = identity.textSignature .replace(/&/g, '&') .replace(//g, '>') .replace(/\n/g, '${escaped}
${endMarker}`; } return ''; } function isQuoteBoundary(node: Node | null): boolean { if (!node || node.nodeType !== 1) return false; const el = node as Element; return el.tagName === 'BLOCKQUOTE' || el.hasAttribute('data-quoted-html'); } /** * Remove the embedded signature range [start/separator marker … end marker] * from a parsed body. Without an end marker the removal stops before the next * quote boundary (a legacyor the QuotedHtml island) so it never * eats into the quoted message. Returns the removed nodes' former position so * a caller can splice a replacement in, or null when the body has no range. */ export function removeSignatureRange( doc: Document, ): { parent: Node; insertBefore: Node | null } | null { const startEl = doc.querySelector( `[${SIGNATURE_RANGE_MARKER}="separator"], [${SIGNATURE_RANGE_MARKER}="start"]` ); if (!startEl) return null; const parent = startEl.parentNode; if (!parent) return null; const endEl = doc.querySelector(`[${SIGNATURE_RANGE_MARKER}="end"]`); const removeUntil = endEl && endEl.parentNode === parent ? endEl : null; const toRemove: Node[] = []; let cursor: Node | null = startEl; while (cursor) { toRemove.push(cursor); if (cursor === removeUntil) break; const next: Node | null = cursor.nextSibling; if (!removeUntil && isQuoteBoundary(next)) break; cursor = next; } let insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null; toRemove.forEach((node) => parent.removeChild(node)); // The end marker is the empty line under the signature, so a click below // the signature puts the caret there. Text typed into it is the user's, // not part of the signature: keep it as an ordinary paragraph after it. if (removeUntil && removeUntil.textContent?.trim()) { const kept = removeUntil.cloneNode(true) as Element; kept.removeAttribute(SIGNATURE_RANGE_MARKER); parent.insertBefore(kept, insertBefore); insertBefore = kept; } return { parent, insertBefore }; } /** * The HTML body with its embedded signature range removed, plus whether one * was there. A bare signature atom without range markers (a body whose marker * paragraphs were dropped) is left in place - it has no defined extent. */ export function stripEmbeddedSignature(html: string): { html: string; hadSignature: boolean } { if (!html.includes(SIGNATURE_RANGE_MARKER)) return { html, hadSignature: false }; const doc = new DOMParser().parseFromString(html, 'text/html'); const removed = removeSignatureRange(doc); if (!removed) return { html, hadSignature: false }; return { html: doc.body.innerHTML, hadSignature: true }; } /** * The HTML body with its embedded signature swapped for `signatureHtml` (from * buildEmbeddedSignatureHtml; empty for an identity without a signature). * The new signature goes where the old one was. A body without one - the * previous identity had none - gets it where getInitialBody would have put * it: above the quote header of a reply or forward (`quoteHeader`, the * header's first line, e.g. "On ... wrote:"), else before the quote, else at * the end. */ export function replaceEmbeddedSignature( html: string, signatureHtml: string, options: { quoteHeader?: string } = {}, ): string { const doc = new DOMParser().parseFromString(html, 'text/html'); let removed = removeSignatureRange(doc); if (!removed) { if (!signatureHtml) return html; const children = Array.from(doc.body.childNodes); const header = options.quoteHeader ? children.find((node) => node.textContent?.includes(options.quoteHeader!)) : undefined; const quote = header ?? children.find(isQuoteBoundary) ?? null; removed = { parent: doc.body, insertBefore: quote }; } const host = doc.createElement('div'); host.innerHTML = signatureHtml; Array.from(host.childNodes).forEach((node) => removed.parent.insertBefore(node, removed.insertBefore) ); return doc.body.innerHTML; } /** * The plain-text body with the previous identity's trailing signature swapped * for the next one's. A body that doesn't end with the previous signature was * edited there (or never had one) and is only given the new signature when * the previous identity had none. */ export function replacePlainTextSignature( body: string, previous: SignatureIdentityLike, next: SignatureIdentityLike, options: { separator: boolean }, ): string { const hadSignature = plainTextBodyHasSignature(body, previous); if (!hadSignature && (previous?.htmlSignature || previous?.textSignature)) return body; const bare = hadSignature ? plainTextBodyWithoutSignature(body, previous) : body; return appendPlainTextSignature(bare, next, options); } /** * replacePlainTextSignature for a plain-text reply or forward with the * signature above the quote: the signature ends the part before the quote * header (`quoteHeader`, its first line). A body whose header can't be found * is returned unchanged. */ export function replacePlainTextSignatureAboveQuote( body: string, quoteHeader: string, previous: SignatureIdentityLike, next: SignatureIdentityLike, options: { separator: boolean }, ): string { const at = quoteHeader ? body.indexOf(quoteHeader) : -1; if (at < 0) return body; const head = body.slice(0, at).replace(/\n+$/, ''); return `${replacePlainTextSignature(head, previous, next, options)}\n\n${body.slice(at)}`; } /** * Convert the rich-text editor's body to a plain-text body. Formatting is * dropped (that is the point of plain text); an embedded signature comes back * as the identity's plain-text signature so the body still "carries" it. */ export function htmlComposeBodyToPlainText( html: string, identity: SignatureIdentityLike, options: { separator: boolean }, ): string { const stripped = stripEmbeddedSignature(html); const text = htmlToPlainText(stripped.html, { paragraphSpacing: true }); if (!stripped.hadSignature) return text; return appendPlainTextSignature(text, identity, { separator: options.separator }); } /** * Convert a plain-text body to editor HTML: paragraphs per blank line,
* per newline. A trailing plain-text signature is swapped for the embedded * (marker-bracketed) HTML signature so identity switches and the send path * keep working as in a body that started out as HTML. */ export function plainComposeBodyToHtml( text: string, identity: SignatureIdentityLike, options: { separator: boolean }, ): string { const hadSignature = plainTextBodyHasSignature(text, identity); const userText = hadSignature ? plainTextBodyWithoutSignature(text, identity) : text; const html = plainTextToComposerBody(userText); if (!hadSignature) return html; const embedded = buildEmbeddedSignatureHtml(identity, { embed: true, separator: options.separator }); return `${html || ''}${embedded}`; }