@handled-ai/design-system 0.20.4 → 0.20.6

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 (34) hide show
  1. package/dist/components/conversation-panel.d.ts +28 -1
  2. package/dist/components/conversation-panel.js +180 -310
  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.js +66 -42
  14. package/dist/components/timeline-activity.js.map +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal/safe-html.d.ts +1 -1
  19. package/dist/internal/safe-html.js +64 -3
  20. package/dist/internal/safe-html.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/components/__tests__/conversation-panel.test.tsx +230 -22
  23. package/src/components/__tests__/email-body.test.tsx +83 -0
  24. package/src/components/__tests__/email-display-helpers.test.ts +91 -0
  25. package/src/components/__tests__/email-preview-card.test.tsx +36 -2
  26. package/src/components/__tests__/timeline-activity.test.tsx +53 -1
  27. package/src/components/conversation-panel.tsx +227 -369
  28. package/src/components/email-body.tsx +126 -0
  29. package/src/components/email-display-helpers.ts +557 -0
  30. package/src/components/email-preview-card.tsx +54 -29
  31. package/src/components/timeline-activity.tsx +73 -53
  32. package/src/index.ts +2 -0
  33. package/src/internal/__tests__/safe-html.test.ts +34 -2
  34. package/src/internal/safe-html.ts +79 -4
@@ -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.4",
3
+ "version": "0.20.6",
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
 
@@ -86,6 +98,54 @@ describe("ConversationPanel", () => {
86
98
  expect(screen.getByText("Draft copy")).toBeDefined();
87
99
  });
88
100
 
101
+
102
+ it("uses the custom read-only reason and disables Open in Gmail when access is unavailable", () => {
103
+ const onOpenInGmail = vi.fn();
104
+
105
+ render(
106
+ <ConversationPanel
107
+ threads={[
108
+ thread({
109
+ canReply: false,
110
+ replyDisabledReason: "Only the case owner can open Gmail drafts for this case.",
111
+ openInGmailDisabled: true,
112
+ openInGmailDisabledReason: "Only the case owner can open this draft in Gmail.",
113
+ }),
114
+ ]}
115
+ me={me}
116
+ onOpenInGmail={onOpenInGmail}
117
+ defaultOpenThreadId="t1"
118
+ />,
119
+ );
120
+
121
+ expect(screen.getByText(/Only the case owner can open Gmail drafts for this case\./)).toBeDefined();
122
+ const button = screen.getByRole("button", {
123
+ name: "Open in Gmail: Only the case owner can open this draft in Gmail.",
124
+ });
125
+ expect(button).toHaveProperty("disabled", true);
126
+
127
+ fireEvent.click(button);
128
+ expect(onOpenInGmail).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("renders Open in Gmail as a new-tab link when a URL is provided", () => {
132
+ render(
133
+ <ConversationPanel
134
+ threads={[
135
+ thread({
136
+ openInGmailUrl: "https://mail.google.com/mail/?authuser=dana%40handled.ai#drafts/msg-1",
137
+ }),
138
+ ]}
139
+ me={me}
140
+ />,
141
+ );
142
+
143
+ const link = screen.getByRole("link", { name: /Open in Gmail/ });
144
+ expect(link.getAttribute("href")).toBe("https://mail.google.com/mail/?authuser=dana%40handled.ai#drafts/msg-1");
145
+ expect(link.getAttribute("target")).toBe("_blank");
146
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
147
+ });
148
+
89
149
  it("auto-opens the first responded thread and renders its newest message as HTML", () => {
90
150
  const { container } = render(<ConversationPanel threads={[thread()]} me={me} />);
91
151
  // newest (inbound) message expanded -> its anchor is in the DOM
@@ -148,14 +208,14 @@ describe("ConversationPanel", () => {
148
208
 
149
209
  const message = container.querySelector('[data-slot="conv-message"]')!;
150
210
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("The updated invoice is attached.");
151
- expect(message.querySelector('[data-slot="conv-message-details"]')).toBeNull();
211
+ expect(message.querySelector('[data-slot="email-body-details"]')).toBeNull();
152
212
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).not.toContain("Confidentiality Notice");
153
213
 
154
- fireEvent.click(screen.getByText("Show signature/details"));
214
+ fireEvent.click(screen.getByRole("button", { name: "•••" }));
155
215
 
156
- expect(screen.getByText("Hide signature/details")).toBeDefined();
157
- expect(message.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("Priya Raman");
158
- 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");
159
219
  });
160
220
 
161
221
  it("collapses trailing plain-text signatures while preserving line boundaries", () => {
@@ -176,14 +236,13 @@ describe("ConversationPanel", () => {
176
236
 
177
237
  const message = container.querySelector('[data-slot="conv-message"]')!;
178
238
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("I can meet tomorrow at 2pm.");
179
- expect(message.querySelector('[data-slot="conv-message-details"]')).toBeNull();
239
+ expect(message.querySelector('[data-slot="email-body-details"]')).toBeNull();
180
240
 
181
- fireEvent.click(screen.getByText("Show signature/details"));
241
+ fireEvent.click(screen.getByRole("button", { name: "•••" }));
182
242
 
183
- const details = message.querySelector('[data-slot="conv-message-details"]')!;
243
+ const details = message.querySelector('[data-slot="email-body-details"]')!;
184
244
  expect(details.textContent).toContain("Best,");
185
245
  expect(details.textContent).toContain("VP Sales, Northwind");
186
- expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent?.endsWith("2pm.\n\n")).toBe(true);
187
246
  expect(details.textContent?.startsWith("Best,")).toBe(true);
188
247
  });
189
248
 
@@ -205,7 +264,7 @@ describe("ConversationPanel", () => {
205
264
  const message = container.querySelector('[data-slot="conv-message"]')!;
206
265
 
207
266
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thanks for sending this over");
208
- expect(screen.queryByText("Show signature/details")).toBeNull();
267
+ expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
209
268
  });
210
269
 
211
270
  it("keeps trailing thanks comma sentence body visible", () => {
@@ -226,7 +285,7 @@ describe("ConversationPanel", () => {
226
285
  const message = container.querySelector('[data-slot="conv-message"]')!;
227
286
 
228
287
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thanks, this helps us decide next steps.");
229
- expect(screen.queryByText("Show signature/details")).toBeNull();
288
+ expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
230
289
  });
231
290
 
232
291
  it("keeps trailing thank-you comma sentence body visible", () => {
@@ -247,7 +306,7 @@ describe("ConversationPanel", () => {
247
306
  const message = container.querySelector('[data-slot="conv-message"]')!;
248
307
 
249
308
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thank you, that clarifies the renewal plan.");
250
- expect(screen.queryByText("Show signature/details")).toBeNull();
309
+ expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
251
310
  });
252
311
 
253
312
  it("keeps command-like double-dash plain-text lines visible", () => {
@@ -269,7 +328,7 @@ describe("ConversationPanel", () => {
269
328
 
270
329
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- retry failed imports");
271
330
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- skip archived records");
272
- expect(screen.queryByText("Show signature/details")).toBeNull();
331
+ expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
273
332
  });
274
333
 
275
334
  it("keeps command-like double-dash HTML blocks visible", () => {
@@ -291,7 +350,7 @@ describe("ConversationPanel", () => {
291
350
 
292
351
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- retry failed imports");
293
352
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- skip archived records");
294
- expect(screen.queryByText("Show signature/details")).toBeNull();
353
+ expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
295
354
  });
296
355
 
297
356
  it("collapses standard double-dash signature separator with sender details", () => {
@@ -314,10 +373,10 @@ describe("ConversationPanel", () => {
314
373
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("The import plan looks good.");
315
374
  expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).not.toContain("Priya Raman");
316
375
 
317
- fireEvent.click(screen.getByText("Show signature/details"));
376
+ fireEvent.click(screen.getByRole("button", { name: "•••" }));
318
377
 
319
- expect(message.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("--");
320
- 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");
321
380
  });
322
381
 
323
382
  it("does not split raw HTML into invalid partial tags when collapsing details", () => {
@@ -339,14 +398,14 @@ describe("ConversationPanel", () => {
339
398
 
340
399
  const link = container.querySelector('a[href="https://example.com/report"]');
341
400
  expect(link?.textContent).toBe("the full report");
342
- expect(container.querySelector('[data-slot="conv-message-details"]')).toBeNull();
401
+ expect(container.querySelector('[data-slot="email-body-details"]')).toBeNull();
343
402
 
344
403
  const message = container.querySelector('[data-slot="conv-message"]')!;
345
404
  expect(message.querySelector('[data-slot="conv-message-body"]')?.innerHTML).toContain("<strong>the full report</strong>");
346
405
  expect(message.querySelector('[data-slot="conv-message-body"]')?.innerHTML).not.toContain("</a></strong>");
347
406
 
348
- fireEvent.click(screen.getByText("Show signature/details"));
349
- 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");
350
409
  expect(container.querySelector('[data-slot="conv-message"]')?.querySelectorAll("p").length).toBeGreaterThanOrEqual(4);
351
410
  });
352
411
 
@@ -371,9 +430,9 @@ describe("ConversationPanel", () => {
371
430
 
372
431
  render(<ConversationPanel threads={[quotedThread]} me={me} />);
373
432
 
374
- fireEvent.click(screen.getByText("Show signature/details"));
433
+ fireEvent.click(screen.getByTitle("Show historical details"));
375
434
 
376
- 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");
377
436
  expect(screen.queryByText("Prior hidden message")).toBeNull();
378
437
 
379
438
  fireEvent.click(screen.getByTitle("Show quoted text"));
@@ -630,4 +689,153 @@ describe("ConversationPanel", () => {
630
689
  expect(screen.getByText("Local draft preview")).toBeDefined();
631
690
  expect(screen.getByText(/Local draft preview only/i)).toBeDefined();
632
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
+
633
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
+ })
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import {
4
+ decodeEmailDisplayText,
5
+ emailBodySnippet,
6
+ formatAddressList,
7
+ formatEmailTimestamp,
8
+ normalizeEmailSender,
9
+ splitEmailHtmlForDisplay,
10
+ splitEmailTextForDisplay,
11
+ } from "../email-display-helpers"
12
+
13
+ function expectNoVisibleEscapeArtifacts(value: string) {
14
+ expect(value).not.toMatch(/&#(?:x[0-9a-f]+|\d+);?/i)
15
+ expect(value).not.toContain("&amp;")
16
+ expect(value).not.toContain("&lt;")
17
+ expect(value).not.toContain("&gt;")
18
+ expect(value).not.toContain("&quot;")
19
+ expect(value).not.toContain("\\n")
20
+ expect(value).not.toContain('\\"')
21
+ expect(value).not.toContain("\\'")
22
+ }
23
+
24
+ describe("email display helpers", () => {
25
+ it("decodes display text without visible escape artifacts", () => {
26
+ const decoded = decodeEmailDisplayText('"Hi Cory &amp; Jane&#39;s team\\nSay \\\"hello\\\""')
27
+
28
+ expect(decoded).toBe('Hi Cory & Jane\'s team\nSay "hello"')
29
+ expectNoVisibleEscapeArtifacts(decoded)
30
+ })
31
+
32
+ it("dedupes combined sender fields into clean name and bare email", () => {
33
+ expect(normalizeEmailSender({ name: "Jane Doe <jane@example.com>", email: "Jane Doe <jane@example.com>" })).toEqual({
34
+ name: "Jane Doe",
35
+ email: "jane@example.com",
36
+ })
37
+
38
+ expect(normalizeEmailSender({ name: "jane@example.com", email: "jane@example.com", fallbackName: "Fallback" })).toEqual({
39
+ name: "jane@example.com",
40
+ email: "jane@example.com",
41
+ })
42
+ })
43
+
44
+ it("formats address lists without inventing names", () => {
45
+ expect(formatAddressList('"Jane Doe" <jane@example.com>, alex@example.com, Support &amp; Success <support@example.com>')).toBe(
46
+ "Jane Doe <jane@example.com>, alex@example.com, Support & Success <support@example.com>",
47
+ )
48
+ })
49
+
50
+ it("formats timestamps deterministically and omits invalid dates", () => {
51
+ expect(formatEmailTimestamp("2026-06-08T20:45:00.000Z")).toBe("Jun 8, 2026, 8:45 PM")
52
+ expect(formatEmailTimestamp("not-a-date")).toBeNull()
53
+ expect(formatEmailTimestamp(null)).toBeNull()
54
+ })
55
+
56
+ it("splits HTML signatures, Gmail quotes, and disclaimers while preserving formatting", () => {
57
+ const disclaimerSplit = splitEmailHtmlForDisplay("<p>Please review.</p><p>Confidentiality Notice: this message is private.</p>")
58
+ expect(disclaimerSplit.bodyHtml).toContain("Please review")
59
+ expect(disclaimerSplit.detailsHtml).toContain("Confidentiality Notice")
60
+
61
+ const split = splitEmailHtmlForDisplay(
62
+ '<p>Hi Dana &amp; team,</p><p>The update looks good.</p><div class="gmail_signature"><table><tbody><tr><td><sup>TM</sup> Jane</td></tr></tbody></table></div><blockquote class="gmail_quote"><div>On Monday, Dana wrote:</div><p>Old note</p></blockquote>',
63
+ )
64
+
65
+ expect(split.bodyHtml).toContain("The update looks good")
66
+ expect(split.bodyHtml).not.toContain("gmail_signature")
67
+ expect(split.detailsHtml).toContain('class="gmail_signature"')
68
+ expect(split.detailsHtml).toContain("<sup>TM</sup>")
69
+ expect(split.detailsHtml).toContain('class="gmail_quote"')
70
+ })
71
+
72
+ it("splits plain text signatures while preserving line boundaries", () => {
73
+ const split = splitEmailTextForDisplay("Hi Dana,\n\nLooks good.\n\n-- \nJane Doe\nVP Sales\njane@example.com")
74
+
75
+ expect(split.bodyText).toBe("Hi Dana,\n\nLooks good.\n\n")
76
+ expect(split.detailsText).toBe("-- \nJane Doe\nVP Sales\njane@example.com")
77
+ })
78
+
79
+ it("builds decoded snippets from visible body only", () => {
80
+ const snippet = emailBodySnippet(
81
+ {
82
+ bodyHtml:
83
+ '<p>Hello &amp; welcome &#39;Cory&#39;</p><p>Thanks,</p><p>Jane Doe</p><p>VP Sales</p><p>jane@example.com</p>',
84
+ },
85
+ 80,
86
+ )
87
+
88
+ expect(snippet).toBe("Hello & welcome 'Cory'")
89
+ expectNoVisibleEscapeArtifacts(snippet)
90
+ })
91
+ })