'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; 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({ 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>({}); const { signatures, identitySignatureMap, setIdentitySignature, } = useSignatureStore(); const identitySigMapping = identity?.id ? (identitySignatureMap[identity.id] ?? {}) : {}; const [sigDefaultId, setSigDefaultId] = useState(identitySigMapping.defaultId ?? ''); const [sigReplyId, setSigReplyId] = useState(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 = {}; 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 (
{/* Name */}
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 && ( )}
{/* Email */}
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 && (

{t('email_immutable')}

)} {errors.email && ( )}
{/* Reply-To */}
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 && ( )}
{/* BCC */}
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 && ( )}
{/* Signature Store Mapping (per-identity) */} {isEditing && signatures.length > 0 && (

{t('signature_store_mapping')}

)} {/* Text Signature */}