feat: show size cap on identity signature fields
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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í...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "作成中...",
|
||||
|
||||
@@ -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": "만드는 중...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "Создание...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "Створення...",
|
||||
|
||||
@@ -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": "创建中...",
|
||||
|
||||
Reference in New Issue
Block a user