diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index c0399cab..41b1a8de 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -41,6 +41,7 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { Input } from "@/components/ui/input"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; +import { appendPlainTextSignature } from "@/lib/signature-utils"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; @@ -862,10 +863,7 @@ export default function Home() { const primaryIdentity = identities[0]; // Append signature from the primary identity - let finalBody = body; - if (primaryIdentity?.textSignature) { - finalBody = body + '\n\n-- \n' + primaryIdentity.textSignature; - } + const finalBody = appendPlainTextSignature(body, primaryIdentity); // Send reply with just the body text await sendEmail( diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index b3bd2393..509ebc75 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -27,6 +27,7 @@ import { substitutePlaceholders } from "@/lib/template-utils"; import { TemplatePicker } from "@/components/templates/template-picker"; import { TemplateForm } from "@/components/templates/template-form"; import type { EmailTemplate } from "@/lib/template-types"; +import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; export interface ComposerDraftData { to: string; @@ -199,6 +200,14 @@ export function EmailComposer({ const { client } = useAuthStore(); const identities = useIdentityStore((s) => s.identities); const primaryIdentity = identities[0] ?? null; + const currentIdentity = selectedIdentityId + ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity + : primaryIdentity; + const composerSignatureHtml = currentIdentity?.htmlSignature + ? `
${sanitizeEmailHtml(currentIdentity.htmlSignature)}
` + : currentIdentity?.textSignature + ? `
${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` + : ''; const getAutocomplete = useContactStore((s) => s.getAutocomplete); const addTemplate = useTemplateStore((s) => s.addTemplate); const sendRawEmail = useEmailStore((s) => s.sendRawEmail); @@ -527,10 +536,6 @@ export function EmailComposer({ setSaveStatus('saving'); // Get the selected identity or primary identity - const currentIdentity = selectedIdentityId - ? identities.find(id => id.id === selectedIdentityId) - : primaryIdentity; - // Generate sub-addressed email if tag is set const fromEmail = currentIdentity?.email ? subAddressTag @@ -649,10 +654,6 @@ export function EmailComposer({ } } - const currentIdentity = selectedIdentityId - ? identities.find(id => id.id === selectedIdentityId) - : primaryIdentity; - const fromEmail = currentIdentity?.email ? subAddressTag ? generateSubAddress(currentIdentity.email, subAddressTag) @@ -660,10 +661,7 @@ export function EmailComposer({ : undefined; // Append signature from the selected identity - let finalBody = body; - if (currentIdentity?.textSignature) { - finalBody = body + '\n\n-- \n' + currentIdentity.textSignature; - } + let finalBody = appendPlainTextSignature(body, currentIdentity); // Append quoted original text for the plain text part in reply/forward if (replyTo && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { @@ -1126,6 +1124,13 @@ export function EmailComposer({ /> + {composerSignatureHtml && ( +
--
${composerSignatureHtml}` }} + /> + )} + {/* Quoted original HTML */} {replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && (
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index b1ce029e..fec4f7ce 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -5,6 +5,7 @@ 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 { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime } from "@/lib/utils"; @@ -2148,9 +2149,7 @@ export function EmailViewer({ // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]; if (hasTextBody && htmlContent) { - const stripped = htmlContent.replace(/<\/?(html|head|body|meta|!doctype|!DOCTYPE|br\s*\/?)[^>]*>/gi, '').trim(); - const hasRichContent = /<(table|tr|td|th|img|style|link|div\s+[^>]*class|span\s+[^>]*class|font|center|blockquote|ul|ol|li|h[1-6])\b/i.test(stripped); - useHtmlVersion = hasRichContent; + useHtmlVersion = hasMeaningfulHtmlBody(htmlContent); } else { useHtmlVersion = !!htmlContent; } diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 13aab9ef..fb44d4d8 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -4,6 +4,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 { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform"; import { useThemeStore } from "@/stores/theme-store"; import { Avatar } from "@/components/ui/avatar"; @@ -320,9 +321,7 @@ function EmailCard({ // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]; if (hasTextBody && htmlContent) { - const stripped = htmlContent.replace(/<\/?(html|head|body|meta|!doctype|!DOCTYPE|br\s*\/?)[^>]*>/gi, '').trim(); - const hasRichContent = /<(table|tr|td|th|img|style|link|div\s+[^>]*class|span\s+[^>]*class|font|center|blockquote|ul|ol|li|h[1-6])\b/i.test(stripped); - useHtmlVersion = hasRichContent; + useHtmlVersion = hasMeaningfulHtmlBody(htmlContent); } else { useHtmlVersion = !!htmlContent; } diff --git a/eslint.config.mjs b/eslint.config.mjs index 3b6197d6..26afa33a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -70,6 +70,7 @@ export default [ "*.config.js", "*.config.mjs", "e2e/**", + "local-data/**/*.mjs", ], }, ]; diff --git a/lib/__tests__/signature-utils.test.ts b/lib/__tests__/signature-utils.test.ts new file mode 100644 index 00000000..d2a95d61 --- /dev/null +++ b/lib/__tests__/signature-utils.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { + appendPlainTextSignature, + getPlainTextSignature, + hasMeaningfulHtmlBody, +} from '../signature-utils'; + +describe('signature-utils', () => { + describe('getPlainTextSignature', () => { + it('prefers text signatures when present', () => { + expect(getPlainTextSignature({ textSignature: 'Regards,\nAlice', htmlSignature: '

Ignored

' })).toBe('Regards,\nAlice'); + }); + + it('converts html-only signatures into plain text', () => { + expect(getPlainTextSignature({ htmlSignature: '

Alice Example
alice@example.com

' })).toBe('Alice Example\nalice@example.com'); + }); + }); + + describe('appendPlainTextSignature', () => { + it('appends a converted html signature to the text body', () => { + expect(appendPlainTextSignature('Hello there', { htmlSignature: '

Alice
Engineering

' })).toBe('Hello there\n\n-- \nAlice\nEngineering'); + }); + + it('leaves the body untouched when no signature exists', () => { + expect(appendPlainTextSignature('Hello there', {})).toBe('Hello there'); + }); + }); + + describe('hasMeaningfulHtmlBody', () => { + it('prefers html bodies that preserve signature formatting', () => { + expect(hasMeaningfulHtmlBody('
Hello

Alice

')).toBe(true); + }); + + it('ignores minimal wrapper html with a single block', () => { + expect(hasMeaningfulHtmlBody('
Hello world
')).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/lib/signature-utils.ts b/lib/signature-utils.ts new file mode 100644 index 00000000..8829fc22 --- /dev/null +++ b/lib/signature-utils.ts @@ -0,0 +1,158 @@ +import { parseHtmlSafely, sanitizeSignatureHtml } from '@/lib/email-sanitization'; + +type SignatureSource = { + textSignature?: string; + htmlSignature?: string; +}; + +const BLOCK_TAGS = new Set([ + 'address', + 'article', + 'aside', + 'blockquote', + 'div', + 'footer', + 'header', + 'li', + 'nav', + 'p', + 'section', + 'tr', +]); + +function normalizeSignatureLineBreaks(value: string): string { + return value + .replace(/\r\n?/g, '\n') + .replace(/\u00a0/g, ' ') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function htmlToPlainText(html: string): string { + const document = parseHtmlSafely(html); + const chunks: string[] = []; + + const appendText = (value: string) => { + if (!value) return; + const normalized = value.replace(/\s+/g, ' '); + if (!normalized.trim()) return; + const previous = chunks[chunks.length - 1]; + if (previous && !previous.endsWith('\n') && !previous.endsWith(' ')) { + chunks.push(' '); + } + chunks.push(normalized); + }; + + const appendNewline = () => { + const previous = chunks[chunks.length - 1]; + if (previous === '\n') return; + if (previous?.endsWith('\n')) return; + chunks.push('\n'); + }; + + const walk = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + appendText(node.textContent || ''); + return; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return; + } + + const element = node as HTMLElement; + const tagName = element.tagName.toLowerCase(); + + if (tagName === 'br') { + appendNewline(); + return; + } + + if (tagName === 'a') { + const text = element.textContent?.replace(/\s+/g, ' ').trim() || ''; + const href = element.getAttribute('href')?.trim() || ''; + const normalizedHref = href.replace(/^mailto:/i, ''); + if (text && normalizedHref && text === normalizedHref) { + appendText(text); + return; + } + if (text && href && text !== href) { + appendText(`${text} <${href}>`); + return; + } + } + + if (BLOCK_TAGS.has(tagName) && chunks.length > 0) { + appendNewline(); + } + + Array.from(element.childNodes).forEach(walk); + + if (BLOCK_TAGS.has(tagName)) { + appendNewline(); + } + }; + + Array.from(document.body.childNodes).forEach(walk); + return normalizeSignatureLineBreaks(chunks.join('')); +} + +export function getPlainTextSignature(signature?: SignatureSource | null): string { + if (signature?.textSignature?.trim()) { + return normalizeSignatureLineBreaks(signature.textSignature); + } + + if (signature?.htmlSignature?.trim()) { + return htmlToPlainText(sanitizeSignatureHtml(signature.htmlSignature)); + } + + return ''; +} + +export function appendPlainTextSignature(body: string, signature?: SignatureSource | null): string { + const plainTextSignature = getPlainTextSignature(signature); + if (!plainTextSignature) { + return body; + } + + return `${body}\n\n-- \n${plainTextSignature}`; +} + +export function hasMeaningfulHtmlBody(html: string): boolean { + if (!html.trim()) return false; + + const document = parseHtmlSafely(html); + const richSelector = [ + 'table', + 'img', + 'style', + 'b', + 'strong', + 'i', + 'em', + 'u', + 'font', + 'a[href]', + 'div[style]', + 'span[style]', + 'p[style]', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'blockquote', + 'br', + ].join(', '); + + if (document.querySelector(richSelector)) { + return true; + } + + const blockElements = document.body.querySelectorAll('p, div, blockquote, li'); + return blockElements.length > 1; +} \ No newline at end of file