@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
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
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
+ }