feat: show size cap on identity signature fields

This commit is contained in:
Linus Rath
2026-05-18 00:34:01 +02:00
parent 92127e2f00
commit 7a72903632
18 changed files with 88 additions and 4 deletions
+54 -4
View File
@@ -8,6 +8,31 @@ import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
// JMAP Identity/set caps signature fields at 2047 UTF-8 bytes per RFC 8621 §6.1.
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;
@@ -246,14 +271,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label>
<textarea
id="identity-text-sig"
maxLength={2000}
value={formData.textSignature}
onChange={(e) => setFormData({ ...formData, textSignature: e.target.value })}
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 */}
@@ -263,14 +289,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label>
<textarea
id="identity-html-sig"
maxLength={50000}
value={formData.htmlSignature}
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
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>
@@ -304,3 +331,26 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</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="ml-1">{t('signature_byte_limit_reached')}</span>}
</p>
);
}
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archiv@email.com",
"text_signature_label": "Textový podpis",
"html_signature_label": "HTML podpis",
"signature_byte_counter": "{bytes} / {max} bajtů",
"signature_byte_limit_reached": "Dosažen limit serveru",
"save": "Uložit identitu",
"cancel": "Zrušit",
"creating": "Vytváření...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "arkiv@email.dk",
"text_signature_label": "Tekstsignatur",
"html_signature_label": "HTML-signatur",
"signature_byte_counter": "{bytes} / {max} bytes",
"signature_byte_limit_reached": "Servergrænse nået",
"save": "Gem identitet",
"cancel": "Annuller",
"creating": "Opretter...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archiv@email.de",
"text_signature_label": "Text-Signatur",
"html_signature_label": "HTML-Signatur",
"signature_byte_counter": "{bytes} / {max} Bytes",
"signature_byte_limit_reached": "Serverlimit erreicht",
"save": "Identität speichern",
"cancel": "Abbrechen",
"creating": "Wird erstellt...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com",
"text_signature_label": "Text Signature",
"html_signature_label": "HTML Signature",
"signature_byte_counter": "{bytes} / {max} bytes",
"signature_byte_limit_reached": "Server limit reached",
"save": "Save Identity",
"cancel": "Cancel",
"creating": "Creating...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archivo@correo.com",
"text_signature_label": "Firma de Texto",
"html_signature_label": "Firma HTML",
"signature_byte_counter": "{bytes} / {max} bytes",
"signature_byte_limit_reached": "Límite del servidor alcanzado",
"save": "Guardar Identidad",
"cancel": "Cancelar",
"creating": "Creando...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com",
"text_signature_label": "Signature texte",
"html_signature_label": "Signature HTML",
"signature_byte_counter": "{bytes} / {max} octets",
"signature_byte_limit_reached": "Limite du serveur atteinte",
"save": "Enregistrer l'identité",
"cancel": "Annuler",
"creating": "Création...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archivio@email.com",
"text_signature_label": "Firma di testo",
"html_signature_label": "Firma HTML",
"signature_byte_counter": "{bytes} / {max} byte",
"signature_byte_limit_reached": "Limite del server raggiunto",
"save": "Salva identità",
"cancel": "Annulla",
"creating": "Creazione...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com",
"text_signature_label": "テキスト署名",
"html_signature_label": "HTML署名",
"signature_byte_counter": "{bytes} / {max} バイト",
"signature_byte_limit_reached": "サーバーの上限に達しました",
"save": "送信者情報を保存",
"cancel": "キャンセル",
"creating": "作成中...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com",
"text_signature_label": "텍스트 서명",
"html_signature_label": "HTML 서명",
"signature_byte_counter": "{bytes} / {max} 바이트",
"signature_byte_limit_reached": "서버 한도 도달",
"save": "저장",
"cancel": "취소",
"creating": "만드는 중...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "arhivs@piemers.lv",
"text_signature_label": "Teksta paraksts",
"html_signature_label": "HTML paraksts",
"signature_byte_counter": "{bytes} / {max} baiti",
"signature_byte_limit_reached": "Sasniegts servera ierobežojums",
"save": "Saglabāt identitāti",
"cancel": "Atcelt",
"creating": "Izveido...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archief@email.nl",
"text_signature_label": "Teksthandtekening",
"html_signature_label": "HTML-handtekening",
"signature_byte_counter": "{bytes} / {max} bytes",
"signature_byte_limit_reached": "Serverlimiet bereikt",
"save": "Identiteit opslaan",
"cancel": "Annuleren",
"creating": "Aanmaken...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com",
"text_signature_label": "Podpis tekstowy",
"html_signature_label": "Podpis HTML",
"signature_byte_counter": "{bytes} / {max} bajtów",
"signature_byte_limit_reached": "Osiągnięto limit serwera",
"save": "Zapisz tożsamość",
"cancel": "Anuluj",
"creating": "Tworzenie...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "arquivo@email.com",
"text_signature_label": "Assinatura de Texto",
"html_signature_label": "Assinatura HTML",
"signature_byte_counter": "{bytes} / {max} bytes",
"signature_byte_limit_reached": "Limite do servidor atingido",
"save": "Salvar Identidade",
"cancel": "Cancelar",
"creating": "Criando...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@пример.рф",
"text_signature_label": "Текстовая подпись",
"html_signature_label": "HTML-подпись",
"signature_byte_counter": "{bytes} / {max} байт",
"signature_byte_limit_reached": "Достигнут лимит сервера",
"save": "Сохранить идентификацию",
"cancel": "Отмена",
"creating": "Создание...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "arsiv@email.com",
"text_signature_label": "Metin İmzası",
"html_signature_label": "HTML İmzası",
"signature_byte_counter": "{bytes} / {max} bayt",
"signature_byte_limit_reached": "Sunucu sınırına ulaşıldı",
"save": "Kimliği Kaydet",
"cancel": "İptal",
"creating": "Oluşturuluyor...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com",
"text_signature_label": "Текстовий підпис",
"html_signature_label": "Підпис HTML",
"signature_byte_counter": "{bytes} / {max} байтів",
"signature_byte_limit_reached": "Досягнуто обмеження сервера",
"save": "Зберегти ідентифікатор",
"cancel": "Скасувати",
"creating": "Створення...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@example.com",
"text_signature_label": "文字签名",
"html_signature_label": "HTML 签名",
"signature_byte_counter": "{bytes} / {max} 字节",
"signature_byte_limit_reached": "已达服务器限制",
"save": "保存身份",
"cancel": "取消",
"creating": "创建中...",