@handled-ai/design-system 0.20.5 → 0.20.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/conversation-panel.d.ts +19 -0
- package/dist/components/conversation-panel.js +116 -292
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/email-body.d.ts +15 -0
- package/dist/components/email-body.js +101 -0
- package/dist/components/email-body.js.map +1 -0
- package/dist/components/email-display-helpers.d.ts +34 -0
- package/dist/components/email-display-helpers.js +436 -0
- package/dist/components/email-display-helpers.js.map +1 -0
- package/dist/components/email-preview-card.d.ts +7 -4
- package/dist/components/email-preview-card.js +48 -25
- package/dist/components/email-preview-card.js.map +1 -1
- package/dist/components/timeline-activity.js +66 -42
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/safe-html.d.ts +1 -1
- package/dist/internal/safe-html.js +64 -3
- package/dist/internal/safe-html.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +182 -22
- package/src/components/__tests__/email-body.test.tsx +83 -0
- package/src/components/__tests__/email-display-helpers.test.ts +91 -0
- package/src/components/__tests__/email-preview-card.test.tsx +36 -2
- package/src/components/__tests__/timeline-activity.test.tsx +53 -1
- package/src/components/conversation-panel.tsx +136 -350
- package/src/components/email-body.tsx +126 -0
- package/src/components/email-display-helpers.ts +557 -0
- package/src/components/email-preview-card.tsx +54 -29
- package/src/components/timeline-activity.tsx +73 -53
- package/src/index.ts +2 -0
- package/src/internal/__tests__/safe-html.test.ts +34 -2
- package/src/internal/safe-html.ts +79 -4
|
@@ -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
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
47
|
-
const
|
|
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
|
-
{
|
|
83
|
+
{subjectLabel}
|
|
62
84
|
</div>
|
|
63
85
|
|
|
64
|
-
<div className="flex items-
|
|
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(
|
|
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">{
|
|
71
|
-
<span className="text-muted-foreground"><{
|
|
92
|
+
<span className="font-semibold text-foreground">{sender.name}</span>{" "}
|
|
93
|
+
{sender.email ? <span className="text-muted-foreground"><{sender.email}></span> : null}
|
|
72
94
|
</div>
|
|
73
|
-
<div className="
|
|
74
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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">{
|
|
337
|
-
{email
|
|
338
|
-
<span className="text-muted-foreground/60 text-xs truncate">{email
|
|
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
|
-
{
|
|
342
|
-
<span className="shrink-0 text-xs text-muted-foreground/50 whitespace-nowrap">{
|
|
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 {
|
|
348
|
-
{!showAllRecipients && (
|
|
358
|
+
To {display.to || "no recipient yet"}
|
|
359
|
+
{!showAllRecipients && (display.cc || display.bcc) ? (
|
|
349
360
|
<>, ...</>
|
|
350
361
|
) : null}
|
|
351
|
-
{showAllRecipients &&
|
|
352
|
-
<>, {
|
|
362
|
+
{showAllRecipients && display.cc ? (
|
|
363
|
+
<>, <span className="text-muted-foreground/40">cc</span> {display.cc}</>
|
|
353
364
|
) : null}
|
|
354
|
-
{showAllRecipients &&
|
|
355
|
-
<> <span className="text-muted-foreground/40">bcc</span> {
|
|
365
|
+
{showAllRecipients && display.bcc ? (
|
|
366
|
+
<> <span className="text-muted-foreground/40">bcc</span> {display.bcc}</>
|
|
356
367
|
) : null}
|
|
357
368
|
</span>
|
|
358
|
-
{(
|
|
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
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
389
|
-
<
|
|
390
|
-
{email.
|
|
391
|
-
|
|
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">{
|
|
431
|
+
<span className="text-muted-foreground">{display?.sender.name}</span>
|
|
412
432
|
<span className="mx-1.5 text-muted-foreground/40">·</span>
|
|
413
|
-
{
|
|
433
|
+
{display?.subject ? (
|
|
414
434
|
<>
|
|
415
|
-
<span className="text-muted-foreground">{
|
|
435
|
+
<span className="text-muted-foreground">{display.subject}</span>
|
|
416
436
|
<span className="mx-1.5 text-muted-foreground/40">·</span>
|
|
417
437
|
</>
|
|
418
438
|
) : null}
|
|
419
|
-
<span className="text-muted-foreground">{
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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="javascript:alert(1)">bad</a><a href="java&#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"
|
|
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
|
-
|
|
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 & 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" &&
|
|
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
|
*/
|