mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
185 lines
7.7 KiB
TypeScript
185 lines
7.7 KiB
TypeScript
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)
|
|
? `<p ${SIGNATURE_RANGE_MARKER}="separator">-- </p>`
|
|
: `<p ${SIGNATURE_RANGE_MARKER}="start"></p>`;
|
|
const endMarker = `<p ${SIGNATURE_RANGE_MARKER}="end"></p>`;
|
|
if (identity?.htmlSignature) {
|
|
return `${startMarker}${buildSignatureBlock(sanitizeSignatureHtml(identity.htmlSignature))}${endMarker}`;
|
|
}
|
|
if (identity?.textSignature) {
|
|
const escaped = identity.textSignature
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\n/g, '<br>');
|
|
return `${startMarker}<p>${escaped}</p>${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 legacy <blockquote> or 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;
|
|
}
|
|
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
|
|
toRemove.forEach((node) => parent.removeChild(node));
|
|
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 before the quote, or at the end when
|
|
* there is no quote, which is where getInitialBody would have put it.
|
|
*/
|
|
export function replaceEmbeddedSignature(html: string, signatureHtml: string): string {
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
let removed = removeSignatureRange(doc);
|
|
if (!removed) {
|
|
if (!signatureHtml) return html;
|
|
const quote = Array.from(doc.body.childNodes).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);
|
|
}
|
|
|
|
/**
|
|
* 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, <br>
|
|
* 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 || '<p></p>'}${embedded}`;
|
|
}
|