@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.
@@ -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, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n}\n\nfunction escapeAttribute(value: string): string {\n return escapeHtml(value).replace(/\"/g, \"&quot;\")\n}\n\nfunction safeCodePoint(value: number): string {\n return Number.isInteger(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : \"\"\n}\n\nfunction decodeHtmlEntities(value: string): string {\n const namedEntities: Record<string, string> = {\n amp: \"&\",\n apos: \"'\",\n colon: \":\",\n gt: \">\",\n lt: \"<\",\n newline: \"\\n\",\n quot: '\"',\n tab: \"\\t\",\n }\n\n let decoded = value\n for (let i = 0; i < 4; i += 1) {\n const next = decoded\n .replace(/&#x([0-9a-f]+);?/gi, (_match, hex: string) => {\n const codePoint = Number.parseInt(hex, 16)\n return safeCodePoint(codePoint)\n })\n .replace(/&#(\\d+);?/g, (_match, decimal: string) => {\n const codePoint = Number.parseInt(decimal, 10)\n return safeCodePoint(codePoint)\n })\n .replace(/&([a-z]+);/gi, (match, name: string) => namedEntities[name.toLowerCase()] ?? match)\n\n if (next === decoded) return decoded\n decoded = next\n }\n\n return decoded\n}\n\nfunction isSafeUrl(value: string): boolean {\n const decoded = decodeHtmlEntities(value).replace(/[\\u0000-\\u001f\\u007f\\s]+/g, \"\").trim()\n if (!decoded) return false\n if (decoded.startsWith(\"//\")) return false\n if (decoded.startsWith(\"#\") || decoded.startsWith(\"/\") || decoded.startsWith(\"./\") || decoded.startsWith(\"../\")) {\n return true\n }\n\n try {\n return SAFE_URL_PROTOCOLS.has(new URL(decoded, \"https://handled.local\").protocol)\n } catch {\n return false\n }\n}\n\nfunction sanitizeClassName(value: string): string | null {\n const safeTokens = value\n .split(/\\s+/)\n .map((token) => token.trim())\n .filter((token) => /^[A-Za-z0-9_-]+$/.test(token))\n\n return safeTokens.length ? safeTokens.join(\" \") : null\n}\n\nfunction sanitizeDimension(value: string): string | null {\n const trimmed = value.trim()\n if (/^\\d{1,4}$/.test(trimmed)) return trimmed\n return null\n}\n\nfunction sanitizeLanguage(value: string): string | null {\n const trimmed = value.trim()\n if (/^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$/.test(trimmed)) return trimmed\n return null\n}\n\nfunction sanitizeDirection(value: string): string | null {\n const trimmed = value.trim().toLowerCase()\n return trimmed === \"ltr\" || trimmed === \"rtl\" || trimmed === \"auto\" ? trimmed : null\n}\n\nfunction sanitizeFontSize(value: string): string | null {\n const trimmed = value.trim().toLowerCase()\n const match = trimmed.match(/^(\\d+(?:\\.\\d+)?)(px|em|rem|%)$/)\n if (!match) return null\n\n const amount = Number.parseFloat(match[1])\n const unit = match[2]\n const maxByUnit: Record<string, number> = { px: 72, em: 4, rem: 4, \"%\": 400 }\n if (!Number.isFinite(amount) || amount <= 0 || amount > maxByUnit[unit]) return null\n return `${amount}${unit}`\n}\n\nfunction sanitizeStyle(value: string): string | null {\n const declarations: string[] = []\n\n for (const rawDeclaration of value.split(\";\")) {\n const separatorIndex = rawDeclaration.indexOf(\":\")\n if (separatorIndex === -1) continue\n\n const property = rawDeclaration.slice(0, separatorIndex).trim().toLowerCase()\n const rawValue = decodeHtmlEntities(rawDeclaration.slice(separatorIndex + 1)).trim().toLowerCase()\n if (!property || !rawValue || /url\\s*\\(|expression\\s*\\(/i.test(rawValue)) continue\n\n if (property === \"vertical-align\" && (rawValue === \"super\" || rawValue === \"sub\")) {\n declarations.push(`vertical-align: ${rawValue}`)\n continue\n }\n\n if (property === \"font-size\") {\n const fontSize = sanitizeFontSize(rawValue)\n if (fontSize) declarations.push(`font-size: ${fontSize}`)\n }\n }\n\n return declarations.length ? declarations.join(\"; \") : null\n}\n\nfunction sanitizeAttribute(tagName: string, name: string, value: string): string | null {\n const attr = name.toLowerCase()\n\n if (\n attr.startsWith(\"on\") ||\n attr === \"srcdoc\" ||\n attr === \"formaction\" ||\n attr === \"xlink:href\" ||\n attr === \"xmlns\"\n ) {\n return null\n }\n\n if (attr === \"style\") {\n const safeStyle = sanitizeStyle(value)\n return safeStyle ? `style=\"${escapeAttribute(safeStyle)}\"` : null\n }\n\n if (attr === \"class\") {\n const safeClassName = sanitizeClassName(value)\n return safeClassName ? `class=\"${escapeAttribute(safeClassName)}\"` : null\n }\n\n if (attr === \"dir\") {\n const safeDirection = sanitizeDirection(value)\n return safeDirection ? `dir=\"${escapeAttribute(safeDirection)}\"` : null\n }\n\n if (attr === \"lang\") {\n const safeLanguage = sanitizeLanguage(value)\n return safeLanguage ? `lang=\"${escapeAttribute(safeLanguage)}\"` : null\n }\n\n if (SAFE_GLOBAL_ATTRS.has(attr) || attr.startsWith(\"aria-\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"a\" && attr === \"href\" && isSafeUrl(value)) {\n return `href=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && attr === \"src\" && isSafeUrl(value)) {\n return `src=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && attr === \"alt\") {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && (attr === \"width\" || attr === \"height\")) {\n const safeDimension = sanitizeDimension(value)\n return safeDimension ? `${attr}=\"${escapeAttribute(safeDimension)}\"` : null\n }\n\n if ((tagName === \"td\" || tagName === \"th\") && (attr === \"colspan\" || attr === \"rowspan\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n return null\n}\n\nfunction sanitizeAttributes(tagName: string, rawAttributes = \"\"): string {\n const attributes: string[] = []\n const attrPattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'=<>`]+)))?/g\n let match: RegExpExecArray | null\n\n while ((match = attrPattern.exec(rawAttributes)) !== null) {\n const [, name, doubleQuotedValue, singleQuotedValue, unquotedValue] = match\n const value = doubleQuotedValue ?? singleQuotedValue ?? unquotedValue ?? \"\"\n const safeAttribute = sanitizeAttribute(tagName, name, value)\n if (safeAttribute) attributes.push(safeAttribute)\n }\n\n if (tagName === \"a\" && attributes.some((attr) => attr.startsWith(\"href=\"))) {\n attributes.push('target=\"_blank\"', 'rel=\"noopener noreferrer\"')\n }\n\n return attributes.length ? ` ${attributes.join(\" \")}` : \"\"\n}\n\nfunction findTagEnd(html: string, startIndex: number): number {\n let quote: '\"' | \"'\" | null = null\n\n for (let i = startIndex + 1; i < html.length; i += 1) {\n const char = html[i]\n if (quote) {\n if (char === quote) quote = null\n continue\n }\n\n if (char === '\"' || char === \"'\") {\n quote = char\n continue\n }\n\n if (char === \">\") return i\n }\n\n return -1\n}\n\nfunction parseTag(rawTag: string): { closing: boolean; name: string; attributes: string } | null {\n const match = rawTag.match(/^<\\s*(\\/)?\\s*([A-Za-z][A-Za-z0-9:-]*)\\b([\\s\\S]*?)\\/?>$/)\n if (!match) return null\n\n return {\n closing: !!match[1],\n name: match[2].toLowerCase(),\n attributes: match[3] ?? \"\",\n }\n}\n\nfunction findDangerousClose(html: string, tagName: string, fromIndex: number): number {\n const closePattern = new RegExp(`</\\\\s*${tagName}\\\\s*>`, \"ig\")\n closePattern.lastIndex = fromIndex\n const match = closePattern.exec(html)\n return match ? closePattern.lastIndex : -1\n}\n\n/**\n * Conservative, deterministic sanitizer for user/email supplied HTML rendered by\n * design-system components. It keeps common email formatting tags while removing\n * executable tags, event handlers, unsafe inline styles, and unsafe URLs. This stays\n * dependency-free for the shared package and intentionally favors stripping\n * ambiguous email content over preserving every possible HTML feature.\n */\nexport function sanitizeHtml(html: string): string {\n let output = \"\"\n let cursor = 0\n\n while (cursor < html.length) {\n const tagStart = html.indexOf(\"<\", cursor)\n if (tagStart === -1) {\n output += html.slice(cursor)\n break\n }\n\n output += html.slice(cursor, tagStart)\n\n if (html.startsWith(\"<!--\", tagStart)) {\n const commentEnd = html.indexOf(\"-->\", tagStart + 4)\n cursor = commentEnd === -1 ? html.length : commentEnd + 3\n continue\n }\n\n const tagEnd = findTagEnd(html, tagStart)\n if (tagEnd === -1) {\n output += escapeHtml(html.slice(tagStart))\n break\n }\n\n const rawTag = html.slice(tagStart, tagEnd + 1)\n const parsed = parseTag(rawTag)\n if (!parsed) {\n cursor = tagEnd + 1\n continue\n }\n\n if (DANGEROUS_BLOCK_TAGS.has(parsed.name)) {\n const closeEnd = parsed.closing ? -1 : findDangerousClose(html, parsed.name, tagEnd + 1)\n cursor = closeEnd === -1 ? tagEnd + 1 : closeEnd\n continue\n }\n\n if (ALLOWED_TAGS.has(parsed.name)) {\n if (parsed.closing) {\n if (!VOID_TAGS.has(parsed.name)) output += `</${parsed.name}>`\n } else {\n output += `<${parsed.name}${sanitizeAttributes(parsed.name, parsed.attributes)}>`\n }\n }\n\n cursor = tagEnd + 1\n }\n\n return output\n}\n\nexport function htmlToTextSnippet(html: string, maxLength = 140): string {\n return sanitizeHtml(html)\n .replace(/<[^>]+>/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim()\n .slice(0, maxLength)\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/internal/safe-html.ts"],"sourcesContent":["const DANGEROUS_BLOCK_TAGS = new Set([\n \"script\",\n \"style\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"svg\",\n \"math\",\n \"template\",\n \"noscript\",\n \"textarea\",\n \"select\",\n])\n\nconst ALLOWED_TAGS = new Set([\n \"a\",\n \"b\",\n \"blockquote\",\n \"br\",\n \"code\",\n \"del\",\n \"div\",\n \"em\",\n \"hr\",\n \"i\",\n \"img\",\n \"li\",\n \"ol\",\n \"p\",\n \"pre\",\n \"s\",\n \"span\",\n \"strong\",\n \"sub\",\n \"sup\",\n \"table\",\n \"tbody\",\n \"td\",\n \"th\",\n \"thead\",\n \"tr\",\n \"u\",\n \"ul\",\n])\n\nconst VOID_TAGS = new Set([\"br\", \"hr\", \"img\"])\nconst SAFE_GLOBAL_ATTRS = new Set([\"aria-label\", \"dir\", \"lang\", \"role\", \"title\"])\nconst SAFE_URL_PROTOCOLS = new Set([\"http:\", \"https:\", \"mailto:\", \"tel:\"])\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n}\n\nfunction escapeAttribute(value: string): string {\n return escapeHtml(value).replace(/\"/g, \"&quot;\")\n}\n\nfunction safeCodePoint(value: number): string {\n return Number.isInteger(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : \"\"\n}\n\nfunction decodeHtmlEntities(value: string): string {\n const namedEntities: Record<string, string> = {\n amp: \"&\",\n apos: \"'\",\n colon: \":\",\n gt: \">\",\n lt: \"<\",\n newline: \"\\n\",\n quot: '\"',\n tab: \"\\t\",\n }\n\n let decoded = value\n for (let i = 0; i < 4; i += 1) {\n const next = decoded\n .replace(/&#x([0-9a-f]+);?/gi, (_match, hex: string) => {\n const codePoint = Number.parseInt(hex, 16)\n return safeCodePoint(codePoint)\n })\n .replace(/&#(\\d+);?/g, (_match, decimal: string) => {\n const codePoint = Number.parseInt(decimal, 10)\n return safeCodePoint(codePoint)\n })\n .replace(/&([a-z]+);/gi, (match, name: string) => namedEntities[name.toLowerCase()] ?? match)\n\n if (next === decoded) return decoded\n decoded = next\n }\n\n return decoded\n}\n\nfunction isSafeUrl(value: string): boolean {\n const decoded = decodeHtmlEntities(value).replace(/[\\u0000-\\u001f\\u007f\\s]+/g, \"\").trim()\n if (!decoded) return false\n if (decoded.startsWith(\"//\")) return false\n if (decoded.startsWith(\"#\") || decoded.startsWith(\"/\") || decoded.startsWith(\"./\") || decoded.startsWith(\"../\")) {\n return true\n }\n\n try {\n return SAFE_URL_PROTOCOLS.has(new URL(decoded, \"https://handled.local\").protocol)\n } catch {\n return false\n }\n}\n\nfunction sanitizeClassName(value: string): string | null {\n const safeTokens = value\n .split(/\\s+/)\n .map((token) => token.trim())\n .filter((token) => /^[A-Za-z0-9_-]+$/.test(token))\n\n return safeTokens.length ? safeTokens.join(\" \") : null\n}\n\nfunction sanitizeDimension(value: string): string | null {\n const trimmed = value.trim()\n if (/^\\d{1,4}$/.test(trimmed)) return trimmed\n return null\n}\n\nfunction sanitizeLanguage(value: string): string | null {\n const trimmed = value.trim()\n if (/^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$/.test(trimmed)) return trimmed\n return null\n}\n\nfunction sanitizeDirection(value: string): string | null {\n const trimmed = value.trim().toLowerCase()\n return trimmed === \"ltr\" || trimmed === \"rtl\" || trimmed === \"auto\" ? trimmed : null\n}\n\nfunction sanitizeFontSize(value: string): string | null {\n const trimmed = value.trim().toLowerCase()\n const match = trimmed.match(/^(\\d+(?:\\.\\d+)?)(px|em|rem|%)$/)\n if (!match) return null\n\n const amount = Number.parseFloat(match[1])\n const unit = match[2]\n const maxByUnit: Record<string, number> = { px: 72, em: 4, rem: 4, \"%\": 400 }\n if (!Number.isFinite(amount) || amount <= 0 || amount > maxByUnit[unit]) return null\n return `${amount}${unit}`\n}\n\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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handled-ai/design-system",
3
- "version": "0.20.22",
3
+ "version": "0.20.25",
4
4
  "description": "Handled UI component library (shadcn-style, New York)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.12.0",
@@ -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
- expect(quoted.outerHTML).not.toContain("style=");
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
- expect(formatEmailTimestamp("2026-06-08T20:45:00.000Z")).toBe("Jun 8, 2026, 8:45 PM")
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
- expect(screen.getByText("Jun 8, 2026, 3:30 PM")).toBeTruthy()
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(value?: string | Date | null): string | null {
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: "UTC",
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
- return chunks
319
- .map((chunk) => chunk.join(""))
320
- .map((innerHtml) => {
321
- if (wrapper) return wrapHtmlLike(wrapper, innerHtml)
322
- return containsBr ? `<div>${innerHtml}</div>` : innerHtml
323
- })
324
- .map(makeHtmlSegment)
325
- .filter((segment): segment is MessageSegment => Boolean(segment))
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) segments.push(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 (SPLITTABLE_BLOCK_TAGS.has(tagName) && BR_TAG_RE.test(blockHtml)) {
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).forEach((chunk) => {
470
+ const innerChunks = inner.split(BR_TAG_RE)
471
+ innerChunks.forEach((chunk, index) => {
430
472
  const segment = makeHtmlSegment(`${openTag}${chunk}${closeTag}`)
431
- if (segment) segments.push(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
- function getTimelineGongCallDisplay(gongCall: TimelineGongCall) {
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 display = getTimelineEmailDisplay(email)
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 display = getTimelineGongCallDisplay(gongCall)
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&#x3a;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&#x3a;script:alert(1)">bad</a><img src="data:text/html,boom" onerror="alert(1)"></p>',
8
8
  )
9
9
 
10
- expect(html).toBe('<p>Hi<a>bad</a><img></p>')
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 &amp; Co"></td>')
62
64
  expect(html).toContain('<sup>TM</sup><sub>LLC</sub>')
63
- expect(html).toContain('style="vertical-align: super; font-size: 10px"')
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