feat: P2.1 extended signatures — multiple per identity + TipTap editor
- 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
This commit is contained in:
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
|
||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
|
||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||
import { useSignatureStore } from '@/stores/signature-store';
|
||||
|
||||
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
||||
const SIGNATURE_MAX_BYTES = 2047;
|
||||
@@ -73,6 +74,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const {
|
||||
signatures,
|
||||
identitySignatureMap,
|
||||
setIdentitySignature,
|
||||
} = useSignatureStore();
|
||||
const identitySigMapping = identity?.id ? (identitySignatureMap[identity.id] ?? {}) : {};
|
||||
const [sigDefaultId, setSigDefaultId] = useState<string>(identitySigMapping.defaultId ?? '');
|
||||
const [sigReplyId, setSigReplyId] = useState<string>(identitySigMapping.replyId ?? '');
|
||||
|
||||
const parseEmailList = (input: string): EmailAddress[] | undefined => {
|
||||
if (!input.trim()) return undefined;
|
||||
|
||||
@@ -134,6 +144,10 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
};
|
||||
|
||||
await onSave(sanitizedData);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'default', sigDefaultId || null);
|
||||
setIdentitySignature(identity.id, 'reply', sigReplyId || null);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -266,6 +280,57 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signature Store Mapping (per-identity) */}
|
||||
{isEditing && signatures.length > 0 && (
|
||||
<div className="border border-border rounded-md p-4 space-y-3 bg-muted/30">
|
||||
<p className="text-sm font-medium text-foreground">{t('signature_store_mapping')}</p>
|
||||
<div>
|
||||
<label htmlFor="identity-sig-default" className="block text-xs text-muted-foreground mb-1">
|
||||
{t('signature_store_default')}
|
||||
</label>
|
||||
<select
|
||||
id="identity-sig-default"
|
||||
value={sigDefaultId}
|
||||
onChange={(e) => {
|
||||
setSigDefaultId(e.target.value);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'default', e.target.value || null);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t('use_global_default')}</option>
|
||||
{signatures.map((sig) => (
|
||||
<option key={sig.id} value={sig.id}>{sig.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="identity-sig-reply" className="block text-xs text-muted-foreground mb-1">
|
||||
{t('signature_store_reply')}
|
||||
</label>
|
||||
<select
|
||||
id="identity-sig-reply"
|
||||
value={sigReplyId}
|
||||
onChange={(e) => {
|
||||
setSigReplyId(e.target.value);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'reply', e.target.value || null);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t('use_global_default')}</option>
|
||||
{signatures.map((sig) => (
|
||||
<option key={sig.id} value={sig.id}>{sig.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Signature */}
|
||||
<div>
|
||||
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
|
||||
|
||||
Reference in New Issue
Block a user