@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { sanitizeHtml } from "../internal/safe-html"
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
import {
|
|
8
|
+
decodeEmailDisplayText,
|
|
9
|
+
splitEmailHtmlForDisplay,
|
|
10
|
+
splitEmailTextForDisplay,
|
|
11
|
+
} from "./email-display-helpers"
|
|
12
|
+
|
|
13
|
+
export interface EmailBodyProps {
|
|
14
|
+
html?: string | null
|
|
15
|
+
text?: string | null
|
|
16
|
+
detailsHtml?: string | null
|
|
17
|
+
detailsText?: string | null
|
|
18
|
+
collapseDetails?: boolean
|
|
19
|
+
defaultDetailsOpen?: boolean
|
|
20
|
+
variant?: "history" | "preview"
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PROSE = cn(
|
|
25
|
+
"break-words leading-[1.62]",
|
|
26
|
+
"[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
|
|
27
|
+
"[&_a]:text-[#1a73e8] [&_a]:underline-offset-2 hover:[&_a]:underline",
|
|
28
|
+
"[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
|
|
29
|
+
"[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
|
|
30
|
+
"[&_table]:my-2 [&_table]:max-w-full [&_table]:border-collapse",
|
|
31
|
+
"[&_td]:align-top [&_td]:pr-2 [&_th]:align-top [&_th]:pr-2",
|
|
32
|
+
"[&_img]:max-w-full [&_img]:h-auto",
|
|
33
|
+
"[&_sup]:align-super [&_sup]:text-[0.75em] [&_sub]:align-sub [&_sub]:text-[0.75em]",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
function PlainTextBlock({ text, className, slot }: { text: string; className?: string; slot: string }) {
|
|
37
|
+
return (
|
|
38
|
+
<div data-slot={slot} className={cn(PROSE, "whitespace-pre-line", className)}>
|
|
39
|
+
{decodeEmailDisplayText(text)}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function HtmlBlock({ html, className, slot }: { html: string; className?: string; slot: string }) {
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
data-slot={slot}
|
|
48
|
+
className={cn(PROSE, className)}
|
|
49
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function EmailBody({
|
|
55
|
+
html,
|
|
56
|
+
text,
|
|
57
|
+
detailsHtml,
|
|
58
|
+
detailsText,
|
|
59
|
+
collapseDetails = false,
|
|
60
|
+
defaultDetailsOpen = false,
|
|
61
|
+
variant = "history",
|
|
62
|
+
className,
|
|
63
|
+
}: EmailBodyProps) {
|
|
64
|
+
const [detailsOpen, setDetailsOpen] = React.useState(defaultDetailsOpen)
|
|
65
|
+
|
|
66
|
+
const htmlParts = html ? splitEmailHtmlForDisplay(html) : null
|
|
67
|
+
const textParts = html ? null : splitEmailTextForDisplay(text ?? "")
|
|
68
|
+
|
|
69
|
+
const bodyHtml = htmlParts?.bodyHtml ?? ""
|
|
70
|
+
const bodyText = textParts?.bodyText ?? ""
|
|
71
|
+
const combinedDetailsHtml = [htmlParts?.detailsHtml, detailsHtml].filter(Boolean).join("")
|
|
72
|
+
const combinedDetailsText = [textParts?.detailsText, detailsText].filter(Boolean).join("")
|
|
73
|
+
const hasBody = Boolean(bodyHtml.trim() || bodyText.trim())
|
|
74
|
+
const hasDetails = Boolean(combinedDetailsHtml.trim() || combinedDetailsText.trim())
|
|
75
|
+
const shouldCollapseDetails = collapseDetails && hasDetails
|
|
76
|
+
const showDetails = hasDetails && (!shouldCollapseDetails || detailsOpen)
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
data-slot="email-body"
|
|
81
|
+
data-variant={variant}
|
|
82
|
+
className={cn(
|
|
83
|
+
"text-sm text-foreground/90",
|
|
84
|
+
variant === "preview" && "text-[13.5px]",
|
|
85
|
+
className,
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
{hasBody ? (
|
|
89
|
+
htmlParts ? (
|
|
90
|
+
<HtmlBlock slot="email-body-content" html={bodyHtml} />
|
|
91
|
+
) : (
|
|
92
|
+
<PlainTextBlock slot="email-body-content" text={bodyText} />
|
|
93
|
+
)
|
|
94
|
+
) : null}
|
|
95
|
+
|
|
96
|
+
{shouldCollapseDetails ? (
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => setDetailsOpen((value) => !value)}
|
|
100
|
+
className="text-muted-foreground hover:text-foreground hover:bg-muted mt-2 rounded px-1.5 text-xs leading-5"
|
|
101
|
+
aria-expanded={detailsOpen}
|
|
102
|
+
title={detailsOpen ? "Hide historical details" : "Show historical details"}
|
|
103
|
+
>
|
|
104
|
+
•••
|
|
105
|
+
</button>
|
|
106
|
+
) : null}
|
|
107
|
+
|
|
108
|
+
{showDetails ? (
|
|
109
|
+
<div
|
|
110
|
+
data-slot="email-body-details"
|
|
111
|
+
className={cn(
|
|
112
|
+
"mt-2 text-muted-foreground",
|
|
113
|
+
shouldCollapseDetails && "border-border border-l-2 pl-3",
|
|
114
|
+
variant === "preview" && "border-border/50 border-t pt-3",
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
{combinedDetailsHtml ? (
|
|
118
|
+
<HtmlBlock slot="email-body-details-content" html={combinedDetailsHtml} />
|
|
119
|
+
) : (
|
|
120
|
+
<PlainTextBlock slot="email-body-details-content" text={combinedDetailsText} />
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
) : null}
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { htmlToTextSnippet, sanitizeHtml } from "../internal/safe-html"
|
|
2
|
+
|
|
3
|
+
export interface NormalizedEmailSender {
|
|
4
|
+
name: string
|
|
5
|
+
email: string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface EmailDisplaySenderInput {
|
|
9
|
+
name?: string | null
|
|
10
|
+
email?: string | null
|
|
11
|
+
fallbackName?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SplitEmailHtmlResult {
|
|
15
|
+
bodyHtml: string
|
|
16
|
+
detailsHtml: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SplitEmailTextResult {
|
|
20
|
+
bodyText: string
|
|
21
|
+
detailsText: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const HTML_ENTITY_RE = /&(?:#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);?/i
|
|
25
|
+
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i
|
|
26
|
+
const ANGLE_ADDRESS_RE = /^\s*(.*?)\s*<\s*([^<>\s]+@[^<>\s]+)\s*>\s*$/
|
|
27
|
+
const BR_TAG_RE = /<br\s*\/?>/gi
|
|
28
|
+
|
|
29
|
+
const SPLITTABLE_BLOCK_TAGS = new Set(["p", "div"])
|
|
30
|
+
const WHOLE_BLOCK_TAGS = new Set(["blockquote", "table", "ul", "ol", "hr"])
|
|
31
|
+
const BLOCK_TAGS = new Set([...SPLITTABLE_BLOCK_TAGS, ...WHOLE_BLOCK_TAGS])
|
|
32
|
+
const HTML_BLOCK_START_RE = /<(p|div|blockquote|table|ul|ol|hr)\b[^>]*>/i
|
|
33
|
+
|
|
34
|
+
const SIGNATURE_DELIMITER_RE = /^--\s*$/
|
|
35
|
+
const GMAIL_SIGNATURE_RE = /\b(?:gmail_signature|gmail_signature_prefix|gmail_extra)\b/i
|
|
36
|
+
const GMAIL_QUOTE_RE = /<blockquote\b[^>]*\bclass=["'][^"']*\bgmail_quote\b/i
|
|
37
|
+
const ON_WROTE_RE = /^On\s.+wrote:\s*$/i
|
|
38
|
+
const SIGNOFF_RE = /^(?:thanks,|thank you,|best,|regards,|sincerely,)$/i
|
|
39
|
+
const DETAILS_START_RE = /^(?:confidentiality notice\b|this message and any attachments\b|this email and any attachments\b|the information contained in this message\b|this communication may contain\b|unsubscribe\b|manage your preferences\b)/i
|
|
40
|
+
const CONTACT_DETAIL_RE = /(?:@|https?:\/\/|www\.|\+?\d[\d\s().-]{6,}\d|\b(?:ceo|cfo|cto|coo|founder|co-founder|director|manager|vp|vice president|president|head of|sales|marketing|operations|account|customer success|success|support|engineer|consultant|partner|principal|advisor|associate)\b|\b(?:inc|llc|ltd|corp|corporation|company|co\.)\b)/i
|
|
41
|
+
|
|
42
|
+
function safeCodePoint(value: number): string {
|
|
43
|
+
return Number.isInteger(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : ""
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function decodeHtmlEntities(value: string): string {
|
|
47
|
+
const namedEntities: Record<string, string> = {
|
|
48
|
+
amp: "&",
|
|
49
|
+
apos: "'",
|
|
50
|
+
colon: ":",
|
|
51
|
+
gt: ">",
|
|
52
|
+
lt: "<",
|
|
53
|
+
nbsp: " ",
|
|
54
|
+
newline: "\n",
|
|
55
|
+
quot: '"',
|
|
56
|
+
tab: "\t",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let decoded = value
|
|
60
|
+
for (let i = 0; i < 4; i += 1) {
|
|
61
|
+
const next = decoded
|
|
62
|
+
.replace(/&#x([0-9a-f]+);?/gi, (_match, hex: string) => safeCodePoint(Number.parseInt(hex, 16)))
|
|
63
|
+
.replace(/&#(\d+);?/g, (_match, decimal: string) => safeCodePoint(Number.parseInt(decimal, 10)))
|
|
64
|
+
.replace(/&([a-z][a-z0-9]+);?/gi, (match, name: string) => namedEntities[name.toLowerCase()] ?? match)
|
|
65
|
+
|
|
66
|
+
if (next === decoded) return decoded
|
|
67
|
+
decoded = next
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return decoded
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function decodeJsonEscapes(value: string): string {
|
|
74
|
+
return value
|
|
75
|
+
.replace(/\\u\{([0-9a-f]{1,6})\}/gi, (_match, hex: string) => safeCodePoint(Number.parseInt(hex, 16)))
|
|
76
|
+
.replace(/\\u([0-9a-f]{4})/gi, (_match, hex: string) => safeCodePoint(Number.parseInt(hex, 16)))
|
|
77
|
+
.replace(/\\r\\n|\\n|\\r/g, "\n")
|
|
78
|
+
.replace(/\\t/g, "\t")
|
|
79
|
+
.replace(/\\(["'\\/])/g, "$1")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function maybeParseJsonString(value: string): string {
|
|
83
|
+
const trimmed = value.trim()
|
|
84
|
+
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return value
|
|
85
|
+
if (!/[\\&]/.test(trimmed)) return value
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(trimmed) as unknown
|
|
89
|
+
return typeof parsed === "string" ? parsed : value
|
|
90
|
+
} catch {
|
|
91
|
+
return value
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decodes display-only email text so UI labels, body fallbacks, collapsed rows,
|
|
97
|
+
* and snippets do not show HTML entities or JSON escape artifacts. It does not
|
|
98
|
+
* sanitize HTML; sanitize at the HTML render boundary before using markup.
|
|
99
|
+
*/
|
|
100
|
+
export function decodeEmailDisplayText(value: string): string {
|
|
101
|
+
let decoded = maybeParseJsonString(value).replace(/\r\n?/g, "\n")
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < 4; i += 1) {
|
|
104
|
+
const next = decodeHtmlEntities(decodeJsonEscapes(decoded))
|
|
105
|
+
if (next === decoded) break
|
|
106
|
+
decoded = next
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return decoded.replace(/\u00a0/g, " ")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stripWrappingQuotes(value: string): string {
|
|
113
|
+
const trimmed = value.trim()
|
|
114
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
115
|
+
return trimmed.slice(1, -1).trim()
|
|
116
|
+
}
|
|
117
|
+
return trimmed
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function extractEmail(value: string): string | null {
|
|
121
|
+
const decoded = decodeEmailDisplayText(value)
|
|
122
|
+
const angleMatch = decoded.match(ANGLE_ADDRESS_RE)
|
|
123
|
+
const email = angleMatch?.[2] ?? decoded.match(EMAIL_RE)?.[0]
|
|
124
|
+
return email ? email.trim() : null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function extractNameFromAddress(value: string): string {
|
|
128
|
+
const decoded = decodeEmailDisplayText(value)
|
|
129
|
+
const angleMatch = decoded.match(ANGLE_ADDRESS_RE)
|
|
130
|
+
if (angleMatch) return stripWrappingQuotes(angleMatch[1] ?? "")
|
|
131
|
+
return stripWrappingQuotes(decoded.replace(EMAIL_RE, "").replace(/[<>]/g, "").trim())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function cleanDisplayName(value: string, email: string | null): string {
|
|
135
|
+
let name = stripWrappingQuotes(decodeEmailDisplayText(value))
|
|
136
|
+
if (email) {
|
|
137
|
+
name = name
|
|
138
|
+
.replace(new RegExp(`<\\s*${email.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*>`, "i"), "")
|
|
139
|
+
.replace(new RegExp(`^${email.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i"), "")
|
|
140
|
+
.trim()
|
|
141
|
+
}
|
|
142
|
+
return stripWrappingQuotes(name)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function normalizeEmailSender(input: EmailDisplaySenderInput): NormalizedEmailSender {
|
|
146
|
+
const fallbackName = decodeEmailDisplayText(input.fallbackName ?? "Unknown sender").trim() || "Unknown sender"
|
|
147
|
+
const rawName = input.name ? decodeEmailDisplayText(input.name) : ""
|
|
148
|
+
const rawEmail = input.email ? decodeEmailDisplayText(input.email) : ""
|
|
149
|
+
const email = extractEmail(rawEmail) ?? extractEmail(rawName)
|
|
150
|
+
|
|
151
|
+
const nameFromName = rawName ? cleanDisplayName(extractNameFromAddress(rawName) || rawName, email) : ""
|
|
152
|
+
const nameFromEmail = rawEmail ? cleanDisplayName(extractNameFromAddress(rawEmail), email) : ""
|
|
153
|
+
const name = nameFromName || nameFromEmail || email || fallbackName
|
|
154
|
+
|
|
155
|
+
return { name, email }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function splitAddressList(value: string): string[] {
|
|
159
|
+
const parts: string[] = []
|
|
160
|
+
let current = ""
|
|
161
|
+
let quote: '"' | "'" | null = null
|
|
162
|
+
let angleDepth = 0
|
|
163
|
+
|
|
164
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
165
|
+
const char = value[index]
|
|
166
|
+
|
|
167
|
+
if (quote) {
|
|
168
|
+
current += char
|
|
169
|
+
if (char === quote && value[index - 1] !== "\\") quote = null
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (char === '"' || char === "'") {
|
|
174
|
+
quote = char
|
|
175
|
+
current += char
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (char === "<") angleDepth += 1
|
|
180
|
+
if (char === ">" && angleDepth > 0) angleDepth -= 1
|
|
181
|
+
|
|
182
|
+
if (char === "," && angleDepth === 0) {
|
|
183
|
+
if (current.trim()) parts.push(current.trim())
|
|
184
|
+
current = ""
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
current += char
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (current.trim()) parts.push(current.trim())
|
|
192
|
+
return parts
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function formatSingleAddress(value: string): string {
|
|
196
|
+
const decoded = decodeEmailDisplayText(value).trim()
|
|
197
|
+
if (!decoded) return ""
|
|
198
|
+
|
|
199
|
+
const email = extractEmail(decoded)
|
|
200
|
+
const name = cleanDisplayName(extractNameFromAddress(decoded), email)
|
|
201
|
+
|
|
202
|
+
if (email && name) return `${name} <${email}>`
|
|
203
|
+
if (email) return email
|
|
204
|
+
return stripWrappingQuotes(decoded)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function formatAddressList(input?: string | string[] | null): string {
|
|
208
|
+
if (!input) return ""
|
|
209
|
+
|
|
210
|
+
const rawItems = Array.isArray(input) ? input : splitAddressList(input)
|
|
211
|
+
return rawItems
|
|
212
|
+
.map((item) => formatSingleAddress(item))
|
|
213
|
+
.filter(Boolean)
|
|
214
|
+
.join(", ")
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function formatEmailTimestamp(value?: string | Date | null): string | null {
|
|
218
|
+
if (!value) return null
|
|
219
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
220
|
+
if (Number.isNaN(date.getTime())) return null
|
|
221
|
+
|
|
222
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
223
|
+
month: "short",
|
|
224
|
+
day: "numeric",
|
|
225
|
+
year: "numeric",
|
|
226
|
+
hour: "numeric",
|
|
227
|
+
minute: "2-digit",
|
|
228
|
+
timeZone: "UTC",
|
|
229
|
+
}).format(date)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type MessageSegment = { html?: string; text?: string; visibleText: string }
|
|
233
|
+
|
|
234
|
+
function escapeHtmlText(value: string): string {
|
|
235
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function decodeHtmlTextNodes(html: string): string {
|
|
239
|
+
if (!HTML_ENTITY_RE.test(html) && !/\\[nrt"'\\/]|\\u/i.test(html)) return html
|
|
240
|
+
|
|
241
|
+
if (typeof document !== "undefined") {
|
|
242
|
+
const template = document.createElement("template")
|
|
243
|
+
template.innerHTML = html
|
|
244
|
+
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_TEXT)
|
|
245
|
+
const textNodes: Text[] = []
|
|
246
|
+
let node = walker.nextNode()
|
|
247
|
+
while (node) {
|
|
248
|
+
textNodes.push(node as Text)
|
|
249
|
+
node = walker.nextNode()
|
|
250
|
+
}
|
|
251
|
+
textNodes.forEach((textNode) => {
|
|
252
|
+
textNode.nodeValue = decodeEmailDisplayText(textNode.nodeValue ?? "")
|
|
253
|
+
})
|
|
254
|
+
return template.innerHTML
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return html
|
|
258
|
+
.split(/(<[^>]+>)/g)
|
|
259
|
+
.map((part) => (part.startsWith("<") && part.endsWith(">") ? part : escapeHtmlText(decodeEmailDisplayText(part))))
|
|
260
|
+
.join("")
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function decodeHtmlText(value: string): string {
|
|
264
|
+
const withoutTags = value
|
|
265
|
+
.replace(BR_TAG_RE, "\n")
|
|
266
|
+
.replace(/<\/(p|div|blockquote|li|tr|table|ul|ol)>/gi, "\n")
|
|
267
|
+
.replace(/<[^>]*>/g, "")
|
|
268
|
+
|
|
269
|
+
return decodeEmailDisplayText(withoutTags)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function htmlToVisibleText(html: string): string {
|
|
273
|
+
return decodeHtmlText(html).replace(/\u00a0/g, " ").replace(/[ \t]+/g, " ").trim()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function serializeNode(node: Node): string {
|
|
277
|
+
const host = document.createElement("div")
|
|
278
|
+
host.appendChild(node.cloneNode(true))
|
|
279
|
+
return host.innerHTML
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function wrapHtmlLike(element: Element, innerHtml: string): string {
|
|
283
|
+
const clone = element.cloneNode(false) as HTMLElement
|
|
284
|
+
clone.innerHTML = innerHtml
|
|
285
|
+
return clone.outerHTML
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function hasDirectBr(nodes: readonly Node[]): boolean {
|
|
289
|
+
return nodes.some((node) => node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "br")
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function hasDirectBlockChild(element: Element): boolean {
|
|
293
|
+
return Array.from(element.children).some((child) => {
|
|
294
|
+
const tagName = child.tagName.toLowerCase()
|
|
295
|
+
return tagName !== "br" && BLOCK_TAGS.has(tagName)
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function makeHtmlSegment(html: string): MessageSegment | null {
|
|
300
|
+
const visibleText = htmlToVisibleText(html)
|
|
301
|
+
if (!html.trim() || (!visibleText && !/<(?:img|hr)\b/i.test(html))) return null
|
|
302
|
+
return { html, visibleText }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function splitInlineNodes(nodes: readonly Node[], wrapper?: Element): MessageSegment[] {
|
|
306
|
+
const containsBr = hasDirectBr(nodes)
|
|
307
|
+
const chunks: string[][] = [[]]
|
|
308
|
+
|
|
309
|
+
nodes.forEach((node) => {
|
|
310
|
+
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "br") {
|
|
311
|
+
chunks.push([])
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
chunks[chunks.length - 1]?.push(serializeNode(node))
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
return chunks
|
|
319
|
+
.map((chunk) => chunk.join(""))
|
|
320
|
+
.map((innerHtml) => {
|
|
321
|
+
if (wrapper) return wrapHtmlLike(wrapper, innerHtml)
|
|
322
|
+
return containsBr ? `<div>${innerHtml}</div>` : innerHtml
|
|
323
|
+
})
|
|
324
|
+
.map(makeHtmlSegment)
|
|
325
|
+
.filter((segment): segment is MessageSegment => Boolean(segment))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function splitElementSegment(element: Element): MessageSegment[] {
|
|
329
|
+
const tagName = element.tagName.toLowerCase()
|
|
330
|
+
|
|
331
|
+
if (GMAIL_SIGNATURE_RE.test(element.outerHTML) || GMAIL_QUOTE_RE.test(element.outerHTML)) {
|
|
332
|
+
const segment = makeHtmlSegment(element.outerHTML)
|
|
333
|
+
return segment ? [segment] : []
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (tagName === "div" && hasDirectBlockChild(element)) {
|
|
337
|
+
const childSegments = splitHtmlNodes(Array.from(element.childNodes))
|
|
338
|
+
return childSegments.length ? childSegments : ([makeHtmlSegment(element.outerHTML)].filter(Boolean) as MessageSegment[])
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (SPLITTABLE_BLOCK_TAGS.has(tagName) && hasDirectBr(Array.from(element.childNodes)) && !hasDirectBlockChild(element)) {
|
|
342
|
+
return splitInlineNodes(Array.from(element.childNodes), element)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const segment = makeHtmlSegment(element.outerHTML)
|
|
346
|
+
return segment ? [segment] : []
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function splitHtmlNodes(nodes: readonly Node[]): MessageSegment[] {
|
|
350
|
+
const segments: MessageSegment[] = []
|
|
351
|
+
let inlineNodes: Node[] = []
|
|
352
|
+
|
|
353
|
+
const flushInline = () => {
|
|
354
|
+
if (!inlineNodes.length) return
|
|
355
|
+
segments.push(...splitInlineNodes(inlineNodes))
|
|
356
|
+
inlineNodes = []
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
nodes.forEach((node) => {
|
|
360
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
361
|
+
const element = node as Element
|
|
362
|
+
const tagName = element.tagName.toLowerCase()
|
|
363
|
+
if (BLOCK_TAGS.has(tagName)) {
|
|
364
|
+
flushInline()
|
|
365
|
+
segments.push(...splitElementSegment(element))
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
inlineNodes.push(node)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
flushInline()
|
|
374
|
+
return segments
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function findMatchingCloseTag(html: string, tagName: string, openTagEnd: number): number {
|
|
378
|
+
if (tagName === "hr") return openTagEnd + 1
|
|
379
|
+
|
|
380
|
+
const tagPattern = new RegExp(`</?${tagName}\\b[^>]*>`, "gi")
|
|
381
|
+
tagPattern.lastIndex = openTagEnd + 1
|
|
382
|
+
let depth = 1
|
|
383
|
+
let match: RegExpExecArray | null
|
|
384
|
+
|
|
385
|
+
while ((match = tagPattern.exec(html)) !== null) {
|
|
386
|
+
const rawTag = match[0]
|
|
387
|
+
if (/^<\//.test(rawTag)) depth -= 1
|
|
388
|
+
else if (!/\/\s*>$/.test(rawTag)) depth += 1
|
|
389
|
+
if (depth === 0) return tagPattern.lastIndex
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return html.length
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function splitHtmlSegmentsFallback(html: string): MessageSegment[] {
|
|
396
|
+
const segments: MessageSegment[] = []
|
|
397
|
+
let cursor = 0
|
|
398
|
+
|
|
399
|
+
const pushInline = (inlineHtml: string) => {
|
|
400
|
+
const chunks = inlineHtml.split(BR_TAG_RE)
|
|
401
|
+
const hadBr = chunks.length > 1
|
|
402
|
+
chunks.forEach((chunk) => {
|
|
403
|
+
const segment = makeHtmlSegment(hadBr ? `<div>${chunk}</div>` : chunk)
|
|
404
|
+
if (segment) segments.push(segment)
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
while (cursor < html.length) {
|
|
409
|
+
const rest = html.slice(cursor)
|
|
410
|
+
const match = HTML_BLOCK_START_RE.exec(rest)
|
|
411
|
+
if (!match || match.index === undefined) {
|
|
412
|
+
pushInline(rest)
|
|
413
|
+
break
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (match.index > 0) pushInline(rest.slice(0, match.index))
|
|
417
|
+
|
|
418
|
+
const tagStart = cursor + match.index
|
|
419
|
+
const rawOpen = match[0]
|
|
420
|
+
const tagName = match[1].toLowerCase()
|
|
421
|
+
const openTagEnd = tagStart + rawOpen.length - 1
|
|
422
|
+
const segmentEnd = findMatchingCloseTag(html, tagName, openTagEnd)
|
|
423
|
+
const blockHtml = html.slice(tagStart, segmentEnd)
|
|
424
|
+
|
|
425
|
+
if (SPLITTABLE_BLOCK_TAGS.has(tagName) && BR_TAG_RE.test(blockHtml)) {
|
|
426
|
+
const openTag = rawOpen
|
|
427
|
+
const closeTag = `</${tagName}>`
|
|
428
|
+
const inner = blockHtml.replace(new RegExp(`^${rawOpen.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"), "").replace(new RegExp(`${closeTag}$`, "i"), "")
|
|
429
|
+
inner.split(BR_TAG_RE).forEach((chunk) => {
|
|
430
|
+
const segment = makeHtmlSegment(`${openTag}${chunk}${closeTag}`)
|
|
431
|
+
if (segment) segments.push(segment)
|
|
432
|
+
})
|
|
433
|
+
} else {
|
|
434
|
+
const segment = makeHtmlSegment(blockHtml)
|
|
435
|
+
if (segment) segments.push(segment)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
cursor = segmentEnd
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return segments
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function segmentHtmlMessage(html: string): MessageSegment[] {
|
|
445
|
+
if (typeof document === "undefined" || typeof Node === "undefined") {
|
|
446
|
+
return splitHtmlSegmentsFallback(html)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const template = document.createElement("template")
|
|
450
|
+
template.innerHTML = html
|
|
451
|
+
return splitHtmlNodes(Array.from(template.content.childNodes))
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function segmentTextMessage(text: string): MessageSegment[] {
|
|
455
|
+
const normalized = decodeEmailDisplayText(text)
|
|
456
|
+
const lines: string[] = normalized.match(/[^\n]*(?:\n|$)/g) ?? []
|
|
457
|
+
return lines
|
|
458
|
+
.filter((line, index) => line.length > 0 && !(index === lines.length - 1 && line === ""))
|
|
459
|
+
.map((line) => ({ text: line, visibleText: line.replace(/\u00a0/g, " ").trim() }))
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function firstVisibleLine(text: string): string {
|
|
463
|
+
return text.replace(/\u00a0/g, " ").trimStart().split(/\r?\n/).find((line) => line.trim())?.trim() ?? ""
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function isLikelySenderNameLine(line: string): boolean {
|
|
467
|
+
if (!line || line.length > 60) return false
|
|
468
|
+
if (/[,@:;!?]|https?:\/\/|www\.|\d/.test(line)) return false
|
|
469
|
+
|
|
470
|
+
const words = line.split(/\s+/).filter(Boolean)
|
|
471
|
+
if (words.length < 1 || words.length > 4) return false
|
|
472
|
+
|
|
473
|
+
return words.every((word) => /^[A-Z][A-Za-z'.-]*$/.test(word))
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function nextVisibleSegmentText(segments: MessageSegment[], fromIndex: number): string {
|
|
477
|
+
for (let index = fromIndex + 1; index < segments.length; index += 1) {
|
|
478
|
+
const text = firstVisibleLine(segments[index]?.visibleText ?? "")
|
|
479
|
+
if (text) return text
|
|
480
|
+
}
|
|
481
|
+
return ""
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function isGmailDetailsSegment(segment: MessageSegment): boolean {
|
|
485
|
+
return Boolean(segment.html && (GMAIL_SIGNATURE_RE.test(segment.html) || GMAIL_QUOTE_RE.test(segment.html)))
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function isFooterBoundary(segments: MessageSegment[], index: number): boolean {
|
|
489
|
+
const segment = segments[index]
|
|
490
|
+
if (!segment) return false
|
|
491
|
+
if (isGmailDetailsSegment(segment)) return true
|
|
492
|
+
|
|
493
|
+
const line = firstVisibleLine(segment.visibleText)
|
|
494
|
+
if (!line) return false
|
|
495
|
+
if (SIGNATURE_DELIMITER_RE.test(line) || DETAILS_START_RE.test(line) || ON_WROTE_RE.test(line)) return true
|
|
496
|
+
|
|
497
|
+
const nextText = nextVisibleSegmentText(segments, index)
|
|
498
|
+
if (SIGNOFF_RE.test(line)) return Boolean(nextText && (isLikelySenderNameLine(nextText) || CONTACT_DETAIL_RE.test(nextText)))
|
|
499
|
+
|
|
500
|
+
return isLikelySenderNameLine(line) && CONTACT_DETAIL_RE.test(nextText)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function splitFooterSegments(segments: MessageSegment[]): { bodySegments: MessageSegment[]; detailsSegments: MessageSegment[] } {
|
|
504
|
+
const visibleIndexes = segments
|
|
505
|
+
.map((segment, index) => (segment.visibleText || isGmailDetailsSegment(segment) ? index : -1))
|
|
506
|
+
.filter((index) => index >= 0)
|
|
507
|
+
const visibleCount = visibleIndexes.length
|
|
508
|
+
if (visibleCount < 2) return { bodySegments: segments, detailsSegments: [] }
|
|
509
|
+
|
|
510
|
+
const trailingCount = Math.max(Math.ceil(visibleCount * 0.4), 8)
|
|
511
|
+
const firstTrailingOrdinal = Math.max(1, visibleCount - trailingCount)
|
|
512
|
+
|
|
513
|
+
for (let ordinal = firstTrailingOrdinal; ordinal < visibleCount; ordinal += 1) {
|
|
514
|
+
const index = visibleIndexes[ordinal]
|
|
515
|
+
if (ordinal > 0 && isFooterBoundary(segments, index)) {
|
|
516
|
+
return { bodySegments: segments.slice(0, index), detailsSegments: segments.slice(index) }
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return { bodySegments: segments, detailsSegments: [] }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function splitEmailHtmlForDisplay(html: string): SplitEmailHtmlResult {
|
|
524
|
+
const sanitizedHtml = sanitizeHtml(decodeHtmlTextNodes(html))
|
|
525
|
+
const { bodySegments, detailsSegments } = splitFooterSegments(segmentHtmlMessage(sanitizedHtml))
|
|
526
|
+
|
|
527
|
+
if (!detailsSegments.length) return { bodyHtml: sanitizedHtml, detailsHtml: "" }
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
bodyHtml: bodySegments.map((segment) => segment.html ?? "").join(""),
|
|
531
|
+
detailsHtml: detailsSegments.map((segment) => segment.html ?? "").join(""),
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function splitEmailTextForDisplay(text: string): SplitEmailTextResult {
|
|
536
|
+
const decodedText = decodeEmailDisplayText(text)
|
|
537
|
+
const { bodySegments, detailsSegments } = splitFooterSegments(segmentTextMessage(decodedText))
|
|
538
|
+
|
|
539
|
+
if (!detailsSegments.length) return { bodyText: decodedText, detailsText: "" }
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
bodyText: bodySegments.map((segment) => segment.text ?? "").join(""),
|
|
543
|
+
detailsText: detailsSegments.map((segment) => segment.text ?? "").join(""),
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function emailBodySnippet(input: { bodyHtml?: string | null; body?: string | null }, maxLength = 140): string {
|
|
548
|
+
const html = input.bodyHtml?.trim()
|
|
549
|
+
if (html) {
|
|
550
|
+
return decodeEmailDisplayText(htmlToTextSnippet(splitEmailHtmlForDisplay(html).bodyHtml, maxLength)).trim()
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const text = input.body ?? ""
|
|
554
|
+
const bodyText = splitEmailTextForDisplay(text).bodyText
|
|
555
|
+
const firstLine = bodyText.split("\n").find((line) => line.trim())?.trim() ?? ""
|
|
556
|
+
return decodeEmailDisplayText(firstLine).replace(/\s+/g, " ").trim().slice(0, maxLength)
|
|
557
|
+
}
|