const ENTITIES: Record<string, string> = {
nbsp: " ",
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
"#39": "'",
rsquo: "’",
lsquo: "‘",
rdquo: "”",
ldquo: "“",
mdash: "—",
ndash: "–",
hellip: "…",
copy: "©",
reg: "®",
trade: "™",
// Same invisible-preheader-padding characters INVISIBLE_CHARS strips, but
// some senders write them as named entities instead of raw bytes. Without
// these mapped, decodeEntities falls through to returning the entity text
// unchanged (there's no "shy"/"zwnj" case to match), so they'd survive as
// literal "­"/"‌" garbage instead of becoming strippable.
shy: "\u00AD",
zwnj: "\u200C",
zwj: "\u200D",
lrm: "\u200E",
rlm: "\u200F",
};
function decodeEntities(text: string): string {
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z0-9]+);/gi, (match, entity: string) => {
if (entity[0] === "#") {
const code = entity[1] === "x" || entity[1] === "X" ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
return Number.isFinite(code) ? String.fromCodePoint(code) : match;
}
return ENTITIES[entity.toLowerCase()] ?? match;
});
}