From 34e495dde356b214eee346722349d4d393a28b17 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 19 Mar 2026 17:20:39 +0100
Subject: [PATCH] Fix email signature rendering
---
app/[locale]/page.tsx | 6 +-
components/email/email-composer.tsx | 29 ++--
components/email/email-viewer.tsx | 5 +-
components/email/thread-conversation-view.tsx | 5 +-
eslint.config.mjs | 1 +
lib/__tests__/signature-utils.test.ts | 39 +++++
lib/signature-utils.ts | 158 ++++++++++++++++++
7 files changed, 221 insertions(+), 22 deletions(-)
create mode 100644 lib/__tests__/signature-utils.test.ts
create mode 100644 lib/signature-utils.ts
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
+ ? `
${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 && (
+
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