@handled-ai/design-system 0.20.5 → 0.20.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/components/conversation-panel.d.ts +19 -0
  2. package/dist/components/conversation-panel.js +116 -292
  3. package/dist/components/conversation-panel.js.map +1 -1
  4. package/dist/components/email-body.d.ts +15 -0
  5. package/dist/components/email-body.js +101 -0
  6. package/dist/components/email-body.js.map +1 -0
  7. package/dist/components/email-display-helpers.d.ts +34 -0
  8. package/dist/components/email-display-helpers.js +436 -0
  9. package/dist/components/email-display-helpers.js.map +1 -0
  10. package/dist/components/email-preview-card.d.ts +7 -4
  11. package/dist/components/email-preview-card.js +48 -25
  12. package/dist/components/email-preview-card.js.map +1 -1
  13. package/dist/components/timeline-activity.d.ts +1 -0
  14. package/dist/components/timeline-activity.js +116 -65
  15. package/dist/components/timeline-activity.js.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +2 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/internal/safe-html.d.ts +1 -1
  20. package/dist/internal/safe-html.js +64 -3
  21. package/dist/internal/safe-html.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/components/__tests__/conversation-panel.test.tsx +182 -22
  24. package/src/components/__tests__/email-body.test.tsx +83 -0
  25. package/src/components/__tests__/email-display-helpers.test.ts +91 -0
  26. package/src/components/__tests__/email-preview-card.test.tsx +36 -2
  27. package/src/components/__tests__/timeline-activity.test.tsx +87 -1
  28. package/src/components/conversation-panel.tsx +136 -350
  29. package/src/components/email-body.tsx +126 -0
  30. package/src/components/email-display-helpers.ts +557 -0
  31. package/src/components/email-preview-card.tsx +54 -29
  32. package/src/components/timeline-activity.tsx +105 -63
  33. package/src/index.ts +2 -0
  34. package/src/internal/__tests__/safe-html.test.ts +34 -2
  35. package/src/internal/safe-html.ts +79 -4
@@ -30,6 +30,8 @@ const ALLOWED_TAGS = /* @__PURE__ */ new Set([
30
30
  "s",
31
31
  "span",
32
32
  "strong",
33
+ "sub",
34
+ "sup",
33
35
  "table",
34
36
  "tbody",
35
37
  "td",
@@ -40,7 +42,7 @@ const ALLOWED_TAGS = /* @__PURE__ */ new Set([
40
42
  "ul"
41
43
  ]);
42
44
  const VOID_TAGS = /* @__PURE__ */ new Set(["br", "hr", "img"]);
43
- const SAFE_GLOBAL_ATTRS = /* @__PURE__ */ new Set(["aria-label", "role", "title"]);
45
+ const SAFE_GLOBAL_ATTRS = /* @__PURE__ */ new Set(["aria-label", "dir", "lang", "role", "title"]);
44
46
  const SAFE_URL_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
45
47
  function escapeHtml(value) {
46
48
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -96,15 +98,70 @@ function sanitizeClassName(value) {
96
98
  const safeTokens = value.split(/\s+/).map((token) => token.trim()).filter((token) => /^[A-Za-z0-9_-]+$/.test(token));
97
99
  return safeTokens.length ? safeTokens.join(" ") : null;
98
100
  }
101
+ function sanitizeDimension(value) {
102
+ const trimmed = value.trim();
103
+ if (/^\d{1,4}$/.test(trimmed)) return trimmed;
104
+ return null;
105
+ }
106
+ function sanitizeLanguage(value) {
107
+ const trimmed = value.trim();
108
+ if (/^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$/.test(trimmed)) return trimmed;
109
+ return null;
110
+ }
111
+ function sanitizeDirection(value) {
112
+ const trimmed = value.trim().toLowerCase();
113
+ return trimmed === "ltr" || trimmed === "rtl" || trimmed === "auto" ? trimmed : null;
114
+ }
115
+ function sanitizeFontSize(value) {
116
+ const trimmed = value.trim().toLowerCase();
117
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)(px|em|rem|%)$/);
118
+ if (!match) return null;
119
+ const amount = Number.parseFloat(match[1]);
120
+ const unit = match[2];
121
+ const maxByUnit = { px: 72, em: 4, rem: 4, "%": 400 };
122
+ if (!Number.isFinite(amount) || amount <= 0 || amount > maxByUnit[unit]) return null;
123
+ return `${amount}${unit}`;
124
+ }
125
+ function sanitizeStyle(value) {
126
+ const declarations = [];
127
+ for (const rawDeclaration of value.split(";")) {
128
+ const separatorIndex = rawDeclaration.indexOf(":");
129
+ if (separatorIndex === -1) continue;
130
+ const property = rawDeclaration.slice(0, separatorIndex).trim().toLowerCase();
131
+ const rawValue = decodeHtmlEntities(rawDeclaration.slice(separatorIndex + 1)).trim().toLowerCase();
132
+ if (!property || !rawValue || /url\s*\(|expression\s*\(/i.test(rawValue)) continue;
133
+ if (property === "vertical-align" && (rawValue === "super" || rawValue === "sub")) {
134
+ declarations.push(`vertical-align: ${rawValue}`);
135
+ continue;
136
+ }
137
+ if (property === "font-size") {
138
+ const fontSize = sanitizeFontSize(rawValue);
139
+ if (fontSize) declarations.push(`font-size: ${fontSize}`);
140
+ }
141
+ }
142
+ return declarations.length ? declarations.join("; ") : null;
143
+ }
99
144
  function sanitizeAttribute(tagName, name, value) {
100
145
  const attr = name.toLowerCase();
101
- if (attr.startsWith("on") || attr === "style" || attr === "srcdoc" || attr === "formaction" || attr === "xlink:href" || attr === "xmlns") {
146
+ if (attr.startsWith("on") || attr === "srcdoc" || attr === "formaction" || attr === "xlink:href" || attr === "xmlns") {
102
147
  return null;
103
148
  }
149
+ if (attr === "style") {
150
+ const safeStyle = sanitizeStyle(value);
151
+ return safeStyle ? `style="${escapeAttribute(safeStyle)}"` : null;
152
+ }
104
153
  if (attr === "class") {
105
154
  const safeClassName = sanitizeClassName(value);
106
155
  return safeClassName ? `class="${escapeAttribute(safeClassName)}"` : null;
107
156
  }
157
+ if (attr === "dir") {
158
+ const safeDirection = sanitizeDirection(value);
159
+ return safeDirection ? `dir="${escapeAttribute(safeDirection)}"` : null;
160
+ }
161
+ if (attr === "lang") {
162
+ const safeLanguage = sanitizeLanguage(value);
163
+ return safeLanguage ? `lang="${escapeAttribute(safeLanguage)}"` : null;
164
+ }
108
165
  if (SAFE_GLOBAL_ATTRS.has(attr) || attr.startsWith("aria-")) {
109
166
  return `${attr}="${escapeAttribute(value)}"`;
110
167
  }
@@ -114,9 +171,13 @@ function sanitizeAttribute(tagName, name, value) {
114
171
  if (tagName === "img" && attr === "src" && isSafeUrl(value)) {
115
172
  return `src="${escapeAttribute(value)}"`;
116
173
  }
117
- if (tagName === "img" && (attr === "alt" || attr === "width" || attr === "height")) {
174
+ if (tagName === "img" && attr === "alt") {
118
175
  return `${attr}="${escapeAttribute(value)}"`;
119
176
  }
177
+ if (tagName === "img" && (attr === "width" || attr === "height")) {
178
+ const safeDimension = sanitizeDimension(value);
179
+ return safeDimension ? `${attr}="${escapeAttribute(safeDimension)}"` : null;
180
+ }
120
181
  if ((tagName === "td" || tagName === "th") && (attr === "colspan" || attr === "rowspan")) {
121
182
  return `${attr}="${escapeAttribute(value)}"`;
122
183
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/internal/safe-html.ts"],"sourcesContent":["const DANGEROUS_BLOCK_TAGS = new Set([\n \"script\",\n \"style\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"svg\",\n \"math\",\n \"template\",\n \"noscript\",\n \"textarea\",\n \"select\",\n])\n\nconst ALLOWED_TAGS = new Set([\n \"a\",\n \"b\",\n \"blockquote\",\n \"br\",\n \"code\",\n \"del\",\n \"div\",\n \"em\",\n \"hr\",\n \"i\",\n \"img\",\n \"li\",\n \"ol\",\n \"p\",\n \"pre\",\n \"s\",\n \"span\",\n \"strong\",\n \"table\",\n \"tbody\",\n \"td\",\n \"th\",\n \"thead\",\n \"tr\",\n \"u\",\n \"ul\",\n])\n\nconst VOID_TAGS = new Set([\"br\", \"hr\", \"img\"])\nconst SAFE_GLOBAL_ATTRS = new Set([\"aria-label\", \"role\", \"title\"])\nconst SAFE_URL_PROTOCOLS = new Set([\"http:\", \"https:\", \"mailto:\", \"tel:\"])\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n}\n\nfunction escapeAttribute(value: string): string {\n return escapeHtml(value).replace(/\"/g, \"&quot;\")\n}\n\nfunction safeCodePoint(value: number): string {\n return Number.isInteger(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : \"\"\n}\n\nfunction decodeHtmlEntities(value: string): string {\n const namedEntities: Record<string, string> = {\n amp: \"&\",\n apos: \"'\",\n colon: \":\",\n gt: \">\",\n lt: \"<\",\n newline: \"\\n\",\n quot: '\"',\n tab: \"\\t\",\n }\n\n let decoded = value\n for (let i = 0; i < 4; i += 1) {\n const next = decoded\n .replace(/&#x([0-9a-f]+);?/gi, (_match, hex: string) => {\n const codePoint = Number.parseInt(hex, 16)\n return safeCodePoint(codePoint)\n })\n .replace(/&#(\\d+);?/g, (_match, decimal: string) => {\n const codePoint = Number.parseInt(decimal, 10)\n return safeCodePoint(codePoint)\n })\n .replace(/&([a-z]+);/gi, (match, name: string) => namedEntities[name.toLowerCase()] ?? match)\n\n if (next === decoded) return decoded\n decoded = next\n }\n\n return decoded\n}\n\nfunction isSafeUrl(value: string): boolean {\n const decoded = decodeHtmlEntities(value).replace(/[\\u0000-\\u001f\\u007f\\s]+/g, \"\").trim()\n if (!decoded) return false\n if (decoded.startsWith(\"//\")) return false\n if (decoded.startsWith(\"#\") || decoded.startsWith(\"/\") || decoded.startsWith(\"./\") || decoded.startsWith(\"../\")) {\n return true\n }\n\n try {\n return SAFE_URL_PROTOCOLS.has(new URL(decoded, \"https://handled.local\").protocol)\n } catch {\n return false\n }\n}\n\nfunction sanitizeClassName(value: string): string | null {\n const safeTokens = value\n .split(/\\s+/)\n .map((token) => token.trim())\n .filter((token) => /^[A-Za-z0-9_-]+$/.test(token))\n\n return safeTokens.length ? safeTokens.join(\" \") : null\n}\n\nfunction sanitizeAttribute(tagName: string, name: string, value: string): string | null {\n const attr = name.toLowerCase()\n\n if (\n attr.startsWith(\"on\") ||\n attr === \"style\" ||\n attr === \"srcdoc\" ||\n attr === \"formaction\" ||\n attr === \"xlink:href\" ||\n attr === \"xmlns\"\n ) {\n return null\n }\n\n if (attr === \"class\") {\n const safeClassName = sanitizeClassName(value)\n return safeClassName ? `class=\"${escapeAttribute(safeClassName)}\"` : null\n }\n\n if (SAFE_GLOBAL_ATTRS.has(attr) || attr.startsWith(\"aria-\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"a\" && attr === \"href\" && isSafeUrl(value)) {\n return `href=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && attr === \"src\" && isSafeUrl(value)) {\n return `src=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && (attr === \"alt\" || attr === \"width\" || attr === \"height\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if ((tagName === \"td\" || tagName === \"th\") && (attr === \"colspan\" || attr === \"rowspan\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n return null\n}\n\nfunction sanitizeAttributes(tagName: string, rawAttributes = \"\"): string {\n const attributes: string[] = []\n const attrPattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'=<>`]+)))?/g\n let match: RegExpExecArray | null\n\n while ((match = attrPattern.exec(rawAttributes)) !== null) {\n const [, name, doubleQuotedValue, singleQuotedValue, unquotedValue] = match\n const value = doubleQuotedValue ?? singleQuotedValue ?? unquotedValue ?? \"\"\n const safeAttribute = sanitizeAttribute(tagName, name, value)\n if (safeAttribute) attributes.push(safeAttribute)\n }\n\n if (tagName === \"a\" && attributes.some((attr) => attr.startsWith(\"href=\"))) {\n attributes.push('target=\"_blank\"', 'rel=\"noopener noreferrer\"')\n }\n\n return attributes.length ? ` ${attributes.join(\" \")}` : \"\"\n}\n\nfunction findTagEnd(html: string, startIndex: number): number {\n let quote: '\"' | \"'\" | null = null\n\n for (let i = startIndex + 1; i < html.length; i += 1) {\n const char = html[i]\n if (quote) {\n if (char === quote) quote = null\n continue\n }\n\n if (char === '\"' || char === \"'\") {\n quote = char\n continue\n }\n\n if (char === \">\") return i\n }\n\n return -1\n}\n\nfunction parseTag(rawTag: string): { closing: boolean; name: string; attributes: string } | null {\n const match = rawTag.match(/^<\\s*(\\/)?\\s*([A-Za-z][A-Za-z0-9:-]*)\\b([\\s\\S]*?)\\/?>$/)\n if (!match) return null\n\n return {\n closing: !!match[1],\n name: match[2].toLowerCase(),\n attributes: match[3] ?? \"\",\n }\n}\n\nfunction findDangerousClose(html: string, tagName: string, fromIndex: number): number {\n const closePattern = new RegExp(`</\\\\s*${tagName}\\\\s*>`, \"ig\")\n closePattern.lastIndex = fromIndex\n const match = closePattern.exec(html)\n return match ? closePattern.lastIndex : -1\n}\n\n/**\n * Conservative, deterministic sanitizer for user/email supplied HTML rendered by\n * design-system components. It keeps common email formatting tags while removing\n * executable tags, event handlers, inline styles, and unsafe URLs. This stays\n * dependency-free for the shared package and intentionally favors stripping\n * ambiguous email content over preserving every possible HTML feature.\n */\nexport function sanitizeHtml(html: string): string {\n let output = \"\"\n let cursor = 0\n\n while (cursor < html.length) {\n const tagStart = html.indexOf(\"<\", cursor)\n if (tagStart === -1) {\n output += html.slice(cursor)\n break\n }\n\n output += html.slice(cursor, tagStart)\n\n if (html.startsWith(\"<!--\", tagStart)) {\n const commentEnd = html.indexOf(\"-->\", tagStart + 4)\n cursor = commentEnd === -1 ? html.length : commentEnd + 3\n continue\n }\n\n const tagEnd = findTagEnd(html, tagStart)\n if (tagEnd === -1) {\n output += escapeHtml(html.slice(tagStart))\n break\n }\n\n const rawTag = html.slice(tagStart, tagEnd + 1)\n const parsed = parseTag(rawTag)\n if (!parsed) {\n cursor = tagEnd + 1\n continue\n }\n\n if (DANGEROUS_BLOCK_TAGS.has(parsed.name)) {\n const closeEnd = parsed.closing ? -1 : findDangerousClose(html, parsed.name, tagEnd + 1)\n cursor = closeEnd === -1 ? tagEnd + 1 : closeEnd\n continue\n }\n\n if (ALLOWED_TAGS.has(parsed.name)) {\n if (parsed.closing) {\n if (!VOID_TAGS.has(parsed.name)) output += `</${parsed.name}>`\n } else {\n output += `<${parsed.name}${sanitizeAttributes(parsed.name, parsed.attributes)}>`\n }\n }\n\n cursor = tagEnd + 1\n }\n\n return output\n}\n\nexport function htmlToTextSnippet(html: string, maxLength = 140): string {\n return sanitizeHtml(html)\n .replace(/<[^>]+>/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim()\n .slice(0, maxLength)\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/internal/safe-html.ts"],"sourcesContent":["const DANGEROUS_BLOCK_TAGS = new Set([\n \"script\",\n \"style\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"svg\",\n \"math\",\n \"template\",\n \"noscript\",\n \"textarea\",\n \"select\",\n])\n\nconst ALLOWED_TAGS = new Set([\n \"a\",\n \"b\",\n \"blockquote\",\n \"br\",\n \"code\",\n \"del\",\n \"div\",\n \"em\",\n \"hr\",\n \"i\",\n \"img\",\n \"li\",\n \"ol\",\n \"p\",\n \"pre\",\n \"s\",\n \"span\",\n \"strong\",\n \"sub\",\n \"sup\",\n \"table\",\n \"tbody\",\n \"td\",\n \"th\",\n \"thead\",\n \"tr\",\n \"u\",\n \"ul\",\n])\n\nconst VOID_TAGS = new Set([\"br\", \"hr\", \"img\"])\nconst SAFE_GLOBAL_ATTRS = new Set([\"aria-label\", \"dir\", \"lang\", \"role\", \"title\"])\nconst SAFE_URL_PROTOCOLS = new Set([\"http:\", \"https:\", \"mailto:\", \"tel:\"])\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n}\n\nfunction escapeAttribute(value: string): string {\n return escapeHtml(value).replace(/\"/g, \"&quot;\")\n}\n\nfunction safeCodePoint(value: number): string {\n return Number.isInteger(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : \"\"\n}\n\nfunction decodeHtmlEntities(value: string): string {\n const namedEntities: Record<string, string> = {\n amp: \"&\",\n apos: \"'\",\n colon: \":\",\n gt: \">\",\n lt: \"<\",\n newline: \"\\n\",\n quot: '\"',\n tab: \"\\t\",\n }\n\n let decoded = value\n for (let i = 0; i < 4; i += 1) {\n const next = decoded\n .replace(/&#x([0-9a-f]+);?/gi, (_match, hex: string) => {\n const codePoint = Number.parseInt(hex, 16)\n return safeCodePoint(codePoint)\n })\n .replace(/&#(\\d+);?/g, (_match, decimal: string) => {\n const codePoint = Number.parseInt(decimal, 10)\n return safeCodePoint(codePoint)\n })\n .replace(/&([a-z]+);/gi, (match, name: string) => namedEntities[name.toLowerCase()] ?? match)\n\n if (next === decoded) return decoded\n decoded = next\n }\n\n return decoded\n}\n\nfunction isSafeUrl(value: string): boolean {\n const decoded = decodeHtmlEntities(value).replace(/[\\u0000-\\u001f\\u007f\\s]+/g, \"\").trim()\n if (!decoded) return false\n if (decoded.startsWith(\"//\")) return false\n if (decoded.startsWith(\"#\") || decoded.startsWith(\"/\") || decoded.startsWith(\"./\") || decoded.startsWith(\"../\")) {\n return true\n }\n\n try {\n return SAFE_URL_PROTOCOLS.has(new URL(decoded, \"https://handled.local\").protocol)\n } catch {\n return false\n }\n}\n\nfunction sanitizeClassName(value: string): string | null {\n const safeTokens = value\n .split(/\\s+/)\n .map((token) => token.trim())\n .filter((token) => /^[A-Za-z0-9_-]+$/.test(token))\n\n return safeTokens.length ? safeTokens.join(\" \") : null\n}\n\nfunction sanitizeDimension(value: string): string | null {\n const trimmed = value.trim()\n if (/^\\d{1,4}$/.test(trimmed)) return trimmed\n return null\n}\n\nfunction sanitizeLanguage(value: string): string | null {\n const trimmed = value.trim()\n if (/^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$/.test(trimmed)) return trimmed\n return null\n}\n\nfunction sanitizeDirection(value: string): string | null {\n const trimmed = value.trim().toLowerCase()\n return trimmed === \"ltr\" || trimmed === \"rtl\" || trimmed === \"auto\" ? trimmed : null\n}\n\nfunction sanitizeFontSize(value: string): string | null {\n const trimmed = value.trim().toLowerCase()\n const match = trimmed.match(/^(\\d+(?:\\.\\d+)?)(px|em|rem|%)$/)\n if (!match) return null\n\n const amount = Number.parseFloat(match[1])\n const unit = match[2]\n const maxByUnit: Record<string, number> = { px: 72, em: 4, rem: 4, \"%\": 400 }\n if (!Number.isFinite(amount) || amount <= 0 || amount > maxByUnit[unit]) return null\n return `${amount}${unit}`\n}\n\nfunction sanitizeStyle(value: string): string | null {\n const declarations: string[] = []\n\n for (const rawDeclaration of value.split(\";\")) {\n const separatorIndex = rawDeclaration.indexOf(\":\")\n if (separatorIndex === -1) continue\n\n const property = rawDeclaration.slice(0, separatorIndex).trim().toLowerCase()\n const rawValue = decodeHtmlEntities(rawDeclaration.slice(separatorIndex + 1)).trim().toLowerCase()\n if (!property || !rawValue || /url\\s*\\(|expression\\s*\\(/i.test(rawValue)) continue\n\n if (property === \"vertical-align\" && (rawValue === \"super\" || rawValue === \"sub\")) {\n declarations.push(`vertical-align: ${rawValue}`)\n continue\n }\n\n if (property === \"font-size\") {\n const fontSize = sanitizeFontSize(rawValue)\n if (fontSize) declarations.push(`font-size: ${fontSize}`)\n }\n }\n\n return declarations.length ? declarations.join(\"; \") : null\n}\n\nfunction sanitizeAttribute(tagName: string, name: string, value: string): string | null {\n const attr = name.toLowerCase()\n\n if (\n attr.startsWith(\"on\") ||\n attr === \"srcdoc\" ||\n attr === \"formaction\" ||\n attr === \"xlink:href\" ||\n attr === \"xmlns\"\n ) {\n return null\n }\n\n if (attr === \"style\") {\n const safeStyle = sanitizeStyle(value)\n return safeStyle ? `style=\"${escapeAttribute(safeStyle)}\"` : null\n }\n\n if (attr === \"class\") {\n const safeClassName = sanitizeClassName(value)\n return safeClassName ? `class=\"${escapeAttribute(safeClassName)}\"` : null\n }\n\n if (attr === \"dir\") {\n const safeDirection = sanitizeDirection(value)\n return safeDirection ? `dir=\"${escapeAttribute(safeDirection)}\"` : null\n }\n\n if (attr === \"lang\") {\n const safeLanguage = sanitizeLanguage(value)\n return safeLanguage ? `lang=\"${escapeAttribute(safeLanguage)}\"` : null\n }\n\n if (SAFE_GLOBAL_ATTRS.has(attr) || attr.startsWith(\"aria-\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"a\" && attr === \"href\" && isSafeUrl(value)) {\n return `href=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && attr === \"src\" && isSafeUrl(value)) {\n return `src=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && attr === \"alt\") {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && (attr === \"width\" || attr === \"height\")) {\n const safeDimension = sanitizeDimension(value)\n return safeDimension ? `${attr}=\"${escapeAttribute(safeDimension)}\"` : null\n }\n\n if ((tagName === \"td\" || tagName === \"th\") && (attr === \"colspan\" || attr === \"rowspan\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n return null\n}\n\nfunction sanitizeAttributes(tagName: string, rawAttributes = \"\"): string {\n const attributes: string[] = []\n const attrPattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'=<>`]+)))?/g\n let match: RegExpExecArray | null\n\n while ((match = attrPattern.exec(rawAttributes)) !== null) {\n const [, name, doubleQuotedValue, singleQuotedValue, unquotedValue] = match\n const value = doubleQuotedValue ?? singleQuotedValue ?? unquotedValue ?? \"\"\n const safeAttribute = sanitizeAttribute(tagName, name, value)\n if (safeAttribute) attributes.push(safeAttribute)\n }\n\n if (tagName === \"a\" && attributes.some((attr) => attr.startsWith(\"href=\"))) {\n attributes.push('target=\"_blank\"', 'rel=\"noopener noreferrer\"')\n }\n\n return attributes.length ? ` ${attributes.join(\" \")}` : \"\"\n}\n\nfunction findTagEnd(html: string, startIndex: number): number {\n let quote: '\"' | \"'\" | null = null\n\n for (let i = startIndex + 1; i < html.length; i += 1) {\n const char = html[i]\n if (quote) {\n if (char === quote) quote = null\n continue\n }\n\n if (char === '\"' || char === \"'\") {\n quote = char\n continue\n }\n\n if (char === \">\") return i\n }\n\n return -1\n}\n\nfunction parseTag(rawTag: string): { closing: boolean; name: string; attributes: string } | null {\n const match = rawTag.match(/^<\\s*(\\/)?\\s*([A-Za-z][A-Za-z0-9:-]*)\\b([\\s\\S]*?)\\/?>$/)\n if (!match) return null\n\n return {\n closing: !!match[1],\n name: match[2].toLowerCase(),\n attributes: match[3] ?? \"\",\n }\n}\n\nfunction findDangerousClose(html: string, tagName: string, fromIndex: number): number {\n const closePattern = new RegExp(`</\\\\s*${tagName}\\\\s*>`, \"ig\")\n closePattern.lastIndex = fromIndex\n const match = closePattern.exec(html)\n return match ? closePattern.lastIndex : -1\n}\n\n/**\n * Conservative, deterministic sanitizer for user/email supplied HTML rendered by\n * design-system components. It keeps common email formatting tags while removing\n * executable tags, event handlers, unsafe inline styles, and unsafe URLs. This stays\n * dependency-free for the shared package and intentionally favors stripping\n * ambiguous email content over preserving every possible HTML feature.\n */\nexport function sanitizeHtml(html: string): string {\n let output = \"\"\n let cursor = 0\n\n while (cursor < html.length) {\n const tagStart = html.indexOf(\"<\", cursor)\n if (tagStart === -1) {\n output += html.slice(cursor)\n break\n }\n\n output += html.slice(cursor, tagStart)\n\n if (html.startsWith(\"<!--\", tagStart)) {\n const commentEnd = html.indexOf(\"-->\", tagStart + 4)\n cursor = commentEnd === -1 ? html.length : commentEnd + 3\n continue\n }\n\n const tagEnd = findTagEnd(html, tagStart)\n if (tagEnd === -1) {\n output += escapeHtml(html.slice(tagStart))\n break\n }\n\n const rawTag = html.slice(tagStart, tagEnd + 1)\n const parsed = parseTag(rawTag)\n if (!parsed) {\n cursor = tagEnd + 1\n continue\n }\n\n if (DANGEROUS_BLOCK_TAGS.has(parsed.name)) {\n const closeEnd = parsed.closing ? -1 : findDangerousClose(html, parsed.name, tagEnd + 1)\n cursor = closeEnd === -1 ? tagEnd + 1 : closeEnd\n continue\n }\n\n if (ALLOWED_TAGS.has(parsed.name)) {\n if (parsed.closing) {\n if (!VOID_TAGS.has(parsed.name)) output += `</${parsed.name}>`\n } else {\n output += `<${parsed.name}${sanitizeAttributes(parsed.name, parsed.attributes)}>`\n }\n }\n\n cursor = tagEnd + 1\n }\n\n return output\n}\n\nexport function htmlToTextSnippet(html: string, maxLength = 140): string {\n return sanitizeHtml(html)\n .replace(/<[^>]+>/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim()\n .slice(0, maxLength)\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handled-ai/design-system",
3
- "version": "0.20.5",
3
+ "version": "0.20.7",
4
4
  "description": "Handled UI component library (shadcn-style, New York)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.12.0",
@@ -7,6 +7,18 @@ import {
7
7
  type ConvParticipant,
8
8
  } from "../conversation-panel";
9
9
 
10
+
11
+ function expectNoVisibleEscapeArtifacts(value: string) {
12
+ expect(value).not.toMatch(/&#(?:x[0-9a-f]+|\d+);?/i);
13
+ expect(value).not.toContain("&amp;");
14
+ expect(value).not.toContain("&lt;");
15
+ expect(value).not.toContain("&gt;");
16
+ expect(value).not.toContain("&quot;");
17
+ expect(value).not.toContain('\\"');
18
+ expect(value).not.toContain("\\'");
19
+ expect(value).not.toContain("\\n");
20
+ }
21
+
10
22
  const me: ConvParticipant = { name: "Dana Okafor", email: "dana@handled.ai" };
11
23
  const priya: ConvParticipant = { name: "Priya Raman", email: "priya@northwind.io" };
12
24
 
@@ -196,14 +208,14 @@ describe("ConversationPanel", () => {
196
208
 
197
209
  const message = container.querySelector('[data-slot="conv-message"]')!;
198
210
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("The updated invoice is attached.");
199
- expect(message.querySelector('[data-slot="conv-message-details"]')).toBeNull();
211
+ expect(message.querySelector('[data-slot="email-body-details"]')).toBeNull();
200
212
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).not.toContain("Confidentiality Notice");
201
213
 
202
- fireEvent.click(screen.getByText("Show signature/details"));
214
+ fireEvent.click(screen.getByRole("button", { name: "•••" }));
203
215
 
204
- expect(screen.getByText("Hide signature/details")).toBeDefined();
205
- expect(message.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("Priya Raman");
206
- expect(message.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("Confidentiality Notice");
216
+ expect(screen.getByRole("button", { name: "•••" })).toBeDefined();
217
+ expect(message.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Priya Raman");
218
+ expect(message.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Confidentiality Notice");
207
219
  });
208
220
 
209
221
  it("collapses trailing plain-text signatures while preserving line boundaries", () => {
@@ -224,14 +236,13 @@ describe("ConversationPanel", () => {
224
236
 
225
237
  const message = container.querySelector('[data-slot="conv-message"]')!;
226
238
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("I can meet tomorrow at 2pm.");
227
- expect(message.querySelector('[data-slot="conv-message-details"]')).toBeNull();
239
+ expect(message.querySelector('[data-slot="email-body-details"]')).toBeNull();
228
240
 
229
- fireEvent.click(screen.getByText("Show signature/details"));
241
+ fireEvent.click(screen.getByRole("button", { name: "•••" }));
230
242
 
231
- const details = message.querySelector('[data-slot="conv-message-details"]')!;
243
+ const details = message.querySelector('[data-slot="email-body-details"]')!;
232
244
  expect(details.textContent).toContain("Best,");
233
245
  expect(details.textContent).toContain("VP Sales, Northwind");
234
- expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent?.endsWith("2pm.\n\n")).toBe(true);
235
246
  expect(details.textContent?.startsWith("Best,")).toBe(true);
236
247
  });
237
248
 
@@ -253,7 +264,7 @@ describe("ConversationPanel", () => {
253
264
  const message = container.querySelector('[data-slot="conv-message"]')!;
254
265
 
255
266
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thanks for sending this over");
256
- expect(screen.queryByText("Show signature/details")).toBeNull();
267
+ expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
257
268
  });
258
269
 
259
270
  it("keeps trailing thanks comma sentence body visible", () => {
@@ -274,7 +285,7 @@ describe("ConversationPanel", () => {
274
285
  const message = container.querySelector('[data-slot="conv-message"]')!;
275
286
 
276
287
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thanks, this helps us decide next steps.");
277
- expect(screen.queryByText("Show signature/details")).toBeNull();
288
+ expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
278
289
  });
279
290
 
280
291
  it("keeps trailing thank-you comma sentence body visible", () => {
@@ -295,7 +306,7 @@ describe("ConversationPanel", () => {
295
306
  const message = container.querySelector('[data-slot="conv-message"]')!;
296
307
 
297
308
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thank you, that clarifies the renewal plan.");
298
- expect(screen.queryByText("Show signature/details")).toBeNull();
309
+ expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
299
310
  });
300
311
 
301
312
  it("keeps command-like double-dash plain-text lines visible", () => {
@@ -317,7 +328,7 @@ describe("ConversationPanel", () => {
317
328
 
318
329
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- retry failed imports");
319
330
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- skip archived records");
320
- expect(screen.queryByText("Show signature/details")).toBeNull();
331
+ expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
321
332
  });
322
333
 
323
334
  it("keeps command-like double-dash HTML blocks visible", () => {
@@ -339,7 +350,7 @@ describe("ConversationPanel", () => {
339
350
 
340
351
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- retry failed imports");
341
352
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- skip archived records");
342
- expect(screen.queryByText("Show signature/details")).toBeNull();
353
+ expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
343
354
  });
344
355
 
345
356
  it("collapses standard double-dash signature separator with sender details", () => {
@@ -362,10 +373,10 @@ describe("ConversationPanel", () => {
362
373
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("The import plan looks good.");
363
374
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).not.toContain("Priya Raman");
364
375
 
365
- fireEvent.click(screen.getByText("Show signature/details"));
376
+ fireEvent.click(screen.getByRole("button", { name: "•••" }));
366
377
 
367
- expect(message.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("--");
368
- expect(message.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("Priya Raman");
378
+ expect(message.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("--");
379
+ expect(message.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Priya Raman");
369
380
  });
370
381
 
371
382
  it("does not split raw HTML into invalid partial tags when collapsing details", () => {
@@ -387,14 +398,14 @@ describe("ConversationPanel", () => {
387
398
 
388
399
  const link = container.querySelector('a[href="https://example.com/report"]');
389
400
  expect(link?.textContent).toBe("the full report");
390
- expect(container.querySelector('[data-slot="conv-message-details"]')).toBeNull();
401
+ expect(container.querySelector('[data-slot="email-body-details"]')).toBeNull();
391
402
 
392
403
  const message = container.querySelector('[data-slot="conv-message"]')!;
393
404
  expect(message.querySelector('[data-slot="conv-message-body"]')?.innerHTML).toContain("<strong>the full report</strong>");
394
405
  expect(message.querySelector('[data-slot="conv-message-body"]')?.innerHTML).not.toContain("</a></strong>");
395
406
 
396
- fireEvent.click(screen.getByText("Show signature/details"));
397
- expect(container.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("Priya Raman");
407
+ fireEvent.click(screen.getByRole("button", { name: "•••" }));
408
+ expect(container.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Priya Raman");
398
409
  expect(container.querySelector('[data-slot="conv-message"]')?.querySelectorAll("p").length).toBeGreaterThanOrEqual(4);
399
410
  });
400
411
 
@@ -419,9 +430,9 @@ describe("ConversationPanel", () => {
419
430
 
420
431
  render(<ConversationPanel threads={[quotedThread]} me={me} />);
421
432
 
422
- fireEvent.click(screen.getByText("Show signature/details"));
433
+ fireEvent.click(screen.getByTitle("Show historical details"));
423
434
 
424
- expect(document.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("Priya Raman");
435
+ expect(document.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Priya Raman");
425
436
  expect(screen.queryByText("Prior hidden message")).toBeNull();
426
437
 
427
438
  fireEvent.click(screen.getByTitle("Show quoted text"));
@@ -678,4 +689,153 @@ describe("ConversationPanel", () => {
678
689
  expect(screen.getByText("Local draft preview")).toBeDefined();
679
690
  expect(screen.getByText(/Local draft preview only/i)).toBeDefined();
680
691
  });
692
+
693
+
694
+ it("renders thread messages ascending by raw chronological timestamp even when input is out of order", () => {
695
+ const outOfOrder = thread({
696
+ messages: [
697
+ {
698
+ id: "newer-inbound",
699
+ direction: "inbound",
700
+ from: priya,
701
+ to: me,
702
+ date: "10:00 AM",
703
+ receivedAt: "2026-06-08T10:00:00.000Z",
704
+ body: "Second chronological reply",
705
+ },
706
+ {
707
+ id: "older-outbound",
708
+ direction: "outbound",
709
+ from: me,
710
+ to: priya,
711
+ date: "9:00 AM",
712
+ sentAt: "2026-06-08T09:00:00.000Z",
713
+ body: "First chronological outbound",
714
+ },
715
+ {
716
+ id: "newest-inbound-fallback",
717
+ direction: "inbound",
718
+ from: priya,
719
+ to: me,
720
+ date: "11:00 AM",
721
+ internalDate: "1780916400000",
722
+ body: "Third chronological Gmail fallback",
723
+ },
724
+ ],
725
+ });
726
+
727
+ const { container } = render(<ConversationPanel threads={[outOfOrder]} me={me} />);
728
+ const rows = Array.from(container.querySelectorAll('[data-slot="conv-message-collapsed"], [data-slot="conv-message"]'));
729
+
730
+ expect(rows).toHaveLength(3);
731
+ expect(rows[0].textContent).toContain("First chronological outbound");
732
+ expect(rows[1].textContent).toContain("Second chronological reply");
733
+ expect(rows[2].textContent).toContain("Third chronological Gmail fallback");
734
+ expect(rows[2].getAttribute("data-slot")).toBe("conv-message");
735
+ expect(container.textContent).toContain("Priya Raman · Priya: Third chronological Gmail fallback");
736
+ });
737
+
738
+ it("preserves stable input order when raw timestamps are invalid or missing", () => {
739
+ const invalidTimestampThread = thread({
740
+ messages: [
741
+ {
742
+ id: "display-newer-but-invalid",
743
+ direction: "inbound",
744
+ from: priya,
745
+ to: me,
746
+ date: "2099-01-01T00:00:00.000Z",
747
+ receivedAt: "not-a-date",
748
+ body: "Input order first invalid timestamp",
749
+ },
750
+ {
751
+ id: "missing-raw-timestamp",
752
+ direction: "outbound",
753
+ from: me,
754
+ to: priya,
755
+ date: "Jan 1",
756
+ body: "Input order second missing timestamp",
757
+ },
758
+ {
759
+ id: "display-older-but-invalid",
760
+ direction: "inbound",
761
+ from: priya,
762
+ to: me,
763
+ date: "1999-01-01T00:00:00.000Z",
764
+ rfc822Date: "definitely invalid",
765
+ body: "Input order third invalid timestamp",
766
+ },
767
+ ],
768
+ });
769
+
770
+ const { container } = render(<ConversationPanel threads={[invalidTimestampThread]} me={me} />);
771
+ const rows = Array.from(container.querySelectorAll('[data-slot="conv-message-collapsed"], [data-slot="conv-message"]'));
772
+
773
+ expect(rows).toHaveLength(3);
774
+ expect(rows[0].textContent).toContain("Input order first invalid timestamp");
775
+ expect(rows[1].textContent).toContain("Input order second missing timestamp");
776
+ expect(rows[2].textContent).toContain("Input order third invalid timestamp");
777
+ expect(rows[2].getAttribute("data-slot")).toBe("conv-message");
778
+ });
779
+
780
+ it("uses shared rendering for decoded snippets, full body/signature, one quote expander, and outbound identity", () => {
781
+ const owner: ConvParticipant = { name: "Dana Viewer", email: "viewer@handled.ai" };
782
+ const actualSender: ConvParticipant = { name: "Alex &amp; Sender", email: "alex.sender@example.com" };
783
+ const customer: ConvParticipant = { name: "Jane &amp; Team", email: "jane@example.com" };
784
+ const richThread = thread({
785
+ contact: customer,
786
+ messages: [
787
+ {
788
+ id: "m1",
789
+ direction: "outbound",
790
+ from: actualSender,
791
+ to: customer,
792
+ date: "Jun 8",
793
+ bodyHtml:
794
+ "<p>Hello Jane &amp; team — it&#39;s ready.</p><p>Thanks,</p><p>Alex Sender</p><p>Handled<sup>AI</sup></p>",
795
+ quoted: {
796
+ attr: "On Monday, Jane &amp; Team wrote:",
797
+ html: '<blockquote class="gmail_quote"><p>Prior &amp; decoded history</p></blockquote>',
798
+ },
799
+ },
800
+ {
801
+ id: "m2",
802
+ direction: "inbound",
803
+ from: customer,
804
+ to: actualSender,
805
+ date: "Today",
806
+ body: "Looks good &amp; approved.\n\n--\nJane Team\nCEO\njane@example.com",
807
+ },
808
+ ],
809
+ });
810
+
811
+ const { container } = render(<ConversationPanel threads={[richThread]} me={owner} />);
812
+
813
+ expect(container.textContent).toContain("Jane & Team · Jane: Looks good & approved.");
814
+ expectNoVisibleEscapeArtifacts(container.textContent ?? "");
815
+
816
+ fireEvent.click(container.querySelector('[data-slot="conv-message"] > button')!);
817
+ const collapsedRows = Array.from(container.querySelectorAll('[data-slot="conv-message-collapsed"]'));
818
+ const outboundRow = collapsedRows.find((row) => row.textContent?.includes("Alex · Hello Jane & team — it's ready."));
819
+ expect(outboundRow).toBeTruthy();
820
+ expect(outboundRow?.textContent).not.toContain("Dana Viewer");
821
+
822
+ fireEvent.click(outboundRow!);
823
+ const message = container.querySelector('[data-slot="conv-message"]')!;
824
+ expect(message.textContent).toContain("Alex & Sender");
825
+ expect(message.textContent).not.toContain("Dana Viewer");
826
+ expect(message.textContent).not.toContain("HandledAI");
827
+ expect(screen.getByTitle("Show historical details")).toBeTruthy();
828
+ expect(screen.getAllByRole("button", { name: "•••" }).length).toBe(2);
829
+
830
+ fireEvent.click(screen.getAllByRole("button", { name: "•••" })[0]);
831
+ expect(container.querySelector("sup")?.textContent).toBe("AI");
832
+ expect(message.textContent).toContain("HandledAI");
833
+ expect(screen.queryByText("Prior & decoded history")).toBeNull();
834
+
835
+ fireEvent.click(screen.getByTitle("Show quoted text"));
836
+ expect(screen.getByText("Prior & decoded history")).toBeTruthy();
837
+ expect(screen.getAllByRole("button", { name: "•••" }).length).toBe(2);
838
+ expectNoVisibleEscapeArtifacts(container.textContent ?? "");
839
+ });
840
+
681
841
  });
@@ -0,0 +1,83 @@
1
+ import React from "react"
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react"
3
+ import { afterEach, describe, expect, it } from "vitest"
4
+
5
+ import { EmailBody } from "../email-body"
6
+
7
+ function expectNoVisibleEscapeArtifacts(value: string) {
8
+ expect(value).not.toMatch(/&#(?:x[0-9a-f]+|\d+);?/i)
9
+ expect(value).not.toContain("&amp;")
10
+ expect(value).not.toContain("&lt;")
11
+ expect(value).not.toContain("&gt;")
12
+ expect(value).not.toContain("&quot;")
13
+ expect(value).not.toContain("\\n")
14
+ expect(value).not.toContain('\\"')
15
+ expect(value).not.toContain("\\'")
16
+ }
17
+
18
+ afterEach(() => {
19
+ cleanup()
20
+ })
21
+
22
+ describe("EmailBody", () => {
23
+ it("sanitizes HTML at render time while preserving superscript signature fidelity", () => {
24
+ const { container } = render(
25
+ <EmailBody
26
+ variant="preview"
27
+ html={'<p>Hello &amp; welcome &#39;team&#39;</p><script>alert(1)</script>'}
28
+ detailsHtml={
29
+ '<div class="gmail_signature"><table><tbody><tr><td><img src="https://example.com/logo.png" width="100" height="40" alt="Logo"><sup>TM</sup><span style="vertical-align: super; font-size: 9px; position: fixed">1</span><a href="javascript:alert(1)" onclick="alert(1)">bad</a></td></tr></tbody></table></div>'
30
+ }
31
+ />,
32
+ )
33
+
34
+ expect(screen.getByText("Hello & welcome 'team'")).toBeTruthy()
35
+ expect(container.querySelector("script")).toBeNull()
36
+ expect(container.querySelector("sup")?.textContent).toBe("TM")
37
+ expect(container.querySelector("img")?.getAttribute("width")).toBe("100")
38
+ expect(container.querySelector("span")?.getAttribute("style")).toBe("vertical-align: super; font-size: 9px")
39
+ expect(container.querySelector("a")?.hasAttribute("href")).toBe(false)
40
+ expect(container.innerHTML).not.toContain("onclick")
41
+ expectNoVisibleEscapeArtifacts(container.textContent ?? "")
42
+ })
43
+
44
+ it("collapses details with an ellipsis toggle only when collapseDetails is enabled", () => {
45
+ const { container } = render(
46
+ <EmailBody
47
+ html="<p>Main body</p><p>Thanks,</p><p>Jane Doe</p><p>VP Sales</p>"
48
+ collapseDetails
49
+ />,
50
+ )
51
+
52
+ expect(screen.getByText("Main body")).toBeTruthy()
53
+ expect(screen.queryByText("Jane Doe")).toBeNull()
54
+ expect(screen.getByRole("button", { name: "•••" }).getAttribute("aria-expanded")).toBe("false")
55
+
56
+ fireEvent.click(screen.getByRole("button", { name: "•••" }))
57
+
58
+ expect(container.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Jane Doe")
59
+ expect(container.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("VP Sales")
60
+ expect(screen.getByRole("button", { name: "•••" }).getAttribute("aria-expanded")).toBe("true")
61
+ })
62
+
63
+ it("shows details by default when collapse is disabled", () => {
64
+ render(<EmailBody text="Please review." detailsText="Confidentiality Notice: private." collapseDetails={false} />)
65
+
66
+ expect(screen.queryByRole("button", { name: "•••" })).toBeNull()
67
+ expect(screen.getByText("Confidentiality Notice: private.")).toBeTruthy()
68
+ })
69
+
70
+ it("honors defaultDetailsOpen for collapsed history", () => {
71
+ render(<EmailBody text="Please review.\n\nBest,\nJane Doe\nVP Sales" collapseDetails defaultDetailsOpen />)
72
+
73
+ expect(document.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Jane Doe")
74
+ expect(screen.getByRole("button", { name: "•••" }).getAttribute("aria-expanded")).toBe("true")
75
+ })
76
+
77
+ it("decodes plain text bodies without visible escape artifacts", () => {
78
+ const { container } = render(<EmailBody text={'"Line one &amp; two\\nLine &#39;three&#39;"'} />)
79
+
80
+ expect(container.textContent).toContain("Line one & two\nLine 'three'")
81
+ expectNoVisibleEscapeArtifacts(container.textContent ?? "")
82
+ })
83
+ })