feat(mail): rich text editor for signatures + placeholder variables
- Replace textarea in SignatureManager with TipTap RichTextEditor
- Add placeholder variable buttons: {{user.name}}, {{user.first_name}},
{{user.last_name}}, {{user.email}}, {{user.role}}, {{tenant.name}}, {{date}}
- Add replaceSignatureVariables() utility in mail API
- ComposeModal now replaces variables with actual user/tenant data when inserting signature
- Variables resolved from auth store (user name, email, role, tenant name)
This commit is contained in:
@@ -417,6 +417,31 @@ export function decodeMimeHeader(value: string | undefined | null): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace placeholder variables in signature HTML with actual user/tenant data.
|
||||||
|
* Variables: {{user.name}}, {{user.first_name}}, {{user.last_name}},
|
||||||
|
* {{user.email}}, {{user.role}}, {{tenant.name}}, {{date}}
|
||||||
|
*/
|
||||||
|
export function replaceSignatureVariables(
|
||||||
|
html: string,
|
||||||
|
user: { name?: string; email?: string; role?: string; first_name?: string; last_name?: string },
|
||||||
|
tenant?: { name?: string },
|
||||||
|
): string {
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = now.toLocaleDateString([], { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||||
|
const firstName = user.first_name || (user.name ? user.name.split(' ')[0] : '');
|
||||||
|
const lastName = user.last_name || (user.name ? user.name.split(' ').slice(1).join(' ') : '');
|
||||||
|
|
||||||
|
return html
|
||||||
|
.replace(/\{\{user\.name\}\}/gi, user.name || '')
|
||||||
|
.replace(/\{\{user\.first_name\}\}/gi, firstName)
|
||||||
|
.replace(/\{\{user\.last_name\}\}/gi, lastName)
|
||||||
|
.replace(/\{\{user\.email\}\}/gi, user.email || '')
|
||||||
|
.replace(/\{\{user\.role\}\}/gi, user.role || '')
|
||||||
|
.replace(/\{\{tenant\.name\}\}/gi, tenant?.name || '')
|
||||||
|
.replace(/\{\{date\}\}/gi, dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Folders ────────────────────────────────────────────────────────────────
|
// ─── Folders ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function fetchFolders(accountId: string): Promise<MailFolder[]> {
|
export function fetchFolders(accountId: string): Promise<MailFolder[]> {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { Select } from '@/components/ui/Select';
|
|||||||
import { TemplatePicker } from './TemplatePicker';
|
import { TemplatePicker } from './TemplatePicker';
|
||||||
import { RichTextEditor } from './RichTextEditor';
|
import { RichTextEditor } from './RichTextEditor';
|
||||||
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail';
|
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail';
|
||||||
import { uploadAttachment, type UploadedAttachment } from '@/api/mail';
|
import { uploadAttachment, type UploadedAttachment, replaceSignatureVariables } from '@/api/mail';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
export type ComposeMode = 'new' | 'reply' | 'forward' | 'draft';
|
export type ComposeMode = 'new' | 'reply' | 'forward' | 'draft';
|
||||||
|
|
||||||
@@ -42,6 +43,8 @@ export function ComposeModal({
|
|||||||
onClose,
|
onClose,
|
||||||
}: ComposeModalProps) {
|
}: ComposeModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const authUser = useAuthStore((s) => s.user);
|
||||||
|
const authTenant = useAuthStore((s) => s.currentTenant);
|
||||||
const [to, setTo] = useState('');
|
const [to, setTo] = useState('');
|
||||||
const [cc, setCc] = useState('');
|
const [cc, setCc] = useState('');
|
||||||
const [bcc, setBcc] = useState('');
|
const [bcc, setBcc] = useState('');
|
||||||
@@ -87,9 +90,14 @@ export function ComposeModal({
|
|||||||
setSelectedSignatureId(signatureId);
|
setSelectedSignatureId(signatureId);
|
||||||
const sig = signatures.find((s) => s.id === signatureId);
|
const sig = signatures.find((s) => s.id === signatureId);
|
||||||
if (sig) {
|
if (sig) {
|
||||||
setBody((prev) => `${prev}<br/><br/>---<br/>${sig.body_html}`);
|
const processedHtml = replaceSignatureVariables(
|
||||||
|
sig.body_html,
|
||||||
|
{ name: authUser?.name, email: authUser?.email, role: authUser?.role, first_name: authUser?.first_name, last_name: authUser?.last_name },
|
||||||
|
{ name: authTenant?.name },
|
||||||
|
);
|
||||||
|
setBody((prev) => `${prev}<br/><br/>---<br/>${processedHtml}`);
|
||||||
}
|
}
|
||||||
}, [signatures]);
|
}, [signatures, authUser, authTenant]);
|
||||||
|
|
||||||
const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => {
|
const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => {
|
||||||
setBody(templateBody);
|
setBody(templateBody);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Signature manager — CRUD for mail signatures.
|
* Signature manager — CRUD for mail signatures with rich text editor.
|
||||||
|
* Supports placeholder variables for user/tenant data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
@@ -10,6 +11,7 @@ import { Card } from '@/components/ui/Card';
|
|||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||||
|
import { RichTextEditor } from './RichTextEditor';
|
||||||
import {
|
import {
|
||||||
fetchSignatures,
|
fetchSignatures,
|
||||||
createSignature,
|
createSignature,
|
||||||
@@ -18,6 +20,17 @@ import {
|
|||||||
type MailSignature,
|
type MailSignature,
|
||||||
} from '@/api/mail';
|
} from '@/api/mail';
|
||||||
|
|
||||||
|
/** Available placeholder variables for signatures. */
|
||||||
|
const SIGNATURE_VARIABLES = [
|
||||||
|
{ token: '{{user.name}}', label: 'Vollständiger Name', description: 'Max Mustermann' },
|
||||||
|
{ token: '{{user.first_name}}', label: 'Vorname', description: 'Max' },
|
||||||
|
{ token: '{{user.last_name}}', label: 'Nachname', description: 'Mustermann' },
|
||||||
|
{ token: '{{user.email}}', label: 'E-Mail', description: 'max@firma.de' },
|
||||||
|
{ token: '{{user.role}}', label: 'Rolle', description: 'Admin' },
|
||||||
|
{ token: '{{tenant.name}}', label: 'Firma/Organisation', description: 'HMS Licht & Ton' },
|
||||||
|
{ token: '{{date}}', label: 'Aktuelles Datum', description: '16.07.2026' },
|
||||||
|
];
|
||||||
|
|
||||||
export function SignatureManager() {
|
export function SignatureManager() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -134,12 +147,24 @@ export function SignatureManager() {
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.signatureBody')}</label>
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.signatureBody')}</label>
|
||||||
<textarea
|
{/* Placeholder variable buttons */}
|
||||||
value={bodyHtml}
|
<div className="flex flex-wrap gap-1 mb-2" data-testid="signature-variables">
|
||||||
onChange={(e) => setBodyHtml(e.target.value)}
|
{SIGNATURE_VARIABLES.map((v) => (
|
||||||
className="w-full min-h-24 border border-secondary-300 rounded-md p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
<button
|
||||||
placeholder="<p>Regards, John</p>"
|
key={v.token}
|
||||||
data-testid="signature-body"
|
type="button"
|
||||||
|
onClick={() => setBodyHtml((prev) => `${prev}${v.token}`)}
|
||||||
|
className="inline-flex items-center px-2 py-0.5 text-xs rounded border border-secondary-300 bg-secondary-50 hover:bg-secondary-100 text-secondary-700"
|
||||||
|
title={v.description}
|
||||||
|
>
|
||||||
|
{v.label} <code className="ml-1 text-primary-600">{v.token}</code>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<RichTextEditor
|
||||||
|
content={bodyHtml}
|
||||||
|
onChange={setBodyHtml}
|
||||||
|
placeholder="<p>Mit freundlichen Grüßen,<br>{{user.name}}</p>"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-secondary-700">
|
<label className="flex items-center gap-2 text-sm text-secondary-700">
|
||||||
|
|||||||
Reference in New Issue
Block a user