@handled-ai/design-system 0.20.5 → 0.20.7

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 (35) 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.d.ts +1 -0
  14. package/dist/components/timeline-activity.js +116 -65
  15. package/dist/components/timeline-activity.js.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +2 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/internal/safe-html.d.ts +1 -1
  20. package/dist/internal/safe-html.js +64 -3
  21. package/dist/internal/safe-html.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/components/__tests__/conversation-panel.test.tsx +182 -22
  24. package/src/components/__tests__/email-body.test.tsx +83 -0
  25. package/src/components/__tests__/email-display-helpers.test.ts +91 -0
  26. package/src/components/__tests__/email-preview-card.test.tsx +36 -2
  27. package/src/components/__tests__/timeline-activity.test.tsx +87 -1
  28. package/src/components/conversation-panel.tsx +136 -350
  29. package/src/components/email-body.tsx +126 -0
  30. package/src/components/email-display-helpers.ts +557 -0
  31. package/src/components/email-preview-card.tsx +54 -29
  32. package/src/components/timeline-activity.tsx +105 -63
  33. package/src/index.ts +2 -0
  34. package/src/internal/__tests__/safe-html.test.ts +34 -2
  35. 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
 
@@ -67,6 +58,7 @@ export interface TimelineEvent {
67
58
  content?: React.ReactNode
68
59
  source?: {
69
60
  label: string
61
+ actionLabel?: string
70
62
  url: string
71
63
  }
72
64
  defaultExpanded?: boolean
@@ -320,6 +312,24 @@ function TimelineItem({
320
312
  type TimelineEmail = NonNullable<TimelineEvent["email"]>
321
313
  type TimelineSource = NonNullable<TimelineEvent["source"]>
322
314
 
315
+ function reactNodeToDisplayText(value: React.ReactNode): string {
316
+ if (typeof value === "string" || typeof value === "number") return decodeEmailDisplayText(String(value))
317
+ return ""
318
+ }
319
+
320
+ function getTimelineEmailDisplay(email: TimelineEmail) {
321
+ const sender = normalizeEmailSender({ name: email.from, email: email.fromEmail })
322
+ const to = formatAddressList(email.to) || decodeEmailDisplayText(email.to ?? "")
323
+ const cc = formatAddressList(email.cc) || decodeEmailDisplayText(email.cc ?? "")
324
+ const bcc = formatAddressList(email.bcc) || decodeEmailDisplayText(email.bcc ?? "")
325
+ const date = formatEmailTimestamp(email.date) ?? decodeEmailDisplayText(email.date ?? "")
326
+ const subject = email.subject ? decodeEmailDisplayText(email.subject) : ""
327
+ const bodyText = reactNodeToDisplayText(email.body)
328
+ const snippet = emailBodySnippet({ bodyHtml: email.bodyHtml, body: bodyText }, 140)
329
+
330
+ return { sender, to, cc, bcc, date, subject, bodyText, snippet }
331
+ }
332
+
323
333
  function EmailMetadata({
324
334
  email,
325
335
  showAllRecipients,
@@ -329,33 +339,35 @@ function EmailMetadata({
329
339
  showAllRecipients: boolean
330
340
  setShowAllRecipients: React.Dispatch<React.SetStateAction<boolean>>
331
341
  }) {
342
+ const display = getTimelineEmailDisplay(email)
343
+
332
344
  return (
333
345
  <>
334
346
  <div className="flex items-center justify-between gap-4">
335
347
  <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
- )}
348
+ <span className="font-semibold text-foreground text-[13px] whitespace-nowrap">{display.sender.name}</span>
349
+ {display.sender.email ? (
350
+ <span className="text-muted-foreground/60 text-xs truncate">{display.sender.email}</span>
351
+ ) : null}
340
352
  </div>
341
- {email.date && (
342
- <span className="shrink-0 text-xs text-muted-foreground/50 whitespace-nowrap">{email.date}</span>
343
- )}
353
+ {display.date ? (
354
+ <span className="shrink-0 text-xs text-muted-foreground/50 whitespace-nowrap">{display.date}</span>
355
+ ) : null}
344
356
  </div>
345
357
  <div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
346
358
  <span className="truncate">
347
- To {email.to}
348
- {!showAllRecipients && (email.cc || email.bcc) ? (
359
+ To {display.to || "no recipient yet"}
360
+ {!showAllRecipients && (display.cc || display.bcc) ? (
349
361
  <>, ...</>
350
362
  ) : null}
351
- {showAllRecipients && email.cc ? (
352
- <>, {email.cc}</>
363
+ {showAllRecipients && display.cc ? (
364
+ <>, <span className="text-muted-foreground/40">cc</span> {display.cc}</>
353
365
  ) : null}
354
- {showAllRecipients && email.bcc ? (
355
- <> <span className="text-muted-foreground/40">bcc</span> {email.bcc}</>
366
+ {showAllRecipients && display.bcc ? (
367
+ <> <span className="text-muted-foreground/40">bcc</span> {display.bcc}</>
356
368
  ) : null}
357
369
  </span>
358
- {(email.cc || email.bcc) && (
370
+ {(display.cc || display.bcc) ? (
359
371
  <button
360
372
  type="button"
361
373
  onClick={(e) => {
@@ -366,30 +378,36 @@ function EmailMetadata({
366
378
  >
367
379
  <ChevronDown className={cn("h-3 w-3 transition-transform", showAllRecipients && "rotate-180")} />
368
380
  </button>
369
- )}
381
+ ) : null}
370
382
  </div>
371
383
  </>
372
384
  )
373
385
  }
374
386
 
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
- )
387
+ function TimelineEmailBody({ email }: { email: TimelineEmail }) {
388
+ const display = getTimelineEmailDisplay(email)
389
+ const bodyFallback = display.bodyText || (typeof email.body === "string" ? email.body : "")
390
+
391
+ if (!email.bodyHtml && !bodyFallback && email.body) {
392
+ return <div className="whitespace-pre-line text-sm leading-relaxed text-foreground/90">{email.body}</div>
386
393
  }
387
394
 
388
- return (
389
- <div className="whitespace-pre-line text-sm leading-relaxed text-foreground/90">
390
- {email.body}
391
- </div>
395
+ const body = (
396
+ <SharedEmailBody
397
+ html={email.bodyHtml}
398
+ text={bodyFallback}
399
+ variant="history"
400
+ collapseDetails={true}
401
+ className="text-sm leading-relaxed"
402
+ />
392
403
  )
404
+
405
+ return email.bodyHtml ? <div data-slot="timeline-email-html">{body}</div> : body
406
+ }
407
+
408
+ function renderDecodedPreview(preview?: React.ReactNode): React.ReactNode {
409
+ if (typeof preview === "string" || typeof preview === "number") return decodeEmailDisplayText(String(preview))
410
+ return preview
393
411
  }
394
412
 
395
413
  function CollapsedEmailPreview({
@@ -405,18 +423,21 @@ function CollapsedEmailPreview({
405
423
  actionClassName: string
406
424
  onClick?: () => void
407
425
  }) {
426
+ const display = email ? getTimelineEmailDisplay(email) : null
427
+ const previewContent = display?.snippet || renderDecodedPreview(preview)
428
+
408
429
  return (
409
430
  <div className={className} onClick={onClick}>
410
431
  <span className="line-clamp-1 pr-3 text-[13px]">
411
- <span className="text-muted-foreground">{email?.from}</span>
432
+ <span className="text-muted-foreground">{display?.sender.name}</span>
412
433
  <span className="mx-1.5 text-muted-foreground/40">&middot;</span>
413
- {email?.subject ? (
434
+ {display?.subject ? (
414
435
  <>
415
- <span className="text-muted-foreground">{email.subject}</span>
436
+ <span className="text-muted-foreground">{display.subject}</span>
416
437
  <span className="mx-1.5 text-muted-foreground/40">&middot;</span>
417
438
  </>
418
439
  ) : null}
419
- <span className="text-muted-foreground">{preview}</span>
440
+ <span className="text-muted-foreground">{previewContent}</span>
420
441
  </span>
421
442
  <button type="button" className={actionClassName}>
422
443
  Expand <ChevronDown className="h-3 w-3" />
@@ -450,6 +471,8 @@ function SourceAction({
450
471
  onSourceClick?: () => void
451
472
  className: string
452
473
  }) {
474
+ const actionLabel = source.actionLabel ?? `Open in ${source.label}`
475
+
453
476
  if (onSourceClick) {
454
477
  return (
455
478
  <button
@@ -457,7 +480,7 @@ function SourceAction({
457
480
  onClick={(e) => { e.stopPropagation(); onSourceClick(); }}
458
481
  className={className}
459
482
  >
460
- Open in {source.label}
483
+ {actionLabel}
461
484
  <ExternalLink className="h-3 w-3" />
462
485
  </button>
463
486
  )
@@ -470,7 +493,7 @@ function SourceAction({
470
493
  rel="noreferrer noopener"
471
494
  className={className}
472
495
  >
473
- Open in {source.label}
496
+ {actionLabel}
474
497
  <ExternalLink className="h-3 w-3" />
475
498
  </a>
476
499
  )
@@ -513,15 +536,24 @@ function EmailCard({
513
536
  />
514
537
  </div>
515
538
 
516
- <EmailBody email={event.email} />
539
+ <TimelineEmailBody email={event.email} />
517
540
 
518
- <ShowLessButton
519
- onClick={(e) => {
520
- e.stopPropagation()
521
- setExpanded(false)
522
- }}
523
- className="mt-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
524
- />
541
+ <div className="mt-2 flex items-center gap-3">
542
+ {event.source ? (
543
+ <SourceAction
544
+ source={event.source}
545
+ onSourceClick={event.onSourceClick}
546
+ className="mr-auto inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
547
+ />
548
+ ) : null}
549
+ <ShowLessButton
550
+ onClick={(e) => {
551
+ e.stopPropagation()
552
+ setExpanded(false)
553
+ }}
554
+ className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
555
+ />
556
+ </div>
525
557
  </div>
526
558
  ) : (
527
559
  <CollapsedEmailPreview
@@ -549,10 +581,20 @@ function EmailCard({
549
581
  </div>
550
582
 
551
583
  <div className={classes.cardBody} data-slot="timeline-card-body">
552
- <EmailBody email={event.email} />
584
+ <TimelineEmailBody email={event.email} />
553
585
  </div>
554
586
 
555
- <div className={cn(classes.cardFooter, classes.actionLinkRow)} data-slot="timeline-card-footer">
587
+ <div
588
+ className={cn(classes.cardFooter, classes.actionLinkRow, event.source ? "justify-between" : "justify-end")}
589
+ data-slot="timeline-card-footer"
590
+ >
591
+ {event.source ? (
592
+ <SourceAction
593
+ source={event.source}
594
+ onSourceClick={event.onSourceClick}
595
+ className={classes.actionLink}
596
+ />
597
+ ) : null}
556
598
  <ShowLessButton
557
599
  type="button"
558
600
  onClick={(e) => {
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
  */