diff --git a/app/globals.css b/app/globals.css index 65e1919e..0fb85bb6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -209,10 +209,14 @@ body { } .email-content-text a { - color: var(--color-primary); + color: #2563eb; text-decoration: underline; } +.dark .email-content-text a { + color: #60a5fa; +} + .email-content { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 426be247..50198d82 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr import ReactDOM from "react-dom"; import DOMPurify from "dompurify"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; -import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; +import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; @@ -2397,16 +2397,8 @@ export function EmailViewer({ if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) { const textContent = email.bodyValues[email.textBody[0].partId].value; - // Convert plain text to HTML with proper formatting - // Uses white-space: pre-wrap on the container to preserve newlines/whitespace - const htmlFromText = textContent - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/(https?:\/\/[^\s<]+)/g, '$1'); - return { - html: htmlFromText, + html: plainTextToSafeHtml(textContent), isHtml: false }; } @@ -2444,12 +2436,7 @@ export function EmailViewer({ return { html: cleanHtml, isHtml: true }; } if (smimeDecryptedText) { - const htmlFromText = smimeDecryptedText - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/(https?:\/\/[^\s<]+)/g, '$1'); - return { html: htmlFromText, isHtml: false }; + return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false }; } // TNEF (winmail.dat) extracted content if (tnefHtml) { @@ -2457,12 +2444,7 @@ export function EmailViewer({ return { html: cleanHtml, isHtml: true }; } if (tnefText) { - const htmlFromText = tnefText - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/(https?:\/\/[^\s<]+)/g, '$1'); - return { html: htmlFromText, isHtml: false }; + return { html: plainTextToSafeHtml(tnefText), isHtml: false }; } // Embedded message/rfc822 unwrapped content if (embeddedEmailHtml) { @@ -2470,12 +2452,7 @@ export function EmailViewer({ return { html: cleanHtml, isHtml: true }; } if (embeddedEmailText) { - const htmlFromText = embeddedEmailText - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/(https?:\/\/[^\s<]+)/g, '$1'); - return { html: htmlFromText, isHtml: false }; + return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false }; } return emailContent; }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 86fa0ce2..9519a600 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from "react"; import DOMPurify from "dompurify"; import { Email, ThreadGroup } from "@/lib/jmap/types"; -import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; +import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform"; import { useThemeStore } from "@/stores/theme-store"; @@ -419,12 +419,7 @@ function EmailCard({ // Plain text fallback if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) { const text = email.bodyValues[email.textBody[0].partId].value; - const htmlEscaped = text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/(https?:\/\/[^\s<]+)/g, '$1'); - return { html: htmlEscaped, isHtml: false }; + return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false }; } } diff --git a/lib/__tests__/email-sanitization.test.ts b/lib/__tests__/email-sanitization.test.ts index 9c02379b..f36c5b50 100644 --- a/lib/__tests__/email-sanitization.test.ts +++ b/lib/__tests__/email-sanitization.test.ts @@ -5,6 +5,7 @@ import { sanitizeSignatureHtml, parseHtmlSafely, hasRichFormatting, + plainTextToSafeHtml, EMAIL_SANITIZE_CONFIG, } from '../email-sanitization'; @@ -252,4 +253,55 @@ describe('email-sanitization', () => { expect(clean).toContain('data:image/gif'); }); }); + + describe('plainTextToSafeHtml', () => { + it('escapes HTML-special characters in surrounding text', () => { + const result = plainTextToSafeHtml(' & "q" \'q\''); + expect(result).not.toContain('