From 67f61f18d0a09646181ebf9e2984653830f66e5d Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 12:55:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20P2.1=20extended=20signatures=20?= =?UTF-8?q?=E2=80=94=20multiple=20per=20identity=20+=20TipTap=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New stores/signature-store.ts: Zustand persist with CRUD, default/reply signature IDs, per-identity signature mapping - New signature-settings.tsx: list management with add/edit/delete/duplicate - New signature-editor-modal.tsx: TipTap rich text editor for signatures - email-composer.tsx: auto-insert signature based on mode (compose/reply) + signature selector dropdown in toolbar - identity-form.tsx: per-identity default/reply signature dropdowns - settings/page.tsx: Signatures tab in Mail settings group --- app/(main)/[locale]/settings/page.tsx | 7 + components/email/email-composer.tsx | 103 ++++- components/identity/identity-form.tsx | 65 +++ .../settings/signature-editor-modal.tsx | 399 ++++++++++++++++++ components/settings/signature-settings.tsx | 273 ++++++++++++ stores/signature-store.ts | 131 ++++++ 6 files changed, 975 insertions(+), 3 deletions(-) create mode 100644 components/settings/signature-editor-modal.tsx create mode 100644 components/settings/signature-settings.tsx create mode 100644 stores/signature-store.ts diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 3735c161..59eef68b 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -46,6 +46,7 @@ import { LayoutSettings } from '@/components/settings/layout-settings'; import { LanguageSettings } from '@/components/settings/language-settings'; import { ReadingSettings } from '@/components/settings/reading-settings'; import { ComposingSettings } from '@/components/settings/composing-settings'; +import { SignatureSettings } from '@/components/settings/signature-settings'; import { ContentSendersSettings } from '@/components/settings/content-senders-settings'; import { AccountSettings } from '@/components/settings/account-settings'; import { IdentitySettings } from '@/components/settings/identity-settings'; @@ -98,6 +99,7 @@ type Tab = | 'composing' | 'downloads' | 'identities' + | 'signatures' | 'vacation' | 'filters' | 'templates' @@ -141,6 +143,7 @@ const tabIcons: Record = { composing: PenLine, downloads: Download, identities: UserPen, + signatures: PenLine, vacation: PalmtreeIcon, filters: Filter, templates: FileText, @@ -219,6 +222,7 @@ const tabSearchPaths: Record = { ], downloads: ['settings.downloads'], identities: ['settings.identities'], + signatures: ['signatures'], vacation: ['settings.vacation'], filters: ['settings.filters'], templates: ['settings.templates'], @@ -254,6 +258,7 @@ const tabKeywords: Record = { composing: 'editor signature plain text reply forward draft compose', downloads: 'download filename template eml attachment save export', identities: 'from address signature email', + signatures: 'signature rich text html editor', vacation: 'auto reply away out of office holiday responder', filters: 'sieve rules block junk forward', templates: 'snippet quick reply', @@ -631,6 +636,7 @@ export default function SettingsPage() { { id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' }, { id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' }, { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' }, + { id: 'signatures', label: t('tabs.signatures'), icon: tabIcons.signatures, group: 'mail' }, ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []), ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []), ...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []), @@ -761,6 +767,7 @@ export default function SettingsPage() { {effectiveActiveTab === 'composing' && } {effectiveActiveTab === 'downloads' && } {effectiveActiveTab === 'identities' && } + {effectiveActiveTab === 'signatures' && } {effectiveActiveTab === 'vacation' && } {effectiveActiveTab === 'filters' && } {effectiveActiveTab === 'templates' && } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 0479f183..abbbf005 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PenLine } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -24,6 +24,7 @@ import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useAccountStore } from "@/stores/account-store"; import { useSettingsStore } from "@/stores/settings-store"; +import { useSignatureStore } from "@/stores/signature-store"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { Avatar } from "@/components/ui/avatar"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; @@ -298,6 +299,34 @@ export function EmailComposer({ const { isFeatureEnabled } = usePolicyStore(); const templatesEnabled = isFeatureEnabled('templatesEnabled'); + const { + signatures, + defaultSignatureId, + replySignatureId, + getSignatureById, + getIdentityDefaultSignatureId, + getIdentityReplySignatureId, + } = useSignatureStore(); + + const resolveStoreSignatureId = (): string | null => { + const perIdentityId = selectedIdentityId || initialData?.selectedIdentityId || null; + if (mode === 'compose') { + if (perIdentityId) { + const id = getIdentityDefaultSignatureId(perIdentityId); + if (id) return id; + } + return defaultSignatureId; + } + if (perIdentityId) { + const id = getIdentityReplySignatureId(perIdentityId); + if (id) return id; + } + return replySignatureId ?? defaultSignatureId; + }; + + const [selectedSignatureId, setSelectedSignatureId] = useState(resolveStoreSignatureId); + const selectedSignature = selectedSignatureId ? getSignatureById(selectedSignatureId) ?? null : null; + // The signature identity used when embedding the signature into the initial // body for "above quote" mode. Mirrors the signatureIdentity derivation // below, but uses initialData (or primary) since selectedIdentityId state @@ -592,6 +621,7 @@ export function EmailComposer({ // 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 [editorReady, setEditorReady] = useState(false); const prevSignatureIdentityIdRef = useRef(signatureIdentity?.id); const prevSignatureSeparatorRef = useRef(signatureSeparatorEnabled); @@ -668,6 +698,28 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]); + const sigInsertedRef = useRef(false); + useEffect(() => { + if (plainTextMode) return; + const editor = editorRef.current; + if (!editor) return; + if (!selectedSignatureId) return; + if (sigInsertedRef.current) return; + const sig = getSignatureById(selectedSignatureId); + if (!sig) return; + const currentHtml = serializeEditorContent(editor); + if (currentHtml.includes(sig.body)) { + sigInsertedRef.current = true; + return; + } + sigInsertedRef.current = true; + if (mode === 'compose') { + editor.chain().focus('end').insertContent(`

${sig.body}`).run(); + } else if ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') { + editor.chain().focus('start').insertContent(sig.body).run(); + } + }, [selectedSignatureId, plainTextMode, mode, signaturePosition, getSignatureById, editorReady]); + useEffect(() => { const handleClickOutsideSendMenu = (event: MouseEvent) => { if (!sendMenuRef.current?.contains(event.target as Node)) { @@ -2498,7 +2550,7 @@ export function EmailComposer({ onImageUpload={handleImageUpload} placeholder={t('body_placeholder')} hasError={validationErrors.body} - onEditorReady={(ed) => { editorRef.current = ed; }} + onEditorReady={(ed) => { editorRef.current = ed; setEditorReady(true); }} /> )} @@ -2657,8 +2709,53 @@ export function EmailComposer({ - {/* Right side - Discard + Send (desktop) */} + {/* Right side - Signature selector + Discard + Send (desktop) */}
+ {signatures.length > 0 && ( +
+ + +
+ )}
+ {/* Signature Store Mapping (per-identity) */} + {isEditing && signatures.length > 0 && ( +
+

{t('signature_store_mapping')}

+
+ + +
+
+ + +
+
+ )} + {/* Text Signature */}