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 { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation'; 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 { interface IdentityFormData {
name: string; name: string;
email: string; email: string;
@@ -246,14 +271,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label> </label>
<textarea <textarea
id="identity-text-sig" id="identity-text-sig"
maxLength={2000}
value={formData.textSignature} 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} rows={3}
disabled={isSubmitting} disabled={isSubmitting}
aria-label={t('text_signature_label')} 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" 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> </div>
{/* HTML Signature */} {/* HTML Signature */}
@@ -263,14 +289,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label> </label>
<textarea <textarea
id="identity-html-sig" id="identity-html-sig"
maxLength={50000}
value={formData.htmlSignature} 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} rows={5}
disabled={isSubmitting} disabled={isSubmitting}
aria-label={t('html_signature_label')} 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" 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 && ( {formData.htmlSignature && (
<div className="mt-2 p-2 border rounded bg-muted"> <div className="mt-2 p-2 border rounded bg-muted">
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div> <div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
@@ -304,3 +331,26 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</form> </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", "bcc_placeholder": "archiv@email.com",
"text_signature_label": "Textový podpis", "text_signature_label": "Textový podpis",
"html_signature_label": "HTML podpis", "html_signature_label": "HTML podpis",
"signature_byte_counter": "{bytes} / {max} bajtů",
"signature_byte_limit_reached": "Dosažen limit serveru",
"save": "Uložit identitu", "save": "Uložit identitu",
"cancel": "Zrušit", "cancel": "Zrušit",
"creating": "Vytváření...", "creating": "Vytváření...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "arkiv@email.dk", "bcc_placeholder": "arkiv@email.dk",
"text_signature_label": "Tekstsignatur", "text_signature_label": "Tekstsignatur",
"html_signature_label": "HTML-signatur", "html_signature_label": "HTML-signatur",
"signature_byte_counter": "{bytes} / {max} bytes",
"signature_byte_limit_reached": "Servergrænse nået",
"save": "Gem identitet", "save": "Gem identitet",
"cancel": "Annuller", "cancel": "Annuller",
"creating": "Opretter...", "creating": "Opretter...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archiv@email.de", "bcc_placeholder": "archiv@email.de",
"text_signature_label": "Text-Signatur", "text_signature_label": "Text-Signatur",
"html_signature_label": "HTML-Signatur", "html_signature_label": "HTML-Signatur",
"signature_byte_counter": "{bytes} / {max} Bytes",
"signature_byte_limit_reached": "Serverlimit erreicht",
"save": "Identität speichern", "save": "Identität speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"creating": "Wird erstellt...", "creating": "Wird erstellt...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com", "bcc_placeholder": "archive@email.com",
"text_signature_label": "Text Signature", "text_signature_label": "Text Signature",
"html_signature_label": "HTML Signature", "html_signature_label": "HTML Signature",
"signature_byte_counter": "{bytes} / {max} bytes",
"signature_byte_limit_reached": "Server limit reached",
"save": "Save Identity", "save": "Save Identity",
"cancel": "Cancel", "cancel": "Cancel",
"creating": "Creating...", "creating": "Creating...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archivo@correo.com", "bcc_placeholder": "archivo@correo.com",
"text_signature_label": "Firma de Texto", "text_signature_label": "Firma de Texto",
"html_signature_label": "Firma HTML", "html_signature_label": "Firma HTML",
"signature_byte_counter": "{bytes} / {max} bytes",
"signature_byte_limit_reached": "Límite del servidor alcanzado",
"save": "Guardar Identidad", "save": "Guardar Identidad",
"cancel": "Cancelar", "cancel": "Cancelar",
"creating": "Creando...", "creating": "Creando...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com", "bcc_placeholder": "archive@email.com",
"text_signature_label": "Signature texte", "text_signature_label": "Signature texte",
"html_signature_label": "Signature HTML", "html_signature_label": "Signature HTML",
"signature_byte_counter": "{bytes} / {max} octets",
"signature_byte_limit_reached": "Limite du serveur atteinte",
"save": "Enregistrer l'identité", "save": "Enregistrer l'identité",
"cancel": "Annuler", "cancel": "Annuler",
"creating": "Création...", "creating": "Création...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archivio@email.com", "bcc_placeholder": "archivio@email.com",
"text_signature_label": "Firma di testo", "text_signature_label": "Firma di testo",
"html_signature_label": "Firma HTML", "html_signature_label": "Firma HTML",
"signature_byte_counter": "{bytes} / {max} byte",
"signature_byte_limit_reached": "Limite del server raggiunto",
"save": "Salva identità", "save": "Salva identità",
"cancel": "Annulla", "cancel": "Annulla",
"creating": "Creazione...", "creating": "Creazione...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com", "bcc_placeholder": "archive@email.com",
"text_signature_label": "テキスト署名", "text_signature_label": "テキスト署名",
"html_signature_label": "HTML署名", "html_signature_label": "HTML署名",
"signature_byte_counter": "{bytes} / {max} バイト",
"signature_byte_limit_reached": "サーバーの上限に達しました",
"save": "送信者情報を保存", "save": "送信者情報を保存",
"cancel": "キャンセル", "cancel": "キャンセル",
"creating": "作成中...", "creating": "作成中...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com", "bcc_placeholder": "archive@email.com",
"text_signature_label": "텍스트 서명", "text_signature_label": "텍스트 서명",
"html_signature_label": "HTML 서명", "html_signature_label": "HTML 서명",
"signature_byte_counter": "{bytes} / {max} 바이트",
"signature_byte_limit_reached": "서버 한도 도달",
"save": "저장", "save": "저장",
"cancel": "취소", "cancel": "취소",
"creating": "만드는 중...", "creating": "만드는 중...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "arhivs@piemers.lv", "bcc_placeholder": "arhivs@piemers.lv",
"text_signature_label": "Teksta paraksts", "text_signature_label": "Teksta paraksts",
"html_signature_label": "HTML 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", "save": "Saglabāt identitāti",
"cancel": "Atcelt", "cancel": "Atcelt",
"creating": "Izveido...", "creating": "Izveido...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archief@email.nl", "bcc_placeholder": "archief@email.nl",
"text_signature_label": "Teksthandtekening", "text_signature_label": "Teksthandtekening",
"html_signature_label": "HTML-handtekening", "html_signature_label": "HTML-handtekening",
"signature_byte_counter": "{bytes} / {max} bytes",
"signature_byte_limit_reached": "Serverlimiet bereikt",
"save": "Identiteit opslaan", "save": "Identiteit opslaan",
"cancel": "Annuleren", "cancel": "Annuleren",
"creating": "Aanmaken...", "creating": "Aanmaken...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com", "bcc_placeholder": "archive@email.com",
"text_signature_label": "Podpis tekstowy", "text_signature_label": "Podpis tekstowy",
"html_signature_label": "Podpis HTML", "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ść", "save": "Zapisz tożsamość",
"cancel": "Anuluj", "cancel": "Anuluj",
"creating": "Tworzenie...", "creating": "Tworzenie...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "arquivo@email.com", "bcc_placeholder": "arquivo@email.com",
"text_signature_label": "Assinatura de Texto", "text_signature_label": "Assinatura de Texto",
"html_signature_label": "Assinatura HTML", "html_signature_label": "Assinatura HTML",
"signature_byte_counter": "{bytes} / {max} bytes",
"signature_byte_limit_reached": "Limite do servidor atingido",
"save": "Salvar Identidade", "save": "Salvar Identidade",
"cancel": "Cancelar", "cancel": "Cancelar",
"creating": "Criando...", "creating": "Criando...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@пример.рф", "bcc_placeholder": "archive@пример.рф",
"text_signature_label": "Текстовая подпись", "text_signature_label": "Текстовая подпись",
"html_signature_label": "HTML-подпись", "html_signature_label": "HTML-подпись",
"signature_byte_counter": "{bytes} / {max} байт",
"signature_byte_limit_reached": "Достигнут лимит сервера",
"save": "Сохранить идентификацию", "save": "Сохранить идентификацию",
"cancel": "Отмена", "cancel": "Отмена",
"creating": "Создание...", "creating": "Создание...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "arsiv@email.com", "bcc_placeholder": "arsiv@email.com",
"text_signature_label": "Metin İmzası", "text_signature_label": "Metin İmzası",
"html_signature_label": "HTML İ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", "save": "Kimliği Kaydet",
"cancel": "İptal", "cancel": "İptal",
"creating": "Oluşturuluyor...", "creating": "Oluşturuluyor...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@email.com", "bcc_placeholder": "archive@email.com",
"text_signature_label": "Текстовий підпис", "text_signature_label": "Текстовий підпис",
"html_signature_label": "Підпис HTML", "html_signature_label": "Підпис HTML",
"signature_byte_counter": "{bytes} / {max} байтів",
"signature_byte_limit_reached": "Досягнуто обмеження сервера",
"save": "Зберегти ідентифікатор", "save": "Зберегти ідентифікатор",
"cancel": "Скасувати", "cancel": "Скасувати",
"creating": "Створення...", "creating": "Створення...",
+2
View File
@@ -1863,6 +1863,8 @@
"bcc_placeholder": "archive@example.com", "bcc_placeholder": "archive@example.com",
"text_signature_label": "文字签名", "text_signature_label": "文字签名",
"html_signature_label": "HTML 签名", "html_signature_label": "HTML 签名",
"signature_byte_counter": "{bytes} / {max} 字节",
"signature_byte_limit_reached": "已达服务器限制",
"save": "保存身份", "save": "保存身份",
"cancel": "取消", "cancel": "取消",
"creating": "创建中...", "creating": "创建中...",