@handled-ai/design-system 0.20.5 → 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.
- package/dist/components/conversation-panel.d.ts +19 -0
- package/dist/components/conversation-panel.js +116 -292
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/email-body.d.ts +15 -0
- package/dist/components/email-body.js +101 -0
- package/dist/components/email-body.js.map +1 -0
- package/dist/components/email-display-helpers.d.ts +34 -0
- package/dist/components/email-display-helpers.js +436 -0
- package/dist/components/email-display-helpers.js.map +1 -0
- package/dist/components/email-preview-card.d.ts +7 -4
- package/dist/components/email-preview-card.js +48 -25
- package/dist/components/email-preview-card.js.map +1 -1
- package/dist/components/timeline-activity.js +66 -42
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/safe-html.d.ts +1 -1
- package/dist/internal/safe-html.js +64 -3
- package/dist/internal/safe-html.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +182 -22
- package/src/components/__tests__/email-body.test.tsx +83 -0
- package/src/components/__tests__/email-display-helpers.test.ts +91 -0
- package/src/components/__tests__/email-preview-card.test.tsx +36 -2
- package/src/components/__tests__/timeline-activity.test.tsx +53 -1
- package/src/components/conversation-panel.tsx +136 -350
- package/src/components/email-body.tsx +126 -0
- package/src/components/email-display-helpers.ts +557 -0
- package/src/components/email-preview-card.tsx +54 -29
- package/src/components/timeline-activity.tsx +73 -53
- package/src/index.ts +2 -0
- package/src/internal/__tests__/safe-html.test.ts +34 -2
- 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, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n}\n\nfunction escapeAttribute(value: string): string {\n return escapeHtml(value).replace(/\"/g, \""\")\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":"AAAA,MAAM,uBAAuB,oBAAI,IAAI;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,MAAM,eAAe,oBAAI,IAAI;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,MAAM,YAAY,oBAAI,IAAI,CAAC,MAAM,MAAM,KAAK,CAAC;AAC7C,MAAM,oBAAoB,oBAAI,IAAI,CAAC,cAAc,QAAQ,OAAO,CAAC;AACjE,MAAM,qBAAqB,oBAAI,IAAI,CAAC,SAAS,UAAU,WAAW,MAAM,CAAC;AAEzE,SAAS,WAAW,OAAuB;AACzC,SAAO,MACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM;AACzB;AAEA,SAAS,gBAAgB,OAAuB;AAC9C,SAAO,WAAW,KAAK,EAAE,QAAQ,MAAM,QAAQ;AACjD;AAEA,SAAS,cAAc,OAAuB;AAC5C,SAAO,OAAO,UAAU,KAAK,KAAK,SAAS,KAAK,SAAS,UAAW,OAAO,cAAc,KAAK,IAAI;AACpG;AAEA,SAAS,mBAAmB,OAAuB;AACjD,QAAM,gBAAwC;AAAA,IAC5C,KAAK;AAAA,IACL,MAAM;AAAA,IACN,OAAO;AAAA,IACP,IAAI;AAAA,IACJ,IAAI;AAAA,IACJ,SAAS;AAAA,IACT,MAAM;AAAA,IACN,KAAK;AAAA,EACP;AAEA,MAAI,UAAU;AACd,WAAS,IAAI,GAAG,IAAI,GAAG,KAAK,GAAG;AAC7B,UAAM,OAAO,QACV,QAAQ,sBAAsB,CAAC,QAAQ,QAAgB;AACtD,YAAM,YAAY,OAAO,SAAS,KAAK,EAAE;AACzC,aAAO,cAAc,SAAS;AAAA,IAChC,CAAC,EACA,QAAQ,cAAc,CAAC,QAAQ,YAAoB;AAClD,YAAM,YAAY,OAAO,SAAS,SAAS,EAAE;AAC7C,aAAO,cAAc,SAAS;AAAA,IAChC,CAAC,EACA,QAAQ,gBAAgB,CAAC,OAAO,SAAc;AArFrD;AAqFwD,iCAAc,KAAK,YAAY,CAAC,MAAhC,YAAqC;AAAA,KAAK;AAE9F,QAAI,SAAS,QAAS,QAAO;AAC7B,cAAU;AAAA,EACZ;AAEA,SAAO;AACT;AAEA,SAAS,UAAU,OAAwB;AACzC,QAAM,UAAU,mBAAmB,KAAK,EAAE,QAAQ,6BAA6B,EAAE,EAAE,KAAK;AACxF,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI,QAAQ,WAAW,IAAI,EAAG,QAAO;AACrC,MAAI,QAAQ,WAAW,GAAG,KAAK,QAAQ,WAAW,GAAG,KAAK,QAAQ,WAAW,IAAI,KAAK,QAAQ,WAAW,KAAK,GAAG;AAC/G,WAAO;AAAA,EACT;AAEA,MAAI;AACF,WAAO,mBAAmB,IAAI,IAAI,IAAI,SAAS,uBAAuB,EAAE,QAAQ;AAAA,EAClF,SAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,kBAAkB,OAA8B;AACvD,QAAM,aAAa,MAChB,MAAM,KAAK,EACX,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC,EAC3B,OAAO,CAAC,UAAU,mBAAmB,KAAK,KAAK,CAAC;AAEnD,SAAO,WAAW,SAAS,WAAW,KAAK,GAAG,IAAI;AACpD;AAEA,SAAS,kBAAkB,SAAiB,MAAc,OAA8B;AACtF,QAAM,OAAO,KAAK,YAAY;AAE9B,MACE,KAAK,WAAW,IAAI,KACpB,SAAS,WACT,SAAS,YACT,SAAS,gBACT,SAAS,gBACT,SAAS,SACT;AACA,WAAO;AAAA,EACT;AAEA,MAAI,SAAS,SAAS;AACpB,UAAM,gBAAgB,kBAAkB,KAAK;AAC7C,WAAO,gBAAgB,UAAU,gBAAgB,aAAa,CAAC,MAAM;AAAA,EACvE;AAEA,MAAI,kBAAkB,IAAI,IAAI,KAAK,KAAK,WAAW,OAAO,GAAG;AAC3D,WAAO,GAAG,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EAC3C;AAEA,MAAI,YAAY,OAAO,SAAS,UAAU,UAAU,KAAK,GAAG;AAC1D,WAAO,SAAS,gBAAgB,KAAK,CAAC;AAAA,EACxC;AAEA,MAAI,YAAY,SAAS,SAAS,SAAS,UAAU,KAAK,GAAG;AAC3D,WAAO,QAAQ,gBAAgB,KAAK,CAAC;AAAA,EACvC;AAEA,MAAI,YAAY,UAAU,SAAS,SAAS,SAAS,WAAW,SAAS,WAAW;AAClF,WAAO,GAAG,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EAC3C;AAEA,OAAK,YAAY,QAAQ,YAAY,UAAU,SAAS,aAAa,SAAS,YAAY;AACxF,WAAO,GAAG,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EAC3C;AAEA,SAAO;AACT;AAEA,SAAS,mBAAmB,SAAiB,gBAAgB,IAAY;AAhKzE;AAiKE,QAAM,aAAuB,CAAC;AAC9B,QAAM,cAAc;AACpB,MAAI;AAEJ,UAAQ,QAAQ,YAAY,KAAK,aAAa,OAAO,MAAM;AACzD,UAAM,CAAC,EAAE,MAAM,mBAAmB,mBAAmB,aAAa,IAAI;AACtE,UAAM,SAAQ,2DAAqB,sBAArB,YAA0C,kBAA1C,YAA2D;AACzE,UAAM,gBAAgB,kBAAkB,SAAS,MAAM,KAAK;AAC5D,QAAI,cAAe,YAAW,KAAK,aAAa;AAAA,EAClD;AAEA,MAAI,YAAY,OAAO,WAAW,KAAK,CAAC,SAAS,KAAK,WAAW,OAAO,CAAC,GAAG;AAC1E,eAAW,KAAK,mBAAmB,2BAA2B;AAAA,EAChE;AAEA,SAAO,WAAW,SAAS,IAAI,WAAW,KAAK,GAAG,CAAC,KAAK;AAC1D;AAEA,SAAS,WAAW,MAAc,YAA4B;AAC5D,MAAI,QAA0B;AAE9B,WAAS,IAAI,aAAa,GAAG,IAAI,KAAK,QAAQ,KAAK,GAAG;AACpD,UAAM,OAAO,KAAK,CAAC;AACnB,QAAI,OAAO;AACT,UAAI,SAAS,MAAO,SAAQ;AAC5B;AAAA,IACF;AAEA,QAAI,SAAS,OAAO,SAAS,KAAK;AAChC,cAAQ;AACR;AAAA,IACF;AAEA,QAAI,SAAS,IAAK,QAAO;AAAA,EAC3B;AAEA,SAAO;AACT;AAEA,SAAS,SAAS,QAA+E;AAxMjG;AAyME,QAAM,QAAQ,OAAO,MAAM,wDAAwD;AACnF,MAAI,CAAC,MAAO,QAAO;AAEnB,SAAO;AAAA,IACL,SAAS,CAAC,CAAC,MAAM,CAAC;AAAA,IAClB,MAAM,MAAM,CAAC,EAAE,YAAY;AAAA,IAC3B,aAAY,WAAM,CAAC,MAAP,YAAY;AAAA,EAC1B;AACF;AAEA,SAAS,mBAAmB,MAAc,SAAiB,WAA2B;AACpF,QAAM,eAAe,IAAI,OAAO,SAAS,OAAO,SAAS,IAAI;AAC7D,eAAa,YAAY;AACzB,QAAM,QAAQ,aAAa,KAAK,IAAI;AACpC,SAAO,QAAQ,aAAa,YAAY;AAC1C;AASO,SAAS,aAAa,MAAsB;AACjD,MAAI,SAAS;AACb,MAAI,SAAS;AAEb,SAAO,SAAS,KAAK,QAAQ;AAC3B,UAAM,WAAW,KAAK,QAAQ,KAAK,MAAM;AACzC,QAAI,aAAa,IAAI;AACnB,gBAAU,KAAK,MAAM,MAAM;AAC3B;AAAA,IACF;AAEA,cAAU,KAAK,MAAM,QAAQ,QAAQ;AAErC,QAAI,KAAK,WAAW,QAAQ,QAAQ,GAAG;AACrC,YAAM,aAAa,KAAK,QAAQ,OAAO,WAAW,CAAC;AACnD,eAAS,eAAe,KAAK,KAAK,SAAS,aAAa;AACxD;AAAA,IACF;AAEA,UAAM,SAAS,WAAW,MAAM,QAAQ;AACxC,QAAI,WAAW,IAAI;AACjB,gBAAU,WAAW,KAAK,MAAM,QAAQ,CAAC;AACzC;AAAA,IACF;AAEA,UAAM,SAAS,KAAK,MAAM,UAAU,SAAS,CAAC;AAC9C,UAAM,SAAS,SAAS,MAAM;AAC9B,QAAI,CAAC,QAAQ;AACX,eAAS,SAAS;AAClB;AAAA,IACF;AAEA,QAAI,qBAAqB,IAAI,OAAO,IAAI,GAAG;AACzC,YAAM,WAAW,OAAO,UAAU,KAAK,mBAAmB,MAAM,OAAO,MAAM,SAAS,CAAC;AACvF,eAAS,aAAa,KAAK,SAAS,IAAI;AACxC;AAAA,IACF;AAEA,QAAI,aAAa,IAAI,OAAO,IAAI,GAAG;AACjC,UAAI,OAAO,SAAS;AAClB,YAAI,CAAC,UAAU,IAAI,OAAO,IAAI,EAAG,WAAU,KAAK,OAAO,IAAI;AAAA,MAC7D,OAAO;AACL,kBAAU,IAAI,OAAO,IAAI,GAAG,mBAAmB,OAAO,MAAM,OAAO,UAAU,CAAC;AAAA,MAChF;AAAA,IACF;AAEA,aAAS,SAAS;AAAA,EACpB;AAEA,SAAO;AACT;AAEO,SAAS,kBAAkB,MAAc,YAAY,KAAa;AACvE,SAAO,aAAa,IAAI,EACrB,QAAQ,YAAY,GAAG,EACvB,QAAQ,QAAQ,GAAG,EACnB,KAAK,EACL,MAAM,GAAG,SAAS;AACvB;","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, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n}\n\nfunction escapeAttribute(value: string): string {\n return escapeHtml(value).replace(/\"/g, \""\")\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
|
@@ -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("&");
|
|
14
|
+
expect(value).not.toContain("<");
|
|
15
|
+
expect(value).not.toContain(">");
|
|
16
|
+
expect(value).not.toContain(""");
|
|
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="
|
|
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.
|
|
214
|
+
fireEvent.click(screen.getByRole("button", { name: "•••" }));
|
|
203
215
|
|
|
204
|
-
expect(screen.
|
|
205
|
-
expect(message.querySelector('[data-slot="
|
|
206
|
-
expect(message.querySelector('[data-slot="
|
|
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="
|
|
239
|
+
expect(message.querySelector('[data-slot="email-body-details"]')).toBeNull();
|
|
228
240
|
|
|
229
|
-
fireEvent.click(screen.
|
|
241
|
+
fireEvent.click(screen.getByRole("button", { name: "•••" }));
|
|
230
242
|
|
|
231
|
-
const details = message.querySelector('[data-slot="
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
376
|
+
fireEvent.click(screen.getByRole("button", { name: "•••" }));
|
|
366
377
|
|
|
367
|
-
expect(message.querySelector('[data-slot="
|
|
368
|
-
expect(message.querySelector('[data-slot="
|
|
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="
|
|
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.
|
|
397
|
-
expect(container.querySelector('[data-slot="
|
|
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.
|
|
433
|
+
fireEvent.click(screen.getByTitle("Show historical details"));
|
|
423
434
|
|
|
424
|
-
expect(document.querySelector('[data-slot="
|
|
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 & Sender", email: "alex.sender@example.com" };
|
|
783
|
+
const customer: ConvParticipant = { name: "Jane & 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 & team — it's ready.</p><p>Thanks,</p><p>Alex Sender</p><p>Handled<sup>AI</sup></p>",
|
|
795
|
+
quoted: {
|
|
796
|
+
attr: "On Monday, Jane & Team wrote:",
|
|
797
|
+
html: '<blockquote class="gmail_quote"><p>Prior & 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 & 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("&")
|
|
10
|
+
expect(value).not.toContain("<")
|
|
11
|
+
expect(value).not.toContain(">")
|
|
12
|
+
expect(value).not.toContain(""")
|
|
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 & welcome 'team'</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 & two\\nLine 'three'"'} />)
|
|
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("&")
|
|
16
|
+
expect(value).not.toContain("<")
|
|
17
|
+
expect(value).not.toContain(">")
|
|
18
|
+
expect(value).not.toContain(""")
|
|
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 & Jane'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 & 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 & 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 & welcome 'Cory'</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
|
+
})
|
|
@@ -10,6 +10,17 @@ afterEach(() => {
|
|
|
10
10
|
|
|
11
11
|
const from = { name: "Cory Pitt", email: "cory@withhandled.com" }
|
|
12
12
|
|
|
13
|
+
function expectNoVisibleEscapeArtifacts(value: string) {
|
|
14
|
+
expect(value).not.toMatch(/&#(?:x[0-9a-f]+|\d+);?/i)
|
|
15
|
+
expect(value).not.toContain("&")
|
|
16
|
+
expect(value).not.toContain("<")
|
|
17
|
+
expect(value).not.toContain(">")
|
|
18
|
+
expect(value).not.toContain(""")
|
|
19
|
+
expect(value).not.toContain('\\"')
|
|
20
|
+
expect(value).not.toContain("\\'")
|
|
21
|
+
expect(value).not.toContain("\\n")
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
describe("EmailPreviewCard", () => {
|
|
14
25
|
it("renders the subject", () => {
|
|
15
26
|
render(<EmailPreviewCard from={from} subject="Quarterly update" />)
|
|
@@ -29,7 +40,7 @@ describe("EmailPreviewCard", () => {
|
|
|
29
40
|
|
|
30
41
|
it("renders the recipient", () => {
|
|
31
42
|
render(<EmailPreviewCard from={from} to="jane@acme.com" />)
|
|
32
|
-
expect(screen.getByText("
|
|
43
|
+
expect(screen.getByText("jane@acme.com")).toBeTruthy()
|
|
33
44
|
expect(
|
|
34
45
|
screen.getByText(/This is how your email lands in jane@acme.com's inbox/),
|
|
35
46
|
).toBeTruthy()
|
|
@@ -37,7 +48,7 @@ describe("EmailPreviewCard", () => {
|
|
|
37
48
|
|
|
38
49
|
it("renders 'to no recipient yet' when no recipient", () => {
|
|
39
50
|
render(<EmailPreviewCard from={from} />)
|
|
40
|
-
expect(screen.getByText("
|
|
51
|
+
expect(screen.getByText("no recipient yet")).toBeTruthy()
|
|
41
52
|
expect(
|
|
42
53
|
screen.getByText(/This is how your email lands in the recipient's inbox/),
|
|
43
54
|
).toBeTruthy()
|
|
@@ -59,4 +70,27 @@ describe("EmailPreviewCard", () => {
|
|
|
59
70
|
)
|
|
60
71
|
expect(container.querySelector("em")?.textContent).toBe("Best, Cory")
|
|
61
72
|
})
|
|
73
|
+
|
|
74
|
+
it("decodes preview copy, displays To/Cc/Bcc, and renders superscript signatures", () => {
|
|
75
|
+
const { container } = render(
|
|
76
|
+
<EmailPreviewCard
|
|
77
|
+
from={{ name: "Cory & Co", email: "Cory Pitt <cory@withhandled.com>" }}
|
|
78
|
+
to={'"Jane & Team" <jane@example.com>'}
|
|
79
|
+
cc={["Ops <ops@example.com>"]}
|
|
80
|
+
bcc="Audit <audit@example.com>"
|
|
81
|
+
subject="Quarterly & renewal"
|
|
82
|
+
htmlBody="<p>Hello Jane & team — it's ready.</p>"
|
|
83
|
+
signatureHtml="<p>Handled<sup>AI</sup></p>"
|
|
84
|
+
/>,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
expect(screen.getByText("Cory & Co")).toBeTruthy()
|
|
88
|
+
expect(screen.getByText("Quarterly & renewal")).toBeTruthy()
|
|
89
|
+
expect(screen.getByText("Jane & Team <jane@example.com>")).toBeTruthy()
|
|
90
|
+
expect(screen.getByText("Ops <ops@example.com>")).toBeTruthy()
|
|
91
|
+
expect(screen.getByText("Audit <audit@example.com>")).toBeTruthy()
|
|
92
|
+
expect(screen.getByText("Hello Jane & team — it's ready.")).toBeTruthy()
|
|
93
|
+
expect(container.querySelector("sup")?.textContent).toBe("AI")
|
|
94
|
+
expectNoVisibleEscapeArtifacts(container.textContent ?? "")
|
|
95
|
+
})
|
|
62
96
|
})
|