Fix email signature rendering
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||
: currentIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
: '';
|
||||
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({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{composerSignatureHtml && (
|
||||
<div
|
||||
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
|
||||
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quoted original HTML */}
|
||||
{replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && (
|
||||
<div className="border-t border-border">
|
||||
|
||||
@@ -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 <br> 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;
|
||||
}
|
||||
|
||||
@@ -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 <br> 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;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export default [
|
||||
"*.config.js",
|
||||
"*.config.mjs",
|
||||
"e2e/**",
|
||||
"local-data/**/*.mjs",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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: '<p>Ignored</p>' })).toBe('Regards,\nAlice');
|
||||
});
|
||||
|
||||
it('converts html-only signatures into plain text', () => {
|
||||
expect(getPlainTextSignature({ htmlSignature: '<p>Alice Example<br><a href="mailto:alice@example.com">alice@example.com</a></p>' })).toBe('Alice Example\nalice@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendPlainTextSignature', () => {
|
||||
it('appends a converted html signature to the text body', () => {
|
||||
expect(appendPlainTextSignature('Hello there', { htmlSignature: '<p>Alice<br>Engineering</p>' })).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('<div>Hello</div><br><p>Alice</p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores minimal wrapper html with a single block', () => {
|
||||
expect(hasMeaningfulHtmlBody('<div>Hello world</div>')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user