@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.
- 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.d.ts +1 -0
- package/dist/components/timeline-activity.js +116 -65
- 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 +87 -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 +105 -63
- 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
|
|
|
@@ -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">{
|
|
337
|
-
{email
|
|
338
|
-
<span className="text-muted-foreground/60 text-xs truncate">{email
|
|
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
|
-
{
|
|
342
|
-
<span className="shrink-0 text-xs text-muted-foreground/50 whitespace-nowrap">{
|
|
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 {
|
|
348
|
-
{!showAllRecipients && (
|
|
359
|
+
To {display.to || "no recipient yet"}
|
|
360
|
+
{!showAllRecipients && (display.cc || display.bcc) ? (
|
|
349
361
|
<>, ...</>
|
|
350
362
|
) : null}
|
|
351
|
-
{showAllRecipients &&
|
|
352
|
-
<>, {
|
|
363
|
+
{showAllRecipients && display.cc ? (
|
|
364
|
+
<>, <span className="text-muted-foreground/40">cc</span> {display.cc}</>
|
|
353
365
|
) : null}
|
|
354
|
-
{showAllRecipients &&
|
|
355
|
-
<> <span className="text-muted-foreground/40">bcc</span> {
|
|
366
|
+
{showAllRecipients && display.bcc ? (
|
|
367
|
+
<> <span className="text-muted-foreground/40">bcc</span> {display.bcc}</>
|
|
356
368
|
) : null}
|
|
357
369
|
</span>
|
|
358
|
-
{(
|
|
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
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
389
|
-
<
|
|
390
|
-
{email.
|
|
391
|
-
|
|
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">{
|
|
432
|
+
<span className="text-muted-foreground">{display?.sender.name}</span>
|
|
412
433
|
<span className="mx-1.5 text-muted-foreground/40">·</span>
|
|
413
|
-
{
|
|
434
|
+
{display?.subject ? (
|
|
414
435
|
<>
|
|
415
|
-
<span className="text-muted-foreground">{
|
|
436
|
+
<span className="text-muted-foreground">{display.subject}</span>
|
|
416
437
|
<span className="mx-1.5 text-muted-foreground/40">·</span>
|
|
417
438
|
</>
|
|
418
439
|
) : null}
|
|
419
|
-
<span className="text-muted-foreground">{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
539
|
+
<TimelineEmailBody email={event.email} />
|
|
517
540
|
|
|
518
|
-
<
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
<
|
|
584
|
+
<TimelineEmailBody email={event.email} />
|
|
553
585
|
</div>
|
|
554
586
|
|
|
555
|
-
<div
|
|
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="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
|
*/
|