@handled-ai/design-system 0.20.5 → 0.20.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/components/conversation-panel.d.ts +19 -0
  2. package/dist/components/conversation-panel.js +116 -292
  3. package/dist/components/conversation-panel.js.map +1 -1
  4. package/dist/components/email-body.d.ts +15 -0
  5. package/dist/components/email-body.js +101 -0
  6. package/dist/components/email-body.js.map +1 -0
  7. package/dist/components/email-display-helpers.d.ts +34 -0
  8. package/dist/components/email-display-helpers.js +436 -0
  9. package/dist/components/email-display-helpers.js.map +1 -0
  10. package/dist/components/email-preview-card.d.ts +7 -4
  11. package/dist/components/email-preview-card.js +48 -25
  12. package/dist/components/email-preview-card.js.map +1 -1
  13. package/dist/components/timeline-activity.js +66 -42
  14. package/dist/components/timeline-activity.js.map +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal/safe-html.d.ts +1 -1
  19. package/dist/internal/safe-html.js +64 -3
  20. package/dist/internal/safe-html.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/components/__tests__/conversation-panel.test.tsx +182 -22
  23. package/src/components/__tests__/email-body.test.tsx +83 -0
  24. package/src/components/__tests__/email-display-helpers.test.ts +91 -0
  25. package/src/components/__tests__/email-preview-card.test.tsx +36 -2
  26. package/src/components/__tests__/timeline-activity.test.tsx +53 -1
  27. package/src/components/conversation-panel.tsx +136 -350
  28. package/src/components/email-body.tsx +126 -0
  29. package/src/components/email-display-helpers.ts +557 -0
  30. package/src/components/email-preview-card.tsx +54 -29
  31. package/src/components/timeline-activity.tsx +73 -53
  32. package/src/index.ts +2 -0
  33. package/src/internal/__tests__/safe-html.test.ts +34 -2
  34. package/src/internal/safe-html.ts +79 -4
@@ -4,14 +4,19 @@ import * as React from "react"
4
4
  import { Eye } from "lucide-react"
5
5
 
6
6
  import { cn } from "../lib/utils"
7
+ import { EmailBody } from "./email-body"
8
+ import { decodeEmailDisplayText, formatAddressList, normalizeEmailSender } from "./email-display-helpers"
7
9
 
8
10
  export interface EmailPreviewCardProps {
9
- from: { name: string; email: string }
10
- to?: string
11
+ from: { name?: string | null; email?: string | null }
12
+ to?: string | string[] | null
13
+ cc?: string | string[] | null
14
+ bcc?: string | string[] | null
11
15
  subject?: string
12
16
  htmlBody?: string
13
17
  textBody?: string
14
18
  signatureHtml?: string | null
19
+ signatureText?: string | null
15
20
  className?: string
16
21
  }
17
22
 
@@ -25,26 +30,43 @@ function getInitials(name: string): string {
25
30
  .toUpperCase()
26
31
  }
27
32
 
28
- function escapeHtml(text: string): string {
29
- return text
30
- .replace(/&/g, "&")
31
- .replace(/</g, "&lt;")
32
- .replace(/>/g, "&gt;")
33
- .replace(/"/g, "&quot;")
34
- .replace(/'/g, "&#39;")
33
+ function formatRecipientLabel(to?: string | string[] | null): string {
34
+ const formatted = formatAddressList(to)
35
+ if (!formatted) return "the recipient's"
36
+
37
+ if (Array.isArray(to) && to.length > 1) return "the recipients'"
38
+ return `${formatted}'s`
39
+ }
40
+
41
+ function RecipientRow({ label, value }: { label: string; value: string }) {
42
+ if (!value) return null
43
+
44
+ return (
45
+ <div className="flex items-start gap-2 text-xs text-muted-foreground">
46
+ <span className="w-8 shrink-0 font-medium text-muted-foreground/70">{label}</span>
47
+ <span className="min-w-0 flex-1 break-words">{value}</span>
48
+ </div>
49
+ )
35
50
  }
36
51
 
37
52
  export function EmailPreviewCard({
38
53
  from,
39
54
  to,
55
+ cc,
56
+ bcc,
40
57
  subject,
41
58
  htmlBody,
42
59
  textBody,
43
60
  signatureHtml,
61
+ signatureText,
44
62
  className,
45
63
  }: EmailPreviewCardProps) {
46
- const recipientLabel = to ? `${to}'s` : "the recipient's"
47
- const bodyHtml = htmlBody ?? (textBody ? escapeHtml(textBody) : "")
64
+ const sender = normalizeEmailSender({ name: from.name, email: from.email, fallbackName: from.email ?? "Unknown sender" })
65
+ const toLabel = formatAddressList(to)
66
+ const ccLabel = formatAddressList(cc)
67
+ const bccLabel = formatAddressList(bcc)
68
+ const recipientLabel = formatRecipientLabel(to)
69
+ const subjectLabel = subject ? decodeEmailDisplayText(subject) : "(no subject)"
48
70
 
49
71
  return (
50
72
  <div className={cn("p-4 bg-muted/30 min-h-full", className)}>
@@ -58,36 +80,39 @@ export function EmailPreviewCard({
58
80
 
59
81
  <div className="bg-background border rounded-xl shadow-sm overflow-hidden">
60
82
  <div className="px-[18px] pt-4 pb-3 text-base font-semibold border-b border-border/50">
61
- {subject || "(no subject)"}
83
+ {subjectLabel}
62
84
  </div>
63
85
 
64
- <div className="flex items-center gap-3 px-[18px] pt-3">
86
+ <div className="flex items-start gap-3 px-[18px] pt-3">
65
87
  <div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-foreground text-background text-xs font-semibold">
66
- {getInitials(from.name)}
88
+ {getInitials(sender.name || sender.email || "?") || "?"}
67
89
  </div>
68
90
  <div className="min-w-0 flex-1">
69
91
  <div className="text-sm">
70
- <span className="font-semibold text-foreground">{from.name}</span>{" "}
71
- <span className="text-muted-foreground">&lt;{from.email}&gt;</span>
92
+ <span className="font-semibold text-foreground">{sender.name}</span>{" "}
93
+ {sender.email ? <span className="text-muted-foreground">&lt;{sender.email}&gt;</span> : null}
72
94
  </div>
73
- <div className="text-xs text-muted-foreground">
74
- {to ? `to ${to}` : "to no recipient yet"}
95
+ <div className="mt-1 space-y-0.5">
96
+ <RecipientRow label="To" value={toLabel || "no recipient yet"} />
97
+ <RecipientRow label="Cc" value={ccLabel} />
98
+ <RecipientRow label="Bcc" value={bccLabel} />
75
99
  </div>
76
100
  </div>
77
- <div className="text-xs text-muted-foreground shrink-0">just now</div>
101
+ <div className="text-xs text-muted-foreground shrink-0 pt-0.5">just now</div>
78
102
  </div>
79
103
 
80
- <div
81
- className="px-[18px] py-2 ml-[47px] text-[13.5px] leading-relaxed whitespace-pre-wrap"
82
- dangerouslySetInnerHTML={{ __html: bodyHtml }}
83
- />
84
-
85
- {signatureHtml ? (
86
- <div
87
- className="ml-[47px] px-[18px] pt-3 mt-3 border-t border-border/50 pb-4 text-xs leading-relaxed text-muted-foreground"
88
- dangerouslySetInnerHTML={{ __html: signatureHtml }}
104
+ <div className="px-[18px] py-3 ml-[47px]">
105
+ <EmailBody
106
+ html={htmlBody}
107
+ text={textBody}
108
+ detailsHtml={signatureHtml}
109
+ detailsText={signatureText}
110
+ variant="preview"
111
+ collapseDetails={false}
112
+ defaultDetailsOpen
113
+ className="text-[13.5px]"
89
114
  />
90
- ) : null}
115
+ </div>
91
116
  </div>
92
117
  </div>
93
118
  )
@@ -2,24 +2,15 @@
2
2
 
3
3
  import * as React from "react"
4
4
  import { cn } from "../lib/utils"
5
- import { sanitizeHtml } from "../internal/safe-html"
6
5
  import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react"
7
-
8
- /**
9
- * Gmail-like reading-pane typography for rendered, sanitized email HTML.
10
- * Self-contained Tailwind child-variant utilities — no global CSS. Links use
11
- * Gmail blue; quoted history (`blockquote.gmail_quote`) is de-emphasized with a
12
- * left rule and muted, slightly smaller text.
13
- */
14
- const EMAIL_HTML_CLASS = cn(
15
- "text-sm leading-[1.62] text-foreground/90 break-words",
16
- "[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
17
- "[&_a]:text-[#1a73e8] [&_a]:underline-offset-2 hover:[&_a]:underline",
18
- "[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
19
- "[&_img]:max-w-full [&_img]:h-auto",
20
- "[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_blockquote]:text-[13px]",
21
- "[&_.gmail_quote]:border-l-2 [&_.gmail_quote]:border-border [&_.gmail_quote]:pl-3 [&_.gmail_quote]:text-muted-foreground [&_.gmail_quote]:text-[13px]"
22
- )
6
+ import { EmailBody as SharedEmailBody } from "./email-body"
7
+ import {
8
+ decodeEmailDisplayText,
9
+ emailBodySnippet,
10
+ formatAddressList,
11
+ formatEmailTimestamp,
12
+ normalizeEmailSender,
13
+ } from "./email-display-helpers"
23
14
 
24
15
  export type TimelineActivityVariant = "default" | "case-panel"
25
16
 
@@ -320,6 +311,24 @@ function TimelineItem({
320
311
  type TimelineEmail = NonNullable<TimelineEvent["email"]>
321
312
  type TimelineSource = NonNullable<TimelineEvent["source"]>
322
313
 
314
+ function reactNodeToDisplayText(value: React.ReactNode): string {
315
+ if (typeof value === "string" || typeof value === "number") return decodeEmailDisplayText(String(value))
316
+ return ""
317
+ }
318
+
319
+ function getTimelineEmailDisplay(email: TimelineEmail) {
320
+ const sender = normalizeEmailSender({ name: email.from, email: email.fromEmail })
321
+ const to = formatAddressList(email.to) || decodeEmailDisplayText(email.to ?? "")
322
+ const cc = formatAddressList(email.cc) || decodeEmailDisplayText(email.cc ?? "")
323
+ const bcc = formatAddressList(email.bcc) || decodeEmailDisplayText(email.bcc ?? "")
324
+ const date = formatEmailTimestamp(email.date) ?? decodeEmailDisplayText(email.date ?? "")
325
+ const subject = email.subject ? decodeEmailDisplayText(email.subject) : ""
326
+ const bodyText = reactNodeToDisplayText(email.body)
327
+ const snippet = emailBodySnippet({ bodyHtml: email.bodyHtml, body: bodyText }, 140)
328
+
329
+ return { sender, to, cc, bcc, date, subject, bodyText, snippet }
330
+ }
331
+
323
332
  function EmailMetadata({
324
333
  email,
325
334
  showAllRecipients,
@@ -329,33 +338,35 @@ function EmailMetadata({
329
338
  showAllRecipients: boolean
330
339
  setShowAllRecipients: React.Dispatch<React.SetStateAction<boolean>>
331
340
  }) {
341
+ const display = getTimelineEmailDisplay(email)
342
+
332
343
  return (
333
344
  <>
334
345
  <div className="flex items-center justify-between gap-4">
335
346
  <div className="flex min-w-0 items-baseline gap-1.5">
336
- <span className="font-semibold text-foreground text-[13px] whitespace-nowrap">{email.from}</span>
337
- {email.fromEmail && (
338
- <span className="text-muted-foreground/60 text-xs truncate">{email.fromEmail}</span>
339
- )}
347
+ <span className="font-semibold text-foreground text-[13px] whitespace-nowrap">{display.sender.name}</span>
348
+ {display.sender.email ? (
349
+ <span className="text-muted-foreground/60 text-xs truncate">{display.sender.email}</span>
350
+ ) : null}
340
351
  </div>
341
- {email.date && (
342
- <span className="shrink-0 text-xs text-muted-foreground/50 whitespace-nowrap">{email.date}</span>
343
- )}
352
+ {display.date ? (
353
+ <span className="shrink-0 text-xs text-muted-foreground/50 whitespace-nowrap">{display.date}</span>
354
+ ) : null}
344
355
  </div>
345
356
  <div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
346
357
  <span className="truncate">
347
- To {email.to}
348
- {!showAllRecipients && (email.cc || email.bcc) ? (
358
+ To {display.to || "no recipient yet"}
359
+ {!showAllRecipients && (display.cc || display.bcc) ? (
349
360
  <>, ...</>
350
361
  ) : null}
351
- {showAllRecipients && email.cc ? (
352
- <>, {email.cc}</>
362
+ {showAllRecipients && display.cc ? (
363
+ <>, <span className="text-muted-foreground/40">cc</span> {display.cc}</>
353
364
  ) : null}
354
- {showAllRecipients && email.bcc ? (
355
- <> <span className="text-muted-foreground/40">bcc</span> {email.bcc}</>
365
+ {showAllRecipients && display.bcc ? (
366
+ <> <span className="text-muted-foreground/40">bcc</span> {display.bcc}</>
356
367
  ) : null}
357
368
  </span>
358
- {(email.cc || email.bcc) && (
369
+ {(display.cc || display.bcc) ? (
359
370
  <button
360
371
  type="button"
361
372
  onClick={(e) => {
@@ -366,30 +377,36 @@ function EmailMetadata({
366
377
  >
367
378
  <ChevronDown className={cn("h-3 w-3 transition-transform", showAllRecipients && "rotate-180")} />
368
379
  </button>
369
- )}
380
+ ) : null}
370
381
  </div>
371
382
  </>
372
383
  )
373
384
  }
374
385
 
375
- function EmailBody({ email }: { email: TimelineEmail }) {
376
- if (email.bodyHtml) {
377
- return (
378
- // Gmail reading-pane typography; quoted history
379
- // (blockquote.gmail_quote) is de-emphasized with a left rule.
380
- <div
381
- data-slot="timeline-email-html"
382
- className={EMAIL_HTML_CLASS}
383
- dangerouslySetInnerHTML={{ __html: sanitizeHtml(email.bodyHtml) }}
384
- />
385
- )
386
+ function TimelineEmailBody({ email }: { email: TimelineEmail }) {
387
+ const display = getTimelineEmailDisplay(email)
388
+ const bodyFallback = display.bodyText || (typeof email.body === "string" ? email.body : "")
389
+
390
+ if (!email.bodyHtml && !bodyFallback && email.body) {
391
+ return <div className="whitespace-pre-line text-sm leading-relaxed text-foreground/90">{email.body}</div>
386
392
  }
387
393
 
388
- return (
389
- <div className="whitespace-pre-line text-sm leading-relaxed text-foreground/90">
390
- {email.body}
391
- </div>
394
+ const body = (
395
+ <SharedEmailBody
396
+ html={email.bodyHtml}
397
+ text={bodyFallback}
398
+ variant="history"
399
+ collapseDetails={true}
400
+ className="text-sm leading-relaxed"
401
+ />
392
402
  )
403
+
404
+ return email.bodyHtml ? <div data-slot="timeline-email-html">{body}</div> : body
405
+ }
406
+
407
+ function renderDecodedPreview(preview?: React.ReactNode): React.ReactNode {
408
+ if (typeof preview === "string" || typeof preview === "number") return decodeEmailDisplayText(String(preview))
409
+ return preview
393
410
  }
394
411
 
395
412
  function CollapsedEmailPreview({
@@ -405,18 +422,21 @@ function CollapsedEmailPreview({
405
422
  actionClassName: string
406
423
  onClick?: () => void
407
424
  }) {
425
+ const display = email ? getTimelineEmailDisplay(email) : null
426
+ const previewContent = display?.snippet || renderDecodedPreview(preview)
427
+
408
428
  return (
409
429
  <div className={className} onClick={onClick}>
410
430
  <span className="line-clamp-1 pr-3 text-[13px]">
411
- <span className="text-muted-foreground">{email?.from}</span>
431
+ <span className="text-muted-foreground">{display?.sender.name}</span>
412
432
  <span className="mx-1.5 text-muted-foreground/40">&middot;</span>
413
- {email?.subject ? (
433
+ {display?.subject ? (
414
434
  <>
415
- <span className="text-muted-foreground">{email.subject}</span>
435
+ <span className="text-muted-foreground">{display.subject}</span>
416
436
  <span className="mx-1.5 text-muted-foreground/40">&middot;</span>
417
437
  </>
418
438
  ) : null}
419
- <span className="text-muted-foreground">{preview}</span>
439
+ <span className="text-muted-foreground">{previewContent}</span>
420
440
  </span>
421
441
  <button type="button" className={actionClassName}>
422
442
  Expand <ChevronDown className="h-3 w-3" />
@@ -513,7 +533,7 @@ function EmailCard({
513
533
  />
514
534
  </div>
515
535
 
516
- <EmailBody email={event.email} />
536
+ <TimelineEmailBody email={event.email} />
517
537
 
518
538
  <ShowLessButton
519
539
  onClick={(e) => {
@@ -549,7 +569,7 @@ function EmailCard({
549
569
  </div>
550
570
 
551
571
  <div className={classes.cardBody} data-slot="timeline-card-body">
552
- <EmailBody email={event.email} />
572
+ <TimelineEmailBody email={event.email} />
553
573
  </div>
554
574
 
555
575
  <div className={cn(classes.cardFooter, classes.actionLinkRow)} data-slot="timeline-card-footer">
package/src/index.ts CHANGED
@@ -21,7 +21,9 @@ export * from "./components/badge"
21
21
  export * from "./components/button"
22
22
  export * from "./components/card"
23
23
  export * from "./components/case-panel-email-composer"
24
+ export * from "./components/email-body"
24
25
  export * from "./components/email-composer-row"
26
+ export * from "./components/email-display-helpers"
25
27
  export * from "./components/email-preview-card"
26
28
  export * from "./components/email-recipient-field"
27
29
  export * from "./components/email-send-bar"
@@ -10,6 +10,12 @@ describe("sanitizeHtml", () => {
10
10
  expect(html).toBe('<p>Hi<a>bad</a><img></p>')
11
11
  })
12
12
 
13
+ it("removes svg, math, and other active embedded content", () => {
14
+ const html = sanitizeHtml('<p>Safe</p><svg><a href="https://evil.test">svg link</a></svg><math><mi>x</mi></math><object data="https://evil.test/x"></object>')
15
+
16
+ expect(html).toBe("<p>Safe</p>")
17
+ })
18
+
13
19
  it("rejects entity-obfuscated unsafe protocols", () => {
14
20
  const html = sanitizeHtml('<a href="jav&#x61;script&colon;alert(1)">bad</a><a href="java&amp;#x73;cript:alert(1)">also bad</a>')
15
21
 
@@ -35,14 +41,40 @@ describe("sanitizeHtml", () => {
35
41
 
36
42
  it("keeps common email formatting, safe links, and gmail_quote classes", () => {
37
43
  const html = sanitizeHtml(
38
- '<blockquote class="gmail_quote weird$class" style="color:red"><a href="https://example.com" target="_self">Quoted</a><br><ul><li>One</li></ul></blockquote>',
44
+ '<blockquote class="gmail_quote weird$class"><a href="https://example.com" target="_self">Quoted</a><br><ul><li>One</li></ul></blockquote>',
39
45
  )
40
46
 
41
47
  expect(html).toContain('class="gmail_quote"')
42
48
  expect(html).toContain('href="https://example.com"')
43
49
  expect(html).toContain('rel="noopener noreferrer"')
44
50
  expect(html).toContain("<ul><li>One</li></ul>")
45
- expect(html).not.toContain("style=")
51
+ })
52
+
53
+ it("preserves high-fidelity safe email signature markup", () => {
54
+ const html = sanitizeHtml(
55
+ '<div dir="ltr" lang="en-US" class="gmail_signature sig$v"><table><tbody><tr><td><img src="https://example.com/logo.png" width="120" height="48" alt="Acme & Co" onerror="alert(1)"></td><td><sup>TM</sup><sub>LLC</sub><span style="vertical-align: super; font-size: 10px; color: red; background-image: url(https://evil.test/x)">1</span></td></tr></tbody></table></div>',
56
+ )
57
+
58
+ expect(html).toContain('dir="ltr"')
59
+ expect(html).toContain('lang="en-US"')
60
+ expect(html).toContain('class="gmail_signature"')
61
+ expect(html).toContain('<table><tbody><tr><td><img src="https://example.com/logo.png" width="120" height="48" alt="Acme &amp; Co"></td>')
62
+ expect(html).toContain('<sup>TM</sup><sub>LLC</sub>')
63
+ expect(html).toContain('style="vertical-align: super; font-size: 10px"')
64
+ expect(html).not.toContain("onerror")
65
+ expect(html).not.toContain("color: red")
66
+ expect(html).not.toContain("background-image")
67
+ })
68
+
69
+ it("bounds image dimensions and inline font-size while preserving subscript alignment", () => {
70
+ const html = sanitizeHtml(
71
+ '<span style="vertical-align: sub; font-size: 500px">H2O</span><img src="https://example.com/x.png" width="99999" height="64" alt="x">',
72
+ )
73
+
74
+ expect(html).toContain('style="vertical-align: sub"')
75
+ expect(html).not.toContain("500px")
76
+ expect(html).toContain('height="64"')
77
+ expect(html).not.toContain('width="99999"')
46
78
  })
47
79
  })
48
80
 
@@ -31,6 +31,8 @@ const ALLOWED_TAGS = new Set([
31
31
  "s",
32
32
  "span",
33
33
  "strong",
34
+ "sub",
35
+ "sup",
34
36
  "table",
35
37
  "tbody",
36
38
  "td",
@@ -42,7 +44,7 @@ const ALLOWED_TAGS = new Set([
42
44
  ])
43
45
 
44
46
  const VOID_TAGS = new Set(["br", "hr", "img"])
45
- const SAFE_GLOBAL_ATTRS = new Set(["aria-label", "role", "title"])
47
+ const SAFE_GLOBAL_ATTRS = new Set(["aria-label", "dir", "lang", "role", "title"])
46
48
  const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"])
47
49
 
48
50
  function escapeHtml(value: string): string {
@@ -116,12 +118,65 @@ function sanitizeClassName(value: string): string | null {
116
118
  return safeTokens.length ? safeTokens.join(" ") : null
117
119
  }
118
120
 
121
+ function sanitizeDimension(value: string): string | null {
122
+ const trimmed = value.trim()
123
+ if (/^\d{1,4}$/.test(trimmed)) return trimmed
124
+ return null
125
+ }
126
+
127
+ function sanitizeLanguage(value: string): string | null {
128
+ const trimmed = value.trim()
129
+ if (/^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$/.test(trimmed)) return trimmed
130
+ return null
131
+ }
132
+
133
+ function sanitizeDirection(value: string): string | null {
134
+ const trimmed = value.trim().toLowerCase()
135
+ return trimmed === "ltr" || trimmed === "rtl" || trimmed === "auto" ? trimmed : null
136
+ }
137
+
138
+ function sanitizeFontSize(value: string): string | null {
139
+ const trimmed = value.trim().toLowerCase()
140
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)(px|em|rem|%)$/)
141
+ if (!match) return null
142
+
143
+ const amount = Number.parseFloat(match[1])
144
+ const unit = match[2]
145
+ const maxByUnit: Record<string, number> = { px: 72, em: 4, rem: 4, "%": 400 }
146
+ if (!Number.isFinite(amount) || amount <= 0 || amount > maxByUnit[unit]) return null
147
+ return `${amount}${unit}`
148
+ }
149
+
150
+ function sanitizeStyle(value: string): string | null {
151
+ const declarations: string[] = []
152
+
153
+ for (const rawDeclaration of value.split(";")) {
154
+ const separatorIndex = rawDeclaration.indexOf(":")
155
+ if (separatorIndex === -1) continue
156
+
157
+ const property = rawDeclaration.slice(0, separatorIndex).trim().toLowerCase()
158
+ const rawValue = decodeHtmlEntities(rawDeclaration.slice(separatorIndex + 1)).trim().toLowerCase()
159
+ if (!property || !rawValue || /url\s*\(|expression\s*\(/i.test(rawValue)) continue
160
+
161
+ if (property === "vertical-align" && (rawValue === "super" || rawValue === "sub")) {
162
+ declarations.push(`vertical-align: ${rawValue}`)
163
+ continue
164
+ }
165
+
166
+ if (property === "font-size") {
167
+ const fontSize = sanitizeFontSize(rawValue)
168
+ if (fontSize) declarations.push(`font-size: ${fontSize}`)
169
+ }
170
+ }
171
+
172
+ return declarations.length ? declarations.join("; ") : null
173
+ }
174
+
119
175
  function sanitizeAttribute(tagName: string, name: string, value: string): string | null {
120
176
  const attr = name.toLowerCase()
121
177
 
122
178
  if (
123
179
  attr.startsWith("on") ||
124
- attr === "style" ||
125
180
  attr === "srcdoc" ||
126
181
  attr === "formaction" ||
127
182
  attr === "xlink:href" ||
@@ -130,11 +185,26 @@ function sanitizeAttribute(tagName: string, name: string, value: string): string
130
185
  return null
131
186
  }
132
187
 
188
+ if (attr === "style") {
189
+ const safeStyle = sanitizeStyle(value)
190
+ return safeStyle ? `style="${escapeAttribute(safeStyle)}"` : null
191
+ }
192
+
133
193
  if (attr === "class") {
134
194
  const safeClassName = sanitizeClassName(value)
135
195
  return safeClassName ? `class="${escapeAttribute(safeClassName)}"` : null
136
196
  }
137
197
 
198
+ if (attr === "dir") {
199
+ const safeDirection = sanitizeDirection(value)
200
+ return safeDirection ? `dir="${escapeAttribute(safeDirection)}"` : null
201
+ }
202
+
203
+ if (attr === "lang") {
204
+ const safeLanguage = sanitizeLanguage(value)
205
+ return safeLanguage ? `lang="${escapeAttribute(safeLanguage)}"` : null
206
+ }
207
+
138
208
  if (SAFE_GLOBAL_ATTRS.has(attr) || attr.startsWith("aria-")) {
139
209
  return `${attr}="${escapeAttribute(value)}"`
140
210
  }
@@ -147,10 +217,15 @@ function sanitizeAttribute(tagName: string, name: string, value: string): string
147
217
  return `src="${escapeAttribute(value)}"`
148
218
  }
149
219
 
150
- if (tagName === "img" && (attr === "alt" || attr === "width" || attr === "height")) {
220
+ if (tagName === "img" && attr === "alt") {
151
221
  return `${attr}="${escapeAttribute(value)}"`
152
222
  }
153
223
 
224
+ if (tagName === "img" && (attr === "width" || attr === "height")) {
225
+ const safeDimension = sanitizeDimension(value)
226
+ return safeDimension ? `${attr}="${escapeAttribute(safeDimension)}"` : null
227
+ }
228
+
154
229
  if ((tagName === "td" || tagName === "th") && (attr === "colspan" || attr === "rowspan")) {
155
230
  return `${attr}="${escapeAttribute(value)}"`
156
231
  }
@@ -219,7 +294,7 @@ function findDangerousClose(html: string, tagName: string, fromIndex: number): n
219
294
  /**
220
295
  * Conservative, deterministic sanitizer for user/email supplied HTML rendered by
221
296
  * design-system components. It keeps common email formatting tags while removing
222
- * executable tags, event handlers, inline styles, and unsafe URLs. This stays
297
+ * executable tags, event handlers, unsafe inline styles, and unsafe URLs. This stays
223
298
  * dependency-free for the shared package and intentionally favors stripping
224
299
  * ambiguous email content over preserving every possible HTML feature.
225
300
  */