Files
SRCmail/components/identity/identity-form.tsx
T
Bernd Rodler 67f61f18d0 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
2026-08-07 12:55:14 +02:00

424 lines
15 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
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;
const utf8Encoder = new TextEncoder();
function utf8ByteLength(s: string): number {
return utf8Encoder.encode(s).length;
}
function truncateToUtf8Bytes(s: string, maxBytes: number): string {
if (utf8ByteLength(s) <= maxBytes) return s;
let lo = 0;
let hi = s.length;
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1;
if (utf8ByteLength(s.slice(0, mid)) <= maxBytes) lo = mid;
else hi = mid - 1;
}
// Don't split a surrogate pair: if we landed right after a high surrogate, back off one code unit.
if (lo > 0) {
const prev = s.charCodeAt(lo - 1);
if (prev >= 0xD800 && prev <= 0xDBFF) lo -= 1;
}
return s.slice(0, lo);
}
interface IdentityFormData {
name: string;
email: string;
replyTo?: EmailAddress[] | null;
bcc?: EmailAddress[] | null;
textSignature?: string | null;
htmlSignature?: string | null;
}
interface IdentityFormProps {
identity?: Identity;
onSave: (data: IdentityFormData) => Promise<void>;
onCancel: () => void;
}
export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps) {
const t = useTranslations('identities.form');
const tValidation = useTranslations('identities.validation_errors');
const tDisplay = useTranslations('identities.display');
const isEditing = !!identity;
const [formData, setFormData] = useState<IdentityFormData>({
name: identity?.name || '',
email: identity?.email || '',
replyTo: identity?.replyTo,
bcc: identity?.bcc,
textSignature: identity?.textSignature || '',
htmlSignature: identity?.htmlSignature || '',
});
const [replyToInput, setReplyToInput] = useState(
identity?.replyTo?.map(a => a.email).join(', ') || ''
);
const [bccInput, setBccInput] = useState(
identity?.bcc?.map(a => a.email).join(', ') || ''
);
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;
const emails = input.split(',').map(e => e.trim()).filter(Boolean);
return emails.map(email => ({ email }));
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = t('name_required');
}
// Use secure email validation
const emailError = getEmailValidationError(formData.email);
if (emailError) {
newErrors.email = emailError;
}
// Validate reply-to email list
if (replyToInput.trim()) {
const validation = validateEmailList(replyToInput);
if (!validation.valid) {
newErrors.replyTo = tValidation('invalid_emails', { emails: validation.invalidEmails.join(', ') });
}
}
// Validate bcc email list
if (bccInput.trim()) {
const validation = validateEmailList(bccInput);
if (!validation.valid) {
newErrors.bcc = tValidation('invalid_emails', { emails: validation.invalidEmails.join(', ') });
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
// JMAP needs explicit null to clear a field; undefined would be dropped
// from the JSON payload and leave the server-side value untouched.
const trimmedText = formData.textSignature?.trim() ?? '';
const trimmedHtml = formData.htmlSignature?.trim() ?? '';
const sanitizedData: IdentityFormData = {
...formData,
textSignature: trimmedText ? formData.textSignature : null,
htmlSignature: trimmedHtml ? sanitizeSignatureHtml(formData.htmlSignature!) : null,
replyTo: parseEmailList(replyToInput) ?? null,
bcc: parseEmailList(bccInput) ?? null,
};
await onSave(sanitizedData);
if (identity?.id) {
setIdentitySignature(identity.id, 'default', sigDefaultId || null);
setIdentitySignature(identity.id, 'reply', sigReplyId || null);
}
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
<label htmlFor="identity-name" className="block text-sm font-medium mb-1">
{t('name_label')} <span className="text-destructive">*</span>
</label>
<Input
id="identity-name"
type="text"
maxLength={256}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('name_placeholder')}
disabled={isSubmitting}
className={errors.name ? 'border-destructive' : ''}
aria-describedby={errors.name ? 'name-error' : undefined}
aria-invalid={!!errors.name}
/>
{errors.name && (
<p
id="name-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.name}
</p>
)}
</div>
{/* Email */}
<div>
<label htmlFor="identity-email" className="block text-sm font-medium mb-1">
{t('email_label')} <span className="text-destructive">*</span>
</label>
<Input
id="identity-email"
type="email"
maxLength={254}
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder={t('email_placeholder')}
disabled={isSubmitting || isEditing}
className={errors.email ? 'border-destructive' : ''}
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
/>
{isEditing && (
<p className="text-sm text-muted-foreground mt-1">
{t('email_immutable')}
</p>
)}
{errors.email && (
<p
id="email-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.email}
</p>
)}
</div>
{/* Reply-To */}
<div>
<label htmlFor="identity-reply-to" className="block text-sm font-medium mb-1">
{t('reply_to_label')}
</label>
<Input
id="identity-reply-to"
type="text"
maxLength={512}
value={replyToInput}
onChange={(e) => setReplyToInput(e.target.value)}
placeholder={t('reply_to_placeholder')}
disabled={isSubmitting}
className={errors.replyTo ? 'border-destructive' : ''}
aria-describedby={errors.replyTo ? 'reply-to-error' : undefined}
aria-invalid={!!errors.replyTo}
/>
{errors.replyTo && (
<p
id="reply-to-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.replyTo}
</p>
)}
</div>
{/* BCC */}
<div>
<label htmlFor="identity-bcc" className="block text-sm font-medium mb-1">
{t('bcc_label')}
</label>
<Input
id="identity-bcc"
type="text"
maxLength={512}
value={bccInput}
onChange={(e) => setBccInput(e.target.value)}
placeholder={t('bcc_placeholder')}
disabled={isSubmitting}
className={errors.bcc ? 'border-destructive' : ''}
aria-describedby={errors.bcc ? 'bcc-error' : undefined}
aria-invalid={!!errors.bcc}
/>
{errors.bcc && (
<p
id="bcc-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.bcc}
</p>
)}
</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">
{t('text_signature_label')}
</label>
<textarea
id="identity-text-sig"
value={formData.textSignature ?? ''}
onChange={(e) => setFormData({ ...formData, textSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
rows={3}
disabled={isSubmitting}
aria-label={t('text_signature_label')}
aria-describedby="identity-text-sig-counter"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<SignatureByteCounter id="identity-text-sig-counter" value={formData.textSignature || ''} />
</div>
{/* HTML Signature */}
<div>
<label htmlFor="identity-html-sig" className="block text-sm font-medium mb-1">
{t('html_signature_label')}
</label>
<textarea
id="identity-html-sig"
value={formData.htmlSignature ?? ''}
onChange={(e) => setFormData({ ...formData, htmlSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
rows={5}
disabled={isSubmitting}
aria-label={t('html_signature_label')}
aria-describedby="identity-html-sig-counter"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<SignatureByteCounter id="identity-html-sig-counter" value={formData.htmlSignature || ''} />
{formData.htmlSignature && (
<div className="mt-2 p-2 border rounded bg-muted">
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
<div
dangerouslySetInnerHTML={{
__html: sanitizeSignatureHtmlForDisplay(formData.htmlSignature)
}}
/>
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
{t('cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? isEditing
? t('updating')
: t('creating')
: t('save')}
</Button>
</div>
</form>
);
}
function SignatureByteCounter({ id, value }: { id: string; value: string }) {
const t = useTranslations('identities.form');
const bytes = utf8ByteLength(value);
const atLimit = bytes >= SIGNATURE_MAX_BYTES;
const nearLimit = !atLimit && bytes >= Math.floor(SIGNATURE_MAX_BYTES * 0.9);
const tone = atLimit
? 'text-destructive'
: nearLimit
? 'text-amber-600 dark:text-amber-400'
: 'text-muted-foreground';
return (
<p
id={id}
className={`text-xs mt-1 tabular-nums ${tone}`}
role="status"
aria-live="polite"
>
{t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })}
{atLimit && <span className="ms-1">{t('signature_byte_limit_reached')}</span>}
</p>
);
}