diff --git a/web/src/lib/__tests__/signatureHtml.test.ts b/web/src/lib/__tests__/signatureHtml.test.ts
index 8671260..6f073b6 100644
--- a/web/src/lib/__tests__/signatureHtml.test.ts
+++ b/web/src/lib/__tests__/signatureHtml.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { buildMarkerSignature, compactHtml, markerOf, SIGNATURE_LIMIT } from "../signatureHtml";
+import { buildMarkerSignature, byteLength, compactHtml, markerOf, signatureTooLong, SIGNATURE_LIMIT } from "../signatureHtml";
describe("signature compaction", () => {
it("strips office cruft and non-essential styles but keeps colours and links", () => {
@@ -24,3 +24,59 @@ describe("signature compaction", () => {
expect(markerOf("
plain
")).toBeNull();
});
});
+
+/**
+ * Stalwart's cap is `value.len() < 2048` on a Rust string — 2047 bytes of
+ * UTF-8. Measuring with JavaScript's `.length` counts UTF-16 units instead,
+ * which agrees only for ASCII: an accent is one unit and two bytes, CJK three,
+ * an emoji two units and four. Every check has to weigh the encoded form or a
+ * signature we judged to fit comes back rejected.
+ */
+describe("signature size is measured in bytes", () => {
+ const sigOf = (html: string) => buildMarkerSignature("blob123", html);
+
+ it("counts multi-byte characters at their encoded size", () => {
+ expect(byteLength("hello")).toBe(5);
+ expect(byteLength("Grüße")).toBe(7); // two 2-byte characters
+ expect(byteLength("日本語")).toBe(9); // three 3-byte characters
+ expect(byteLength("🎉")).toBe(4); // one 4-byte character, two UTF-16 units
+ });
+
+ it("spots a signature that fits in characters but not in bytes", () => {
+ // Comfortably under the limit counted as characters, well over it as bytes.
+ const cjk = "日".repeat(1200);
+ expect(cjk.length).toBeLessThan(SIGNATURE_LIMIT);
+ expect(signatureTooLong(cjk, cjk)).toBe(true);
+ });
+
+ it("keeps a marker signature within the byte limit for non-ASCII text", () => {
+ for (const filler of ["ü", "日", "🎉", "x"]) {
+ const m = sigOf(`${filler.repeat(3000)}
`);
+ expect(byteLength(m.htmlSignature), `html for ${filler}`).toBeLessThanOrEqual(SIGNATURE_LIMIT);
+ expect(byteLength(m.textSignature), `text for ${filler}`).toBeLessThanOrEqual(SIGNATURE_LIMIT);
+ expect(markerOf(m.htmlSignature)).toEqual({ blobId: "blob123", type: "text/html" });
+ }
+ });
+
+ it("never truncates through a surrogate pair", () => {
+ const m = sigOf(`${"🎉".repeat(3000)}
`);
+ // A split pair leaves a lone surrogate, which encodes as U+FFFD.
+ expect(m.htmlSignature).not.toContain("�");
+ expect(m.textSignature).not.toContain("�");
+ expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(m.textSignature)).toBe(false);
+ });
+
+ it("never truncates through an HTML entity", () => {
+ // Escaping turns each of these into a 5-character entity; cutting the
+ // rendered string could leave "&am" behind.
+ const m = sigOf(`${"a & b ".repeat(400)}
`);
+ expect(m.htmlSignature).not.toMatch(/&[a-z]*$/i);
+ expect(m.htmlSignature.replace(/&(amp|lt|gt|quot|#39);/g, "")).not.toContain("&");
+ });
+
+ it("leaves a signature that already fits completely alone", () => {
+ const m = sigOf("Grüße, John
");
+ expect(m.textSignature).toBe("Grüße, John");
+ expect(m.htmlSignature).not.toContain("…");
+ });
+});
diff --git a/web/src/lib/signatureHtml.ts b/web/src/lib/signatureHtml.ts
index 493ada9..1dbc3f4 100644
--- a/web/src/lib/signatureHtml.ts
+++ b/web/src/lib/signatureHtml.ts
@@ -6,8 +6,45 @@
*/
import { escapeHtml, htmlToText } from "./text";
+/**
+ * Stalwart accepts a signature of `value.len() < 2048` — and that is Rust's
+ * `len()`, so the limit is 2047 **bytes of UTF-8**, not characters. A string's
+ * `.length` in JavaScript counts UTF-16 units, which matches only for ASCII: an
+ * accent is one unit but two bytes, CJK three, an emoji two units and four. So
+ * every check here weighs the encoded form, or a signature we judged to fit
+ * would come back rejected.
+ */
export const SIGNATURE_LIMIT = 2047;
+const encoder = new TextEncoder();
+
+export function byteLength(s: string): number {
+ return encoder.encode(s).length;
+}
+
+/**
+ * Render `text` into at most `budget` bytes, appending an ellipsis if it had to
+ * be cut. Cutting the *source* text and rendering afterwards — rather than
+ * slicing the rendered string — means a cut can never land inside an HTML
+ * entity or a `
`; stepping through code points means it never splits a
+ * surrogate pair either. Binary search keeps it to a handful of encodes.
+ */
+function renderWithinBytes(text: string, budget: number, render: (t: string) => string): string {
+ const whole = render(text);
+ if (byteLength(whole) <= budget) return whole;
+ const ellipsis = "…";
+ if (budget < byteLength(ellipsis)) return "";
+ const chars = Array.from(text);
+ let lo = 0;
+ let hi = chars.length;
+ while (lo < hi) {
+ const mid = Math.ceil((lo + hi) / 2);
+ if (byteLength(render(chars.slice(0, mid).join("")) + ellipsis) <= budget) lo = mid;
+ else hi = mid - 1;
+ }
+ return render(chars.slice(0, lo).join("")) + ellipsis;
+}
+
const KEEP_STYLES = new Set(["color", "background-color", "font-weight", "font-style", "text-decoration", "font-size", "font-family", "text-align", "vertical-align", "width", "height", "max-width", "border", "border-left", "padding-left", "margin"]);
const KEEP_ATTRS = new Set(["href", "src", "alt", "width", "height", "target", "style", "title", "colspan", "rowspan", "cellpadding", "cellspacing", "border", "align", "valign"]);
const DROP_TAGS = new Set(["META", "STYLE", "SCRIPT", "LINK", "TITLE", "HEAD", "O:P", "XML", "NOSCRIPT", "IFRAME", "OBJECT", "EMBED", "FORM", "INPUT", "BUTTON"]);
@@ -96,8 +133,15 @@ export function markerOf(htmlSignature: string | null | undefined): { blobId: st
export function buildMarkerSignature(blobId: string, fullHtml: string): { htmlSignature: string; textSignature: string } {
const text = htmlToText(fullHtml);
const marker = ``;
- const budget = SIGNATURE_LIMIT - marker.length - 11; //
- let fallback = escapeHtml(text).replace(/\n/g, "
");
- if (fallback.length > budget) fallback = `${fallback.slice(0, Math.max(0, budget - 1))}…`;
- return { htmlSignature: `${marker}${fallback}
`, textSignature: text.length > SIGNATURE_LIMIT ? `${text.slice(0, SIGNATURE_LIMIT - 1)}…` : text };
+ const budget = SIGNATURE_LIMIT - byteLength(marker) - "".length;
+ const fallback = renderWithinBytes(text, budget, (t) => escapeHtml(t).replace(/\n/g, "
"));
+ return {
+ htmlSignature: `${marker}${fallback}
`,
+ textSignature: renderWithinBytes(text, SIGNATURE_LIMIT, (t) => t),
+ };
+}
+
+/** Whether a signature would be refused by the server as it stands. */
+export function signatureTooLong(htmlSignature: string, textSignature: string): boolean {
+ return byteLength(htmlSignature) > SIGNATURE_LIMIT || byteLength(textSignature) > SIGNATURE_LIMIT;
}
diff --git a/web/src/views/settings/IdentitiesSettings.tsx b/web/src/views/settings/IdentitiesSettings.tsx
index 92a6cab..1defef3 100644
--- a/web/src/views/settings/IdentitiesSettings.tsx
+++ b/web/src/views/settings/IdentitiesSettings.tsx
@@ -10,7 +10,7 @@ import { parseAddressList, formatAddressList } from "@/lib/address";
import { htmlToText } from "@/lib/text";
import { sanitizeEditorHtml } from "@/lib/html";
import { externalizeDataImages, storeSignatureHtml, uploadSignatureImage } from "@/lib/signatureImages";
-import { buildMarkerSignature, compactHtml, SIGNATURE_LIMIT } from "@/lib/signatureHtml";
+import { buildMarkerSignature, byteLength, compactHtml, signatureTooLong, SIGNATURE_LIMIT } from "@/lib/signatureHtml";
export function IdentitiesSettings() {
const identities = useMail((s) => s.identities);
@@ -57,8 +57,9 @@ function IdentityDialog({ identity, onClose }: { identity: Partial; on
const [busy, setBusy] = useState(false);
const ref = useRef(null);
const compact = compactHtml(sanitizeEditorHtml(html));
- const sigLen = compact.length;
- const tooLong = sigLen > SIGNATURE_LIMIT || htmlToText(compact).length > SIGNATURE_LIMIT;
+ // The server's limit is on encoded bytes, so that is what to count and show.
+ const sigLen = byteLength(compact);
+ const tooLong = signatureTooLong(compact, htmlToText(compact));
const save = async () => {
setBusy(true);
try {
@@ -67,7 +68,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial; on
const clean = compactHtml(externalized);
let htmlSignature = clean;
let textSignature = htmlToText(clean);
- if (clean.length > SIGNATURE_LIMIT || textSignature.length > SIGNATURE_LIMIT) {
+ if (signatureTooLong(clean, textSignature)) {
const blobId = await storeSignatureHtml(clean);
({ htmlSignature, textSignature } = buildMarkerSignature(blobId, clean));
}
@@ -103,7 +104,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial; on
Images are stored in your Files (folder “ihasmail”) and embedded when you send.
{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}
- {tooLong && This signature is larger than the server's {SIGNATURE_LIMIT}-character limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.
}
+ {tooLong && This signature is larger than the server's {SIGNATURE_LIMIT}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.
}
);