diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index c36f366b..072c7145 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -1659,7 +1659,9 @@ export default function Home() {
// Append signature from the sending identity (fall back to primary
// when the reply-from lives on the same identity but a different alias).
- const finalBody = appendPlainTextSignature(body, sendingIdentity);
+ const finalBody = appendPlainTextSignature(body, sendingIdentity, {
+ separator: useSettingsStore.getState().signatureSeparatorEnabled,
+ });
const originalEmailId = selectedEmail.id;
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 75012ce8..cb1aa8d3 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -35,6 +35,7 @@ import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature
import { resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { RichTextEditor } from "@/components/email/rich-text-editor";
+import type { Editor } from "@tiptap/react";
/** Strip HTML tags and decode entities to get a plain-text version */
function htmlToPlainText(html: string): string {
@@ -116,6 +117,39 @@ type ComposerAttachment = {
abortController?: AbortController;
};
+type SignatureIdentityLike = {
+ htmlSignature?: string;
+ textSignature?: string;
+} | null | undefined;
+
+// Render the embedded signature for "above quote" mode. Bracketed with
+// `data-signature-block` marker paragraphs so we can swap the inner content
+// when the user switches identity without losing the surrounding draft or
+// quoted message. The markers are preserved through TipTap by the
+// StyledParagraph extension.
+function buildEmbeddedSignatureHtml(
+ identity: SignatureIdentityLike,
+ options: { embed: boolean; separator: boolean }
+): string {
+ if (!options.embed) return '';
+ const startMarker = options.separator
+ ? `
--
`
+ : ``;
+ const endMarker = ``;
+ if (identity?.htmlSignature) {
+ return `${startMarker}${sanitizeEmailHtml(identity.htmlSignature)}${endMarker}`;
+ }
+ if (identity?.textSignature) {
+ const escaped = identity.textSignature
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/\n/g, '
');
+ return `${startMarker}${escaped}
${endMarker}`;
+ }
+ return '';
+}
+
export function EmailComposer({
onSend,
onClose,
@@ -136,6 +170,7 @@ export function EmailComposer({
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
+ const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
@@ -206,8 +241,9 @@ export function EmailComposer({
// drafting area and the quoted content so it reads naturally as a
// closing for the reply body. Send-time append is skipped — see
// shouldEmbedSignatureAboveQuote.
+ const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
const signatureBlock = shouldEmbedSignatureAboveQuote
- ? `\n\n-- \n${getPlainTextSignature(initialSignatureIdentity)}`
+ ? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
: '';
if (mode === 'forward') {
@@ -225,21 +261,10 @@ export function EmailComposer({
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
- // When "above quote" is configured, splice signature between the user's
- // drafting area and the quoted content so it reads naturally as a closing
- // for the reply body. Send-time append is skipped — see
- // shouldEmbedSignatureAboveQuote.
- const buildEmbeddedSignatureHtml = (): string => {
- if (!shouldEmbedSignatureAboveQuote) return '';
- if (initialSignatureIdentity?.htmlSignature) {
- return `
--
${sanitizeEmailHtml(initialSignatureIdentity.htmlSignature)}`;
- }
- if (initialSignatureIdentity?.textSignature) {
- return `
--
${initialSignatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`;
- }
- return '';
- };
- const signatureBlock = buildEmbeddedSignatureHtml();
+ const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
+ embed: shouldEmbedSignatureAboveQuote,
+ separator: signatureSeparatorEnabled,
+ });
// Build quoted content as HTML
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
@@ -335,6 +360,69 @@ export function EmailComposer({
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
? currentIdentity
: primaryIdentity;
+
+ // Hold the TipTap editor instance so we can swap the embedded signature
+ // when the user switches identity in "above quote" mode without rebuilding
+ // the whole body (which would lose user edits to the surrounding draft).
+ const editorRef = useRef(null);
+ const prevSignatureIdentityIdRef = useRef(signatureIdentity?.id);
+ const prevSignatureSeparatorRef = useRef(signatureSeparatorEnabled);
+
+ useEffect(() => {
+ const editor = editorRef.current;
+ const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
+ const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
+ prevSignatureIdentityIdRef.current = signatureIdentity?.id;
+ prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
+ if (!editor) return;
+ if (!identityChanged && !separatorChanged) return;
+ if (plainTextMode) return;
+ if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
+ if (signaturePosition !== 'above_quote') return;
+
+ const currentHtml = editor.getHTML();
+ const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
+ const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
+ if (!startEl) return;
+ const endEl = doc.querySelector('[data-signature-block="end"]');
+
+ const newSignature = buildEmbeddedSignatureHtml(signatureIdentity, {
+ embed: true,
+ separator: signatureSeparatorEnabled,
+ });
+ if (!newSignature) return;
+
+ // Build a temporary container holding the replacement nodes so we can
+ // splice them in without re-serializing/parsing twice.
+ const replacementHost = doc.createElement('div');
+ replacementHost.innerHTML = newSignature;
+ const replacementNodes = Array.from(replacementHost.childNodes);
+
+ const parent = startEl.parentNode;
+ if (!parent) return;
+
+ // Remove the existing signature range [startEl … endEl] inclusive, or
+ // from startEl to the next blockquote if no end marker is present.
+ const removeUntil = endEl && endEl.parentNode === parent ? endEl : null;
+ let cursor: ChildNode | null = startEl;
+ const toRemove: ChildNode[] = [];
+ while (cursor) {
+ toRemove.push(cursor);
+ if (cursor === removeUntil) break;
+ const next: ChildNode | null = cursor.nextSibling;
+ if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break;
+ cursor = next;
+ }
+ const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
+ toRemove.forEach((node) => parent.removeChild(node));
+ replacementNodes.forEach((node) => parent.insertBefore(node, insertBefore));
+
+ const nextHtml = doc.body.innerHTML;
+ if (nextHtml !== currentHtml) {
+ editor.commands.setContent(nextHtml, { emitUpdate: true });
+ }
+ }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
+
useEffect(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
@@ -1038,11 +1126,12 @@ export function EmailComposer({
// Build HTML signature block (used only in rich text mode)
const buildSignatureHtml = (): string => {
if (signatureAlreadyInBody) return '';
+ const sep = signatureSeparatorEnabled ? `
--
` : `
`;
if (signatureIdentity?.htmlSignature) {
- return `
--
${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
+ return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
}
if (signatureIdentity?.textSignature) {
- return `
--
${signatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`;
+ return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`;
}
return '';
};
@@ -1053,9 +1142,10 @@ export function EmailComposer({
: null;
// In plain text mode, send text/plain only (no HTML body)
+ const signatureOpts = { separator: signatureSeparatorEnabled };
const finalBody = plainTextMode
- ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity))
- : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity));
+ ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
+ : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts));
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const finalHtmlBody = plainTextMode
@@ -1628,6 +1718,7 @@ export function EmailComposer({
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
+ onEditorReady={(ed) => { editorRef.current = ed; }}
/>
)}
@@ -1638,13 +1729,13 @@ export function EmailComposer({
: plainTextMode ? (
getPlainTextSignature(signatureIdentity) ? (
- {'-- \n'}{getPlainTextSignature(signatureIdentity)}
+ {signatureSeparatorEnabled ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)}
) : null
) : composerSignatureHtml ? (
--
${composerSignatureHtml}` }}
+ dangerouslySetInnerHTML={{ __html: `${signatureSeparatorEnabled ? '--
' : ''}${composerSignatureHtml}` }}
/>
) : null}
diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx
index 88975a65..0588d107 100644
--- a/components/email/rich-text-editor.tsx
+++ b/components/email/rich-text-editor.tsx
@@ -1,8 +1,10 @@
"use client";
import React, { useEffect, useCallback, useState, useRef } from "react";
-import { useEditor, EditorContent } from "@tiptap/react";
+import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
+import Paragraph from "@tiptap/extension-paragraph";
+import Heading from "@tiptap/extension-heading";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import TextAlign from "@tiptap/extension-text-align";
@@ -44,6 +46,51 @@ export interface InlineImageUpload {
cid?: string;
}
+// Pasted email content (signatures, replies, quoted text) commonly carries
+// inline styles on block elements. StarterKit's default Paragraph/Heading
+// drop unknown attributes; extend them to round-trip `style` and `class` so
+// signature formatting survives the editor.
+const styledBlockAttributes = {
+ style: {
+ default: null as string | null,
+ parseHTML: (el: HTMLElement) => el.getAttribute("style"),
+ renderHTML: (attrs: Record) =>
+ attrs.style ? { style: attrs.style } : {},
+ },
+ class: {
+ default: null as string | null,
+ parseHTML: (el: HTMLElement) => el.getAttribute("class"),
+ renderHTML: (attrs: Record) =>
+ attrs.class ? { class: attrs.class } : {},
+ },
+ "data-signature-block": {
+ default: null as string | null,
+ parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"),
+ renderHTML: (attrs: Record) =>
+ attrs["data-signature-block"]
+ ? { "data-signature-block": attrs["data-signature-block"] }
+ : {},
+ },
+};
+
+const StyledParagraph = Paragraph.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ ...styledBlockAttributes,
+ };
+ },
+});
+
+const StyledHeading = Heading.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ ...styledBlockAttributes,
+ };
+ },
+});
+
interface RichTextEditorProps {
content: string;
onChange: (html: string) => void;
@@ -51,6 +98,7 @@ interface RichTextEditorProps {
placeholder?: string;
className?: string;
hasError?: boolean;
+ onEditorReady?: (editor: Editor) => void;
}
function ToolbarButton({
@@ -131,17 +179,23 @@ export function RichTextEditor({
placeholder,
className,
hasError,
+ onEditorReady,
}: RichTextEditorProps) {
const onImageUploadRef = React.useRef(onImageUpload);
onImageUploadRef.current = onImageUpload;
+ const onEditorReadyRef = React.useRef(onEditorReady);
+ onEditorReadyRef.current = onEditorReady;
const editor = useEditor({
extensions: [
StarterKit.configure({
- heading: { levels: [1, 2] },
+ heading: false,
+ paragraph: false,
link: false,
underline: false,
}),
+ StyledParagraph,
+ StyledHeading.configure({ levels: [1, 2] }),
Underline,
Link.configure({
openOnClick: false,
@@ -239,6 +293,12 @@ export function RichTextEditor({
}
}, [content, editor]);
+ // Expose the editor instance once it's ready so parents can target
+ // specific nodes (e.g. swap the embedded signature on identity change).
+ useEffect(() => {
+ if (editor) onEditorReadyRef.current?.(editor);
+ }, [editor]);
+
const addLink = useCallback(() => {
if (!editor) return;
const previousUrl = editor.getAttributes("link").href;
diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx
index 68e64245..9223d78b 100644
--- a/components/settings/composing-settings.tsx
+++ b/components/settings/composing-settings.tsx
@@ -28,6 +28,7 @@ export function ComposingSettings() {
attachmentReminderKeywords,
subAddressDelimiter,
signaturePosition,
+ signatureSeparatorEnabled,
updateSetting,
} = useSettingsStore();
@@ -62,6 +63,13 @@ export function ComposingSettings() {
/>
+
+ updateSetting('signatureSeparatorEnabled', checked)}
+ />
+
+
()(
plainTextMode: state.plainTextMode,
subAddressDelimiter: state.subAddressDelimiter,
signaturePosition: state.signaturePosition,
+ signatureSeparatorEnabled: state.signatureSeparatorEnabled,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound,