@handled-ai/design-system 0.20.22 → 0.20.25
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 +9 -0
- package/dist/components/conversation-panel.js +2 -1
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/email-display-helpers.d.ts +3 -1
- package/dist/components/email-display-helpers.js +63 -14
- package/dist/components/email-display-helpers.js.map +1 -1
- package/dist/components/timeline-activity.js +18 -6
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/internal/safe-html.js +32 -0
- package/dist/internal/safe-html.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +40 -1
- package/src/components/__tests__/email-display-helpers.test.ts +45 -1
- package/src/components/__tests__/timeline-activity.test.tsx +7 -1
- package/src/components/conversation-panel.tsx +10 -0
- package/src/components/email-display-helpers.ts +63 -15
- package/src/components/timeline-activity.tsx +28 -6
- package/src/internal/__tests__/safe-html.test.ts +24 -5
- package/src/internal/safe-html.ts +39 -0
|
@@ -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 \"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":[]}
|
|
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\n// Presentational properties preserved so email signatures keep their authored\n// Gmail look (serif/italic/gray etc.) when rendered in the conversation panel.\n// Values are validated per-property; anything containing url()/expression()\n// (or any property not listed) is still dropped — these are text-presentation\n// only, no layout escape or resource loading.\nconst COLOR_VALUE = /^(#[0-9a-f]{3,8}|rgba?\\([\\d\\s,./%]+\\)|[a-z]+)$/\nconst LENGTH_BOX_VALUE = /^(-?[\\d.]+(px|em|rem|%)?|auto|0)(\\s+(-?[\\d.]+(px|em|rem|%)?|auto|0)){0,3}$/\nconst SAFE_TEXT_STYLE_PROPS: Record<string, RegExp> = {\n color: COLOR_VALUE,\n \"background-color\": COLOR_VALUE,\n \"font-family\": /^[a-z0-9\\s,'\"-]+$/,\n \"font-style\": /^(italic|normal|oblique)$/,\n \"font-weight\": /^(bold|bolder|lighter|normal|[1-9]00)$/,\n \"font-variant\": /^[a-z\\s-]+$/,\n \"text-decoration\": /^[a-z\\s-]+$/,\n \"text-decoration-line\": /^[a-z\\s-]+$/,\n \"text-transform\": /^(none|capitalize|uppercase|lowercase)$/,\n \"text-align\": /^(left|right|center|justify)$/,\n \"letter-spacing\": /^(normal|-?[\\d.]+(px|em|rem)?)$/,\n \"line-height\": /^(normal|[\\d.]+(px|em|rem|%)?)$/,\n \"white-space\": /^[a-z-]+$/,\n margin: LENGTH_BOX_VALUE,\n \"margin-top\": LENGTH_BOX_VALUE,\n \"margin-right\": LENGTH_BOX_VALUE,\n \"margin-bottom\": LENGTH_BOX_VALUE,\n \"margin-left\": LENGTH_BOX_VALUE,\n padding: LENGTH_BOX_VALUE,\n \"padding-top\": LENGTH_BOX_VALUE,\n \"padding-right\": LENGTH_BOX_VALUE,\n \"padding-bottom\": LENGTH_BOX_VALUE,\n \"padding-left\": LENGTH_BOX_VALUE,\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 continue\n }\n\n const allowedValue = SAFE_TEXT_STYLE_PROPS[property]\n if (allowedValue?.test(rawValue)) {\n declarations.push(`${property}: ${rawValue}`)\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
|
@@ -366,11 +366,50 @@ describe("ConversationPanel", () => {
|
|
|
366
366
|
fireEvent.click(screen.getByTitle("Show quoted text"));
|
|
367
367
|
const quoted = container.querySelector("blockquote.gmail_quote")!;
|
|
368
368
|
expect(quoted).not.toBeNull();
|
|
369
|
-
|
|
369
|
+
// Presentational color survives sanitization (signature-fidelity
|
|
370
|
+
// allowlist); the unsafe data: URL is still gone.
|
|
371
|
+
expect(quoted.outerHTML).toContain('style="color: red"');
|
|
370
372
|
expect(quoted.outerHTML).not.toContain("data:text/html");
|
|
371
373
|
});
|
|
372
374
|
|
|
373
375
|
|
|
376
|
+
it("renders an explicit signatureHtml collapsed behind the details toggle, preserving authored styles", () => {
|
|
377
|
+
const sigThread = thread({
|
|
378
|
+
messages: [
|
|
379
|
+
{
|
|
380
|
+
id: "m1",
|
|
381
|
+
direction: "outbound",
|
|
382
|
+
from: me,
|
|
383
|
+
to: priya,
|
|
384
|
+
date: "Today",
|
|
385
|
+
receipt: { kind: "draft", label: "Draft" },
|
|
386
|
+
bodyHtml: "<p>Hi Priya,</p><p>Want to see if the math works?</p>",
|
|
387
|
+
signatureHtml:
|
|
388
|
+
'<div class="gmail_signature"><p style="font-style: italic; font-family: \'pt serif\', serif; color: rgb(102, 102, 102)">2261 Market Street, Suite 86807</p><p>Mercury is a fintech company, not an FDIC-insured bank.</p></div>',
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const { container } = render(<ConversationPanel threads={[sigThread]} me={me} />);
|
|
394
|
+
const message = container.querySelector('[data-slot="conv-message"]')!;
|
|
395
|
+
|
|
396
|
+
// Signature is collapsed by default — body visible, signature not.
|
|
397
|
+
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Want to see if the math works?");
|
|
398
|
+
expect(message.textContent).not.toContain("2261 Market Street");
|
|
399
|
+
expect(message.querySelector('[data-slot="email-body-details"]')).toBeNull();
|
|
400
|
+
|
|
401
|
+
fireEvent.click(screen.getByRole("button", { name: "•••" }));
|
|
402
|
+
|
|
403
|
+
const details = message.querySelector('[data-slot="email-body-details"]')!;
|
|
404
|
+
expect(details.textContent).toContain("2261 Market Street, Suite 86807");
|
|
405
|
+
expect(details.textContent).toContain("FDIC-insured");
|
|
406
|
+
// Authored Gmail-look styles survive into the rendered signature.
|
|
407
|
+
const styled = details.querySelector("p[style]")!;
|
|
408
|
+
expect(styled.getAttribute("style")).toContain("font-style: italic");
|
|
409
|
+
expect(styled.getAttribute("style")).toContain("font-family: 'pt serif', serif");
|
|
410
|
+
expect(styled.getAttribute("style")).toContain("color: rgb(102, 102, 102)");
|
|
411
|
+
});
|
|
412
|
+
|
|
374
413
|
it("collapses trailing HTML signature and disclaimer behind a details toggle", () => {
|
|
375
414
|
const htmlThread = thread({
|
|
376
415
|
messages: [
|
|
@@ -48,11 +48,21 @@ describe("email display helpers", () => {
|
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
it("formats timestamps deterministically and omits invalid dates", () => {
|
|
51
|
-
|
|
51
|
+
// Explicit timeZone keeps assertions deterministic across runner TZs.
|
|
52
|
+
expect(formatEmailTimestamp("2026-06-08T20:45:00.000Z", { timeZone: "UTC" })).toBe("Jun 8, 2026, 8:45 PM")
|
|
53
|
+
expect(formatEmailTimestamp("2026-06-08T20:45:00.000Z", { timeZone: "America/New_York" })).toBe("Jun 8, 2026, 4:45 PM")
|
|
52
54
|
expect(formatEmailTimestamp("not-a-date")).toBeNull()
|
|
53
55
|
expect(formatEmailTimestamp(null)).toBeNull()
|
|
54
56
|
})
|
|
55
57
|
|
|
58
|
+
it("defaults to the runtime's local timezone (viewer-local in the browser)", () => {
|
|
59
|
+
const value = "2026-06-08T20:45:00.000Z"
|
|
60
|
+
const expected = new Intl.DateTimeFormat("en-US", {
|
|
61
|
+
month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit",
|
|
62
|
+
}).format(new Date(value))
|
|
63
|
+
expect(formatEmailTimestamp(value)).toBe(expected)
|
|
64
|
+
})
|
|
65
|
+
|
|
56
66
|
it("splits HTML signatures, Gmail quotes, and disclaimers while preserving formatting", () => {
|
|
57
67
|
const disclaimerSplit = splitEmailHtmlForDisplay("<p>Please review.</p><p>Confidentiality Notice: this message is private.</p>")
|
|
58
68
|
expect(disclaimerSplit.bodyHtml).toContain("Please review")
|
|
@@ -69,6 +79,40 @@ describe("email display helpers", () => {
|
|
|
69
79
|
expect(split.detailsHtml).toContain('class="gmail_quote"')
|
|
70
80
|
})
|
|
71
81
|
|
|
82
|
+
it("keeps blank-line markers when the footer split rebuilds the body (Gmail wire format)", () => {
|
|
83
|
+
// The Gmail wire format marks blank lines as <div><br></div>; the rebuilt
|
|
84
|
+
// body must keep them or paragraph spacing collapses in preview/history.
|
|
85
|
+
const wire =
|
|
86
|
+
'<div style="margin:0; line-height:1.4;">Hey Clint,</div>' +
|
|
87
|
+
'<div style="margin:0; line-height:1.4;"><br /></div>' +
|
|
88
|
+
'<div style="margin:0; line-height:1.4;">Admin access roles come with overhead.</div>' +
|
|
89
|
+
'<div style="margin:0; line-height:1.4;"><br /></div>' +
|
|
90
|
+
'<div style="margin:0; line-height:1.4;">Best,</div>' +
|
|
91
|
+
'<div style="margin:0; line-height:1.4;">Cory</div>'
|
|
92
|
+
|
|
93
|
+
const split = splitEmailHtmlForDisplay(wire)
|
|
94
|
+
|
|
95
|
+
// The signoff split fires ("Best," + name moves to details)…
|
|
96
|
+
expect(split.detailsHtml).toContain("Best,")
|
|
97
|
+
expect(split.bodyHtml).not.toContain("Best,")
|
|
98
|
+
// …and the body keeps both blank-line markers between paragraphs.
|
|
99
|
+
expect(split.bodyHtml.match(/<br\s*\/?>/g)?.length).toBe(2)
|
|
100
|
+
expect(split.bodyHtml).toContain("Hey Clint,")
|
|
101
|
+
expect(split.bodyHtml).toContain("Admin access roles come with overhead.")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("keeps interior blank lines made of consecutive <br>s inside one block", () => {
|
|
105
|
+
const split = splitEmailHtmlForDisplay(
|
|
106
|
+
"<div>First paragraph.<br /><br />Second paragraph.</div>" +
|
|
107
|
+
"<div><br /></div><div>Thanks,</div><div>Jane</div>",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(split.detailsHtml).toContain("Thanks,")
|
|
111
|
+
// One blank from the double <br>, one from the standalone marker div that
|
|
112
|
+
// precedes the signoff boundary.
|
|
113
|
+
expect(split.bodyHtml.match(/<br\s*\/?>/g)?.length).toBe(2)
|
|
114
|
+
})
|
|
115
|
+
|
|
72
116
|
it("splits plain text signatures while preserving line boundaries", () => {
|
|
73
117
|
const split = splitEmailTextForDisplay("Hi Dana,\n\nLooks good.\n\n-- \nJane Doe\nVP Sales\njane@example.com")
|
|
74
118
|
|
|
@@ -404,7 +404,13 @@ describe("TimelineActivity", () => {
|
|
|
404
404
|
|
|
405
405
|
fireEvent.click(screen.getByRole("button", { name: /Expand/i }))
|
|
406
406
|
|
|
407
|
-
|
|
407
|
+
// Post-hydration the card shows the viewer's LOCAL timezone (the UTC
|
|
408
|
+
// rendering only exists for the SSR/hydration paint). Mirror the local
|
|
409
|
+
// formatting so the assertion is deterministic across runner timezones.
|
|
410
|
+
const expectedLocalDate = new Intl.DateTimeFormat("en-US", {
|
|
411
|
+
month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit",
|
|
412
|
+
}).format(new Date("2026-06-08T15:30:00.000Z"))
|
|
413
|
+
expect(screen.getByText(expectedLocalDate)).toBeTruthy()
|
|
408
414
|
expect(screen.getByText(/Jane & Team <jane@example.com>/)).toBeTruthy()
|
|
409
415
|
expect(container.querySelector('[data-slot="email-body-details"]')).toBeNull()
|
|
410
416
|
expect(screen.getByRole("button", { name: "•••" })).toBeTruthy()
|
|
@@ -99,6 +99,15 @@ export interface ConvMessage {
|
|
|
99
99
|
receipt?: { kind: "new" | "read" | "opened" | "sent" | "draft"; label: string }
|
|
100
100
|
/** HTML body (preferred). Sanitized by the component before rendering. */
|
|
101
101
|
bodyHtml?: string
|
|
102
|
+
/**
|
|
103
|
+
* Pre-split signature HTML for this message, sanitized server-side by the
|
|
104
|
+
* sender's signature pipeline (which preserves the authored Gmail look —
|
|
105
|
+
* fonts, colors, logos). When present it renders inside the collapsed
|
|
106
|
+
* details section ("•••" toggle) instead of relying on the heuristic footer
|
|
107
|
+
* split of `bodyHtml`, so long signatures never expand the thread by
|
|
108
|
+
* default.
|
|
109
|
+
*/
|
|
110
|
+
signatureHtml?: string | null
|
|
102
111
|
/** Plain-text fallback when `bodyHtml` is absent. */
|
|
103
112
|
body?: string
|
|
104
113
|
/** Quoted prior message, collapsed behind a toggle. Sanitized before rendering. */
|
|
@@ -470,6 +479,7 @@ function MessageView({
|
|
|
470
479
|
<EmailBody
|
|
471
480
|
html={message.bodyHtml}
|
|
472
481
|
text={message.body}
|
|
482
|
+
detailsHtml={message.signatureHtml ?? undefined}
|
|
473
483
|
variant="history"
|
|
474
484
|
collapseDetails={true}
|
|
475
485
|
className="text-sm"
|
|
@@ -214,18 +214,25 @@ export function formatAddressList(input?: string | string[] | null): string {
|
|
|
214
214
|
.join(", ")
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
export function formatEmailTimestamp(
|
|
217
|
+
export function formatEmailTimestamp(
|
|
218
|
+
value?: string | Date | null,
|
|
219
|
+
opts?: { timeZone?: string },
|
|
220
|
+
): string | null {
|
|
218
221
|
if (!value) return null
|
|
219
222
|
const date = value instanceof Date ? value : new Date(value)
|
|
220
223
|
if (Number.isNaN(date.getTime())) return null
|
|
221
224
|
|
|
225
|
+
// Default = the runtime's local timezone (the viewer's, in the browser).
|
|
226
|
+
// Callers that render during SSR/hydration pass timeZone "UTC" for the
|
|
227
|
+
// first paint so server and client HTML match, then re-render local after
|
|
228
|
+
// mount (see useHydrated in timeline-activity).
|
|
222
229
|
return new Intl.DateTimeFormat("en-US", {
|
|
223
230
|
month: "short",
|
|
224
231
|
day: "numeric",
|
|
225
232
|
year: "numeric",
|
|
226
233
|
hour: "numeric",
|
|
227
234
|
minute: "2-digit",
|
|
228
|
-
timeZone:
|
|
235
|
+
...(opts?.timeZone ? { timeZone: opts.timeZone } : {}),
|
|
229
236
|
}).format(date)
|
|
230
237
|
}
|
|
231
238
|
|
|
@@ -302,6 +309,18 @@ function makeHtmlSegment(html: string): MessageSegment | null {
|
|
|
302
309
|
return { html, visibleText }
|
|
303
310
|
}
|
|
304
311
|
|
|
312
|
+
// Blank-line segments carry no visible text, so they never participate in
|
|
313
|
+
// footer-boundary detection — but their html must survive the rebuild, or a
|
|
314
|
+
// split body loses every intentional blank line between paragraphs (the
|
|
315
|
+
// Gmail wire format marks them as <div><br></div>).
|
|
316
|
+
function makeBlankLineSegment(html: string): MessageSegment {
|
|
317
|
+
return { html, visibleText: "" }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isBlankLineHtml(html: string): boolean {
|
|
321
|
+
return Boolean(html.trim()) && !htmlToVisibleText(html) && !/<(?:img|hr)\b/i.test(html)
|
|
322
|
+
}
|
|
323
|
+
|
|
305
324
|
function splitInlineNodes(nodes: readonly Node[], wrapper?: Element): MessageSegment[] {
|
|
306
325
|
const containsBr = hasDirectBr(nodes)
|
|
307
326
|
const chunks: string[][] = [[]]
|
|
@@ -315,14 +334,22 @@ function splitInlineNodes(nodes: readonly Node[], wrapper?: Element): MessageSeg
|
|
|
315
334
|
chunks[chunks.length - 1]?.push(serializeNode(node))
|
|
316
335
|
})
|
|
317
336
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
337
|
+
const rendered = chunks.map((chunk) => chunk.join(""))
|
|
338
|
+
const segments: MessageSegment[] = []
|
|
339
|
+
rendered.forEach((innerHtml, index) => {
|
|
340
|
+
const html = wrapper ? wrapHtmlLike(wrapper, innerHtml) : containsBr ? `<div>${innerHtml}</div>` : innerHtml
|
|
341
|
+
const segment = makeHtmlSegment(html)
|
|
342
|
+
if (segment) {
|
|
343
|
+
segments.push(segment)
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
// An interior empty chunk sits between two <br>s: an intentional blank
|
|
347
|
+
// line. Leading/trailing empties stay dropped, as before.
|
|
348
|
+
if (containsBr && index > 0 && index < rendered.length - 1) {
|
|
349
|
+
segments.push(makeBlankLineSegment(wrapper ? wrapHtmlLike(wrapper, "<br>") : "<div><br></div>"))
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
return segments
|
|
326
353
|
}
|
|
327
354
|
|
|
328
355
|
function splitElementSegment(element: Element): MessageSegment[] {
|
|
@@ -333,6 +360,12 @@ function splitElementSegment(element: Element): MessageSegment[] {
|
|
|
333
360
|
return segment ? [segment] : []
|
|
334
361
|
}
|
|
335
362
|
|
|
363
|
+
// A block with no visible content (e.g. <div><br></div>, <p></p>) is a
|
|
364
|
+
// blank-line marker — keep it whole instead of splitting/dropping it.
|
|
365
|
+
if (isBlankLineHtml(element.outerHTML)) {
|
|
366
|
+
return [makeBlankLineSegment(element.outerHTML)]
|
|
367
|
+
}
|
|
368
|
+
|
|
336
369
|
if (tagName === "div" && hasDirectBlockChild(element)) {
|
|
337
370
|
const childSegments = splitHtmlNodes(Array.from(element.childNodes))
|
|
338
371
|
return childSegments.length ? childSegments : ([makeHtmlSegment(element.outerHTML)].filter(Boolean) as MessageSegment[])
|
|
@@ -399,9 +432,15 @@ function splitHtmlSegmentsFallback(html: string): MessageSegment[] {
|
|
|
399
432
|
const pushInline = (inlineHtml: string) => {
|
|
400
433
|
const chunks = inlineHtml.split(BR_TAG_RE)
|
|
401
434
|
const hadBr = chunks.length > 1
|
|
402
|
-
chunks.forEach((chunk) => {
|
|
435
|
+
chunks.forEach((chunk, index) => {
|
|
403
436
|
const segment = makeHtmlSegment(hadBr ? `<div>${chunk}</div>` : chunk)
|
|
404
|
-
if (segment)
|
|
437
|
+
if (segment) {
|
|
438
|
+
segments.push(segment)
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
if (hadBr && index > 0 && index < chunks.length - 1) {
|
|
442
|
+
segments.push(makeBlankLineSegment("<div><br /></div>"))
|
|
443
|
+
}
|
|
405
444
|
})
|
|
406
445
|
}
|
|
407
446
|
|
|
@@ -422,13 +461,22 @@ function splitHtmlSegmentsFallback(html: string): MessageSegment[] {
|
|
|
422
461
|
const segmentEnd = findMatchingCloseTag(html, tagName, openTagEnd)
|
|
423
462
|
const blockHtml = html.slice(tagStart, segmentEnd)
|
|
424
463
|
|
|
425
|
-
if (
|
|
464
|
+
if (isBlankLineHtml(blockHtml)) {
|
|
465
|
+
segments.push(makeBlankLineSegment(blockHtml))
|
|
466
|
+
} else if (SPLITTABLE_BLOCK_TAGS.has(tagName) && BR_TAG_RE.test(blockHtml)) {
|
|
426
467
|
const openTag = rawOpen
|
|
427
468
|
const closeTag = `</${tagName}>`
|
|
428
469
|
const inner = blockHtml.replace(new RegExp(`^${rawOpen.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"), "").replace(new RegExp(`${closeTag}$`, "i"), "")
|
|
429
|
-
inner.split(BR_TAG_RE)
|
|
470
|
+
const innerChunks = inner.split(BR_TAG_RE)
|
|
471
|
+
innerChunks.forEach((chunk, index) => {
|
|
430
472
|
const segment = makeHtmlSegment(`${openTag}${chunk}${closeTag}`)
|
|
431
|
-
if (segment)
|
|
473
|
+
if (segment) {
|
|
474
|
+
segments.push(segment)
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
if (index > 0 && index < innerChunks.length - 1) {
|
|
478
|
+
segments.push(makeBlankLineSegment(`${openTag}<br />${closeTag}`))
|
|
479
|
+
}
|
|
432
480
|
})
|
|
433
481
|
} else {
|
|
434
482
|
const segment = makeHtmlSegment(blockHtml)
|
|
@@ -392,9 +392,29 @@ function cleanGongText(value?: string | null): string {
|
|
|
392
392
|
return decodeEmailDisplayText(value).trim()
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
-
|
|
395
|
+
/**
|
|
396
|
+
* True once the component has mounted on the client. Timestamps render in
|
|
397
|
+
* UTC for SSR + the hydration pass (server and client HTML must match — the
|
|
398
|
+
* server has no idea what timezone the viewer is in), then flip to the
|
|
399
|
+
* viewer's local timezone immediately after mount.
|
|
400
|
+
*/
|
|
401
|
+
function useHydrated(): boolean {
|
|
402
|
+
const [hydrated, setHydrated] = React.useState(false)
|
|
403
|
+
React.useEffect(() => {
|
|
404
|
+
setHydrated(true)
|
|
405
|
+
}, [])
|
|
406
|
+
return hydrated
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
type TimestampDisplayOptions = { utcTimestamps?: boolean }
|
|
410
|
+
|
|
411
|
+
function timestampTimeZone(opts?: TimestampDisplayOptions): { timeZone?: string } {
|
|
412
|
+
return opts?.utcTimestamps ? { timeZone: "UTC" } : {}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function getTimelineGongCallDisplay(gongCall: TimelineGongCall, opts?: TimestampDisplayOptions) {
|
|
396
416
|
const title = cleanGongText(gongCall.title)
|
|
397
|
-
const startTime = formatEmailTimestamp(gongCall.startTime) ?? ""
|
|
417
|
+
const startTime = formatEmailTimestamp(gongCall.startTime, timestampTimeZone(opts)) ?? ""
|
|
398
418
|
const duration = formatCallDuration(gongCall.durationSeconds)
|
|
399
419
|
const direction = cleanGongText(gongCall.direction)
|
|
400
420
|
const outcome = cleanGongText(gongCall.outcome)
|
|
@@ -407,12 +427,12 @@ function getTimelineGongCallDisplay(gongCall: TimelineGongCall) {
|
|
|
407
427
|
return { title, startTime, duration, direction, outcome, brief, keyPoints, nextSteps, url, snippet }
|
|
408
428
|
}
|
|
409
429
|
|
|
410
|
-
function getTimelineEmailDisplay(email: TimelineEmail) {
|
|
430
|
+
function getTimelineEmailDisplay(email: TimelineEmail, opts?: TimestampDisplayOptions) {
|
|
411
431
|
const sender = normalizeEmailSender({ name: email.from, email: email.fromEmail })
|
|
412
432
|
const to = formatAddressList(email.to) || decodeEmailDisplayText(email.to ?? "")
|
|
413
433
|
const cc = formatAddressList(email.cc) || decodeEmailDisplayText(email.cc ?? "")
|
|
414
434
|
const bcc = formatAddressList(email.bcc) || decodeEmailDisplayText(email.bcc ?? "")
|
|
415
|
-
const date = formatEmailTimestamp(email.date) ?? decodeEmailDisplayText(email.date ?? "")
|
|
435
|
+
const date = formatEmailTimestamp(email.date, timestampTimeZone(opts)) ?? decodeEmailDisplayText(email.date ?? "")
|
|
416
436
|
const subject = email.subject ? decodeEmailDisplayText(email.subject) : ""
|
|
417
437
|
const bodyText = reactNodeToDisplayText(email.body)
|
|
418
438
|
const snippet = emailBodySnippet({ bodyHtml: email.bodyHtml, body: bodyText }, 140)
|
|
@@ -430,7 +450,8 @@ function EmailMetadata({
|
|
|
430
450
|
showAllRecipients: boolean
|
|
431
451
|
setShowAllRecipients: React.Dispatch<React.SetStateAction<boolean>>
|
|
432
452
|
}) {
|
|
433
|
-
const
|
|
453
|
+
const hydrated = useHydrated()
|
|
454
|
+
const display = getTimelineEmailDisplay(email, { utcTimestamps: !hydrated })
|
|
434
455
|
const hasExpandableRecipients = Boolean(display.cc || display.bcc)
|
|
435
456
|
|
|
436
457
|
return (
|
|
@@ -847,7 +868,8 @@ function GongCallCard({
|
|
|
847
868
|
classes: TimelineVariantClasses
|
|
848
869
|
}) {
|
|
849
870
|
const gongCall = event.gongCall as TimelineGongCall
|
|
850
|
-
const
|
|
871
|
+
const hydrated = useHydrated()
|
|
872
|
+
const display = getTimelineGongCallDisplay(gongCall, { utcTimestamps: !hydrated })
|
|
851
873
|
|
|
852
874
|
if (variant === "default") {
|
|
853
875
|
return (
|
|
@@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest"
|
|
|
2
2
|
import { htmlToTextSnippet, sanitizeHtml } from "../safe-html"
|
|
3
3
|
|
|
4
4
|
describe("sanitizeHtml", () => {
|
|
5
|
-
it("removes executable tags, event handlers, styles, and unsafe urls", () => {
|
|
5
|
+
it("removes executable tags, event handlers, unsafe styles, and unsafe urls", () => {
|
|
6
6
|
const html = sanitizeHtml(
|
|
7
|
-
'<p style="color:red" onclick="alert(1)">Hi<script>alert(1)</script><iframe src="https://evil.test"></iframe><a href="java:script:alert(1)">bad</a><img src="data:text/html,boom" onerror="alert(1)"></p>',
|
|
7
|
+
'<p style="color:red; background-image: url(https://evil.test/x)" onclick="alert(1)">Hi<script>alert(1)</script><iframe src="https://evil.test"></iframe><a href="java:script:alert(1)">bad</a><img src="data:text/html,boom" onerror="alert(1)"></p>',
|
|
8
8
|
)
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Presentational color survives (signature fidelity); the url()-bearing
|
|
11
|
+
// declaration and everything executable is gone.
|
|
12
|
+
expect(html).toBe('<p style="color: red">Hi<a>bad</a><img></p>')
|
|
11
13
|
})
|
|
12
14
|
|
|
13
15
|
it("removes svg, math, and other active embedded content", () => {
|
|
@@ -60,12 +62,29 @@ describe("sanitizeHtml", () => {
|
|
|
60
62
|
expect(html).toContain('class="gmail_signature"')
|
|
61
63
|
expect(html).toContain('<table><tbody><tr><td><img src="https://example.com/logo.png" width="120" height="48" alt="Acme & Co"></td>')
|
|
62
64
|
expect(html).toContain('<sup>TM</sup><sub>LLC</sub>')
|
|
63
|
-
|
|
65
|
+
// Authored presentational styles now survive (signature Gmail-look
|
|
66
|
+
// fidelity); url()-bearing declarations are still dropped.
|
|
67
|
+
expect(html).toContain('style="vertical-align: super; font-size: 10px; color: red"')
|
|
64
68
|
expect(html).not.toContain("onerror")
|
|
65
|
-
expect(html).not.toContain("color: red")
|
|
66
69
|
expect(html).not.toContain("background-image")
|
|
67
70
|
})
|
|
68
71
|
|
|
72
|
+
it("preserves authored signature typography and spacing styles, dropping unknown properties", () => {
|
|
73
|
+
const html = sanitizeHtml(
|
|
74
|
+
'<p style="font-family: \'pt serif\', serif; font-style: italic; font-size: 12px; color: rgb(102, 102, 102); line-height: 1.38; margin: 0 0 8px 0; position: fixed; z-index: 9999">2261 Market Street</p>',
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
expect(html).toContain("font-family: 'pt serif', serif")
|
|
78
|
+
expect(html).toContain("font-style: italic")
|
|
79
|
+
expect(html).toContain("font-size: 12px")
|
|
80
|
+
expect(html).toContain("color: rgb(102, 102, 102)")
|
|
81
|
+
expect(html).toContain("line-height: 1.38")
|
|
82
|
+
expect(html).toContain("margin: 0 0 8px 0")
|
|
83
|
+
// Layout-escape properties are not allowlisted.
|
|
84
|
+
expect(html).not.toContain("position")
|
|
85
|
+
expect(html).not.toContain("z-index")
|
|
86
|
+
})
|
|
87
|
+
|
|
69
88
|
it("bounds image dimensions and inline font-size while preserving subscript alignment", () => {
|
|
70
89
|
const html = sanitizeHtml(
|
|
71
90
|
'<span style="vertical-align: sub; font-size: 500px">H2O</span><img src="https://example.com/x.png" width="99999" height="64" alt="x">',
|
|
@@ -147,6 +147,39 @@ function sanitizeFontSize(value: string): string | null {
|
|
|
147
147
|
return `${amount}${unit}`
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// Presentational properties preserved so email signatures keep their authored
|
|
151
|
+
// Gmail look (serif/italic/gray etc.) when rendered in the conversation panel.
|
|
152
|
+
// Values are validated per-property; anything containing url()/expression()
|
|
153
|
+
// (or any property not listed) is still dropped — these are text-presentation
|
|
154
|
+
// only, no layout escape or resource loading.
|
|
155
|
+
const COLOR_VALUE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s,./%]+\)|[a-z]+)$/
|
|
156
|
+
const LENGTH_BOX_VALUE = /^(-?[\d.]+(px|em|rem|%)?|auto|0)(\s+(-?[\d.]+(px|em|rem|%)?|auto|0)){0,3}$/
|
|
157
|
+
const SAFE_TEXT_STYLE_PROPS: Record<string, RegExp> = {
|
|
158
|
+
color: COLOR_VALUE,
|
|
159
|
+
"background-color": COLOR_VALUE,
|
|
160
|
+
"font-family": /^[a-z0-9\s,'"-]+$/,
|
|
161
|
+
"font-style": /^(italic|normal|oblique)$/,
|
|
162
|
+
"font-weight": /^(bold|bolder|lighter|normal|[1-9]00)$/,
|
|
163
|
+
"font-variant": /^[a-z\s-]+$/,
|
|
164
|
+
"text-decoration": /^[a-z\s-]+$/,
|
|
165
|
+
"text-decoration-line": /^[a-z\s-]+$/,
|
|
166
|
+
"text-transform": /^(none|capitalize|uppercase|lowercase)$/,
|
|
167
|
+
"text-align": /^(left|right|center|justify)$/,
|
|
168
|
+
"letter-spacing": /^(normal|-?[\d.]+(px|em|rem)?)$/,
|
|
169
|
+
"line-height": /^(normal|[\d.]+(px|em|rem|%)?)$/,
|
|
170
|
+
"white-space": /^[a-z-]+$/,
|
|
171
|
+
margin: LENGTH_BOX_VALUE,
|
|
172
|
+
"margin-top": LENGTH_BOX_VALUE,
|
|
173
|
+
"margin-right": LENGTH_BOX_VALUE,
|
|
174
|
+
"margin-bottom": LENGTH_BOX_VALUE,
|
|
175
|
+
"margin-left": LENGTH_BOX_VALUE,
|
|
176
|
+
padding: LENGTH_BOX_VALUE,
|
|
177
|
+
"padding-top": LENGTH_BOX_VALUE,
|
|
178
|
+
"padding-right": LENGTH_BOX_VALUE,
|
|
179
|
+
"padding-bottom": LENGTH_BOX_VALUE,
|
|
180
|
+
"padding-left": LENGTH_BOX_VALUE,
|
|
181
|
+
}
|
|
182
|
+
|
|
150
183
|
function sanitizeStyle(value: string): string | null {
|
|
151
184
|
const declarations: string[] = []
|
|
152
185
|
|
|
@@ -166,6 +199,12 @@ function sanitizeStyle(value: string): string | null {
|
|
|
166
199
|
if (property === "font-size") {
|
|
167
200
|
const fontSize = sanitizeFontSize(rawValue)
|
|
168
201
|
if (fontSize) declarations.push(`font-size: ${fontSize}`)
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const allowedValue = SAFE_TEXT_STYLE_PROPS[property]
|
|
206
|
+
if (allowedValue?.test(rawValue)) {
|
|
207
|
+
declarations.push(`${property}: ${rawValue}`)
|
|
169
208
|
}
|
|
170
209
|
}
|
|
171
210
|
|