Signatures render into the main document - the identity form's live preview and the composer's signature block - rather than the sandboxed iframe used for message bodies. SIGNATURE_SANITIZE_CONFIG allows no target attribute, so those anchors were live and target-less: one click navigated the whole app away, discarding the unsent draft or the unsaved signature with it. Add sanitizeSignatureHtmlForDisplay, which keeps the storage sanitizer's image restrictions but forces target="_blank" rel="noopener noreferrer" on every anchor, and use it at the two render sites. The composer's SignatureBlock NodeView stamps the target on its rendered DOM instead, because attrs.html is what serializeEditorContent emits into the sent message - storage and the recipient's copy stay exactly as the user wrote them.
359 lines
12 KiB
TypeScript
359 lines
12 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';
|
|
|
|
// 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 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);
|
|
} 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>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|