From c92a68aba1d378f2fe81f5728f013eb78b1ddd6d Mon Sep 17 00:00:00 2001 From: John Ellis Date: Mon, 24 Aug 2026 09:32:39 -0700 Subject: [PATCH 1/5] Measure signatures in bytes, so the oversize fallback actually saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stalwart accepts a signature of `value.len() < 2048`, and that is Rust's len(): 2047 bytes of UTF-8. Every check here counted JavaScript `.length` instead, which is UTF-16 units and agrees only for ASCII — an accent is one unit and two bytes, CJK three, an emoji two units and four. That alone would let a non-Latin signature we judged to fit come back rejected. But the fallback that is supposed to rescue an oversize signature was broken outright, for everyone: it truncated to `budget - 1` characters and appended an ellipsis, one character but three bytes, so the result was always 2047 characters and 2049 bytes. Every marker signature Stalwart was ever offered was two bytes too long, ASCII included. That is why this flow has been sitting in the README as implemented but unconfirmed — the first person to exceed 2 KB would have hit it. Cutting the source text and rendering afterwards, rather than slicing the rendered string, also means a cut can no longer land inside an HTML entity, and stepping through code points means it cannot split a surrogate pair. The old tests used ASCII only, which is how this survived; the new ones weigh the encoded form. --- web/src/lib/__tests__/signatureHtml.test.ts | 58 ++++++++++++++++++- web/src/lib/signatureHtml.ts | 52 +++++++++++++++-- web/src/views/settings/IdentitiesSettings.tsx | 11 ++-- 3 files changed, 111 insertions(+), 10 deletions(-) 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.
} ); From ee7542fd86f657ae0dd7074f252c349c52eb4b13 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Mon, 24 Aug 2026 09:42:25 -0700 Subject: [PATCH 2/5] Stop sending nodeType to servers that have no such property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading a file or creating a folder failed on the live 0.15.5 server with `invalidProperties (nodeType)`. The property arrived in Stalwart 0.16; before that a FileNode has no nodeType at all, and the create is refused outright. Older servers tell a file from a directory a different way: the node carries file properties or it does not. Setting blobId, size or type — even to null — makes it a file, so a directory there is exactly parentId plus name. 0.16 is also the first release to advertise urn:stalwart:jmap and no earlier one knows that capability, so its presence stands in for "has the newer FileNode shape". Creates, and the property lists we ask for, are shaped from that. The read side needed it too: a server that never reports nodeType would have had every folder drawn with a file icon, sorted among the files and opening as a download. Nodes are normalised as they arrive, so everything downstream can still just read nodeType. --- web/src/lib/__tests__/filenode.test.ts | 83 ++++++++++++++++++++++++++ web/src/lib/filenode.ts | 56 +++++++++++++++++ web/src/lib/signatureImages.ts | 18 +++--- web/src/store/files.ts | 14 ++--- 4 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 web/src/lib/__tests__/filenode.test.ts create mode 100644 web/src/lib/filenode.ts diff --git a/web/src/lib/__tests__/filenode.test.ts b/web/src/lib/__tests__/filenode.test.ts new file mode 100644 index 0000000..c350d5e --- /dev/null +++ b/web/src/lib/__tests__/filenode.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { client } from "@/jmap/client"; +import { directoryCreate, fileCreate, fileNodeProps, supportsNodeType, withNodeType } from "../filenode"; +import type { FileNode, JmapSession } from "@/jmap/types"; + +/** + * `nodeType` arrived in Stalwart 0.16. Sending it to an older server fails the + * whole create with `invalidProperties (nodeType)` — which is what uploading a + * file or making a folder hit on the live 0.15.5 box. Those servers tell a file + * from a directory by whether it carries file properties at all. + */ + +function session(caps: string[]): JmapSession { + return { capabilities: Object.fromEntries(caps.map((c) => [c, {}])), accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession; +} + +const NEW_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode", "urn:stalwart:jmap"]; +const OLD_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode"]; + +afterEach(() => { + client.session = null; +}); + +describe("on Stalwart 0.16 and newer", () => { + it("uses nodeType everywhere", () => { + client.session = session(NEW_SERVER); + expect(supportsNodeType()).toBe(true); + expect(fileNodeProps()).toContain("nodeType"); + expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" }); + expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png", nodeType: "file" }); + }); + + it("leaves what the server reported alone", () => { + client.session = session(NEW_SERVER); + const nodes = [{ id: "1", name: "x", nodeType: "directory" }] as Partial[]; + expect(withNodeType(nodes)).toEqual(nodes); + }); +}); + +describe("on Stalwart before 0.16", () => { + it("never mentions nodeType, in creates or in requested properties", () => { + client.session = session(OLD_SERVER); + expect(supportsNodeType()).toBe(false); + expect(fileNodeProps()).not.toContain("nodeType"); + expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail" }); + expect(JSON.stringify(fileCreate("d1", "logo.png", "b1", "image/png"))).not.toContain("nodeType"); + }); + + it("keeps a directory free of file properties, which is what makes it one", () => { + client.session = session(OLD_SERVER); + const dir = directoryCreate(null, "ihasmail"); + // Setting blobId, size or type — even to null — would make this a file. + expect(dir).not.toHaveProperty("blobId"); + expect(dir).not.toHaveProperty("size"); + expect(dir).not.toHaveProperty("type"); + }); + + it("still sends what a file needs", () => { + client.session = session(OLD_SERVER); + expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png" }); + }); + + it("works out nodeType from the file properties, so folders stay folders", () => { + client.session = session(OLD_SERVER); + const out = withNodeType([ + { id: "1", name: "Documents", blobId: null, size: null, type: null }, + { id: "2", name: "notes.txt", blobId: "b1", size: 11, type: "text/plain" }, + { id: "3", name: "empty.txt", blobId: "b2", size: 0, type: null }, + ] as Partial[]); + expect(out.map((n) => n.nodeType)).toEqual(["directory", "file", "file"]); + }); + + it("does not overwrite a nodeType that did come back", () => { + client.session = session(OLD_SERVER); + const out = withNodeType([{ id: "1", name: "x", nodeType: "symlink", blobId: "b1" }] as Partial[]); + expect(out[0]!.nodeType).toBe("symlink"); + }); +}); + +it("assumes the older shape when there is no session yet", () => { + client.session = null; + expect(supportsNodeType()).toBe(false); +}); diff --git a/web/src/lib/filenode.ts b/web/src/lib/filenode.ts new file mode 100644 index 0000000..39e38b6 --- /dev/null +++ b/web/src/lib/filenode.ts @@ -0,0 +1,56 @@ +/** + * FileNode compatibility across Stalwart releases. + * + * `nodeType` arrived in 0.16. Before that a FileNode had no such property at + * all, and the server rejects the whole create with + * `invalidProperties (nodeType)` — which is what uploading a file or making a + * folder used to hit. Older servers instead tell a file from a directory by + * whether it carries file properties at all: set `blobId`, `size` or `type` + * (even to null) and the node becomes a file, leave them off and it is a + * directory. + * + * 0.16 is also the first release to advertise `urn:stalwart:jmap`, and no + * earlier one knows that capability, so its presence is a reliable stand-in for + * "this server has the newer FileNode shape". + */ +import { client } from "@/jmap/client"; +import type { FileNode, Id } from "@/jmap/types"; + +const STALWART_CAP = "urn:stalwart:jmap"; + +export function supportsNodeType(): boolean { + return client.hasCapability(STALWART_CAP); +} + +const BASE_PROPS = ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"]; + +/** Properties to request, asking for `nodeType` only where it exists. */ +export function fileNodeProps(): string[] { + return supportsNodeType() ? [...BASE_PROPS, "nodeType"] : BASE_PROPS; +} + +/** Create-arguments for a directory. */ +export function directoryCreate(parentId: Id | null, name: string): Record { + // Any file property — blobId, size, type — would make this a file on an + // older server, so a directory there is exactly parentId plus name. + return supportsNodeType() ? { parentId, name, nodeType: "directory" } : { parentId, name }; +} + +/** Create-arguments for a file with an already-uploaded blob. */ +export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record { + const base = { parentId, name, blobId, type }; + return supportsNodeType() ? { ...base, nodeType: "file" } : base; +} + +/** + * Fill in `nodeType` where the server does not report it, so everything + * downstream — icons, sorting, "is this a folder" — can rely on it. + */ +export function withNodeType>(nodes: T[]): T[] { + if (supportsNodeType()) return nodes; + return nodes.map((n) => (n.nodeType ? n : { ...n, nodeType: isFile(n) ? "file" : "directory" })); +} + +function isFile(n: Partial): boolean { + return n.blobId != null || n.size != null || n.type != null; +} diff --git a/web/src/lib/signatureImages.ts b/web/src/lib/signatureImages.ts index 16eaef5..9f73fe2 100644 --- a/web/src/lib/signatureImages.ts +++ b/web/src/lib/signatureImages.ts @@ -6,30 +6,34 @@ */ import { CAP, client, setErrorMessage } from "@/jmap/client"; import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types"; +import { directoryCreate, fileCreate, supportsNodeType, withNodeType } from "@/lib/filenode"; import { useSession } from "@/store/session"; import { toast } from "@/ui/toast"; const FOLDER = "ihasmail"; +/** Just enough to find the folder, asking for nodeType only where it exists. */ +const folderProps = () => (supportsNodeType() ? ["id", "name", "nodeType", "parentId"] : ["id", "name", "parentId", "blobId", "size", "type"]); + async function ensureFolder(accountId: string): Promise { let list: FileNode[] = []; try { const res = await client.chain([ ["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "nodeType", "parentId"] }, "g"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], ]); - list = (res.get("g")?.[0] as unknown as GetResponse).list; + list = withNodeType((res.get("g")?.[0] as unknown as GetResponse).list); } catch { // Older servers: no filter support — scan everything. const res = await client.chain([ ["FileNode/query", { accountId, limit: 1000 }, "q"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "nodeType", "parentId"] }, "g"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], ]); - list = (res.get("g")?.[0] as unknown as GetResponse).list; + list = withNodeType((res.get("g")?.[0] as unknown as GetResponse).list); } const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId); if (existing) return existing.id; - const set = await client.call>("FileNode/set", { accountId, create: { d: { parentId: null, name: FOLDER, nodeType: "directory" } } }); + const set = await client.call>("FileNode/set", { accountId, create: { d: directoryCreate(null, FOLDER) } }); const err = set.notCreated?.d; if (err) throw new Error(setErrorMessage(err)); return set.created!.d!.id; @@ -51,7 +55,7 @@ export async function uploadSignatureImage(file: File): Promise { const up = await client.upload(accountId, file, { type }); const folderId = await ensureFolder(accountId); const name = `${Date.now()}-${file.name.replace(/[^\w.-]+/g, "_")}`; - const res = await client.call>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type } } }); + const res = await client.call>("FileNode/set", { accountId, create: { f: fileCreate(folderId, name, up.blobId, type) } }); const err = res.notCreated?.f; if (err) throw new Error(setErrorMessage(err)); const created = res.created?.f as Partial | undefined; @@ -71,7 +75,7 @@ export async function storeSignatureHtml(html: string): Promise { const up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" }); const folderId = await ensureFolder(accountId); const name = `signature-${Date.now()}.html`; - const res = await client.call>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type: "text/html" } } }); + const res = await client.call>("FileNode/set", { accountId, create: { f: fileCreate(folderId, name, up.blobId, "text/html") } }); const err = res.notCreated?.f; if (err) throw new Error(setErrorMessage(err)); const created = res.created?.f as Partial | undefined; diff --git a/web/src/store/files.ts b/web/src/store/files.ts index f5cb530..21ba126 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client"; +import { directoryCreate, fileCreate, fileNodeProps, withNodeType } from "@/lib/filenode"; import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types"; import { useSession } from "./session"; @@ -23,7 +24,6 @@ interface FilesState { applyChanges(types: Set): void; } -const PROPS = ["id", "parentId", "nodeType", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"]; /** Whether the server supports parentId/isTopLevel query filters (detected at runtime). */ let filtersSupported = true; @@ -37,11 +37,11 @@ async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial< for (let guard = 0; guard < 100; guard++) { const res = await client.chain([ ["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: PROPS }, "g"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"], ]); const q = res.get("q")?.[0] as unknown as QueryResponse; const g = res.get("g")?.[0] as unknown as GetResponse; - all.push(...g.list); + all.push(...withNodeType(g.list)); position += q.ids.length; if (!q.ids.length || (q.total != null && position >= q.total)) break; } @@ -84,13 +84,13 @@ export const useFiles = create((set, get) => ({ const filter = parentId ? { parentId } : { isTopLevel: true }; const res = await client.chain([ ["FileNode/query", { accountId, filter, sort: [{ property: "nodeType", isAscending: false }, { property: "name", isAscending: true }], limit: 1000 }, "q"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: PROPS }, "g"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"], ]); const q = res.get("q")?.[0] as unknown as QueryResponse; const g = res.get("g")?.[0] as unknown as GetResponse; set((s) => { const nodes = { ...s.nodes }; - for (const n of g.list) nodes[n.id] = n; + for (const n of withNodeType(g.list)) nodes[n.id] = n; return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids }, loading: false, error: null }; }); } catch (err) { @@ -112,7 +112,7 @@ export const useFiles = create((set, get) => ({ async mkdir(parentId, name) { const accountId = get().accountId!; - const res = await client.call>("FileNode/set", { accountId, create: { d: { parentId, name, nodeType: "directory" } } }); + const res = await client.call>("FileNode/set", { accountId, create: { d: directoryCreate(parentId, name) } }); const err = res.notCreated?.d; if (err) throw new Error(setErrorMessage(err)); await get().loadChildren(parentId); @@ -131,7 +131,7 @@ export const useFiles = create((set, get) => ({ }); const res = await client.call>("FileNode/set", { accountId, - create: { f: { parentId, name: f.name, nodeType: "file", blobId: up.blobId, type: f.type || "application/octet-stream" } }, + create: { f: fileCreate(parentId, f.name, up.blobId, f.type || "application/octet-stream") }, }); const err = res.notCreated?.f; if (err) throw new Error(setErrorMessage(err)); From b8263aa78500751b9c4022f0c0c3390e839cb612 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Mon, 24 Aug 2026 09:56:53 -0700 Subject: [PATCH 3/5] Translate the older FileNode rights, so Rename and Delete work again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a file did nothing on the live 0.15.5 server, with no error: the menu items are gated on myRights.mayDelete and myRights.mayRename, and 0.16 was the release that split rights up. Before it a node carried mayRead, mayWrite and mayShare, with the one mayWrite covering everything the newer release names separately — so both items sat permanently disabled. Widen mayWrite into the four rights the newer shape names, alongside the nodeType normalisation, and the UI can keep reading the 0.16 vocabulary. --- web/src/lib/__tests__/filenode.test.ts | 44 +++++++++++++++++++++++--- web/src/lib/filenode.ts | 26 ++++++++++++--- web/src/lib/signatureImages.ts | 6 ++-- web/src/store/files.ts | 6 ++-- 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/web/src/lib/__tests__/filenode.test.ts b/web/src/lib/__tests__/filenode.test.ts index c350d5e..c932c94 100644 --- a/web/src/lib/__tests__/filenode.test.ts +++ b/web/src/lib/__tests__/filenode.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { client } from "@/jmap/client"; -import { directoryCreate, fileCreate, fileNodeProps, supportsNodeType, withNodeType } from "../filenode"; +import { directoryCreate, fileCreate, fileNodeProps, supportsNodeType, normalizeFileNodes } from "../filenode"; import type { FileNode, JmapSession } from "@/jmap/types"; /** @@ -33,7 +33,7 @@ describe("on Stalwart 0.16 and newer", () => { it("leaves what the server reported alone", () => { client.session = session(NEW_SERVER); const nodes = [{ id: "1", name: "x", nodeType: "directory" }] as Partial[]; - expect(withNodeType(nodes)).toEqual(nodes); + expect(normalizeFileNodes(nodes)).toEqual(nodes); }); }); @@ -62,7 +62,7 @@ describe("on Stalwart before 0.16", () => { it("works out nodeType from the file properties, so folders stay folders", () => { client.session = session(OLD_SERVER); - const out = withNodeType([ + const out = normalizeFileNodes([ { id: "1", name: "Documents", blobId: null, size: null, type: null }, { id: "2", name: "notes.txt", blobId: "b1", size: 11, type: "text/plain" }, { id: "3", name: "empty.txt", blobId: "b2", size: 0, type: null }, @@ -72,7 +72,7 @@ describe("on Stalwart before 0.16", () => { it("does not overwrite a nodeType that did come back", () => { client.session = session(OLD_SERVER); - const out = withNodeType([{ id: "1", name: "x", nodeType: "symlink", blobId: "b1" }] as Partial[]); + const out = normalizeFileNodes([{ id: "1", name: "x", nodeType: "symlink", blobId: "b1" }] as Partial[]); expect(out[0]!.nodeType).toBe("symlink"); }); }); @@ -81,3 +81,39 @@ it("assumes the older shape when there is no session yet", () => { client.session = null; expect(supportsNodeType()).toBe(false); }); + +/** + * Rights were split up in 0.16. Before that a node carried mayRead / mayWrite / + * mayShare, with mayWrite covering everything the newer release names + * separately — so Rename and Delete sat permanently greyed out, doing nothing + * and saying nothing. + */ +describe("rights on a pre-0.16 server", () => { + const oldRights = (mayWrite: boolean) => ({ mayRead: true, mayWrite, mayShare: false }); + + it("widens mayWrite into the rights the UI gates on", () => { + client.session = session(OLD_SERVER); + const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(true) }] as unknown as Partial[]); + expect(node!.myRights).toMatchObject({ mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: false }); + }); + + it("does not hand out rights the server withheld", () => { + client.session = session(OLD_SERVER); + const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(false) }] as unknown as Partial[]); + expect(node!.myRights).toMatchObject({ mayRename: false, mayDelete: false, mayModifyContent: false }); + }); + + it("leaves rights that already use the newer names untouched", () => { + client.session = session(OLD_SERVER); + const newer = { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: false, mayModifyContent: true, mayShare: true }; + const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: newer }] as unknown as Partial[]); + expect(node!.myRights).toEqual(newer); + }); + + it("copes with a node that reported no rights at all", () => { + client.session = session(OLD_SERVER); + const [node] = normalizeFileNodes([{ id: "1", name: "x" }] as Partial[]); + expect(node!.myRights).toBeUndefined(); + expect(node!.nodeType).toBe("directory"); + }); +}); diff --git a/web/src/lib/filenode.ts b/web/src/lib/filenode.ts index 39e38b6..a4270a3 100644 --- a/web/src/lib/filenode.ts +++ b/web/src/lib/filenode.ts @@ -43,12 +43,30 @@ export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: } /** - * Fill in `nodeType` where the server does not report it, so everything - * downstream — icons, sorting, "is this a folder" — can rely on it. + * Fill in what an older server does not report, so everything downstream — + * icons, sorting, "may I delete this" — can read the 0.16 shape. + * + * Rights were split up in 0.16. Before that a node carried `mayRead`, + * `mayWrite` and `mayShare`, with the one `mayWrite` covering everything the + * newer release names separately. Without translating it, the Rename and + * Delete menu items sit permanently greyed out: no error, just nothing. */ -export function withNodeType>(nodes: T[]): T[] { +export function normalizeFileNodes>(nodes: T[]): T[] { if (supportsNodeType()) return nodes; - return nodes.map((n) => (n.nodeType ? n : { ...n, nodeType: isFile(n) ? "file" : "directory" })); + return nodes.map((n) => ({ + ...n, + nodeType: n.nodeType ?? (isFile(n) ? "file" : "directory"), + myRights: widenRights(n.myRights), + })); +} + +type Rights = FileNode["myRights"]; + +function widenRights(rights: Rights | undefined): Rights | undefined { + if (!rights) return rights; + const r = rights as Rights & { mayWrite?: boolean }; + if (r.mayDelete !== undefined || r.mayWrite === undefined) return rights; // already the newer shape + return { ...r, mayAddChildren: r.mayWrite, mayRename: r.mayWrite, mayDelete: r.mayWrite, mayModifyContent: r.mayWrite }; } function isFile(n: Partial): boolean { diff --git a/web/src/lib/signatureImages.ts b/web/src/lib/signatureImages.ts index 9f73fe2..52d4d8c 100644 --- a/web/src/lib/signatureImages.ts +++ b/web/src/lib/signatureImages.ts @@ -6,7 +6,7 @@ */ import { CAP, client, setErrorMessage } from "@/jmap/client"; import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types"; -import { directoryCreate, fileCreate, supportsNodeType, withNodeType } from "@/lib/filenode"; +import { directoryCreate, fileCreate, supportsNodeType, normalizeFileNodes } from "@/lib/filenode"; import { useSession } from "@/store/session"; import { toast } from "@/ui/toast"; @@ -22,14 +22,14 @@ async function ensureFolder(accountId: string): Promise { ["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"], ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], ]); - list = withNodeType((res.get("g")?.[0] as unknown as GetResponse).list); + list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse).list); } catch { // Older servers: no filter support — scan everything. const res = await client.chain([ ["FileNode/query", { accountId, limit: 1000 }, "q"], ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], ]); - list = withNodeType((res.get("g")?.[0] as unknown as GetResponse).list); + list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse).list); } const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId); if (existing) return existing.id; diff --git a/web/src/store/files.ts b/web/src/store/files.ts index 21ba126..df6821d 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client"; -import { directoryCreate, fileCreate, fileNodeProps, withNodeType } from "@/lib/filenode"; +import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes } from "@/lib/filenode"; import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types"; import { useSession } from "./session"; @@ -41,7 +41,7 @@ async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial< ]); const q = res.get("q")?.[0] as unknown as QueryResponse; const g = res.get("g")?.[0] as unknown as GetResponse; - all.push(...withNodeType(g.list)); + all.push(...normalizeFileNodes(g.list)); position += q.ids.length; if (!q.ids.length || (q.total != null && position >= q.total)) break; } @@ -90,7 +90,7 @@ export const useFiles = create((set, get) => ({ const g = res.get("g")?.[0] as unknown as GetResponse; set((s) => { const nodes = { ...s.nodes }; - for (const n of withNodeType(g.list)) nodes[n.id] = n; + for (const n of normalizeFileNodes(g.list)) nodes[n.id] = n; return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids }, loading: false, error: null }; }); } catch (err) { From 57ff18bb7f8fba55d0f95cceab70cb20a2f80a70 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Mon, 24 Aug 2026 10:05:08 -0700 Subject: [PATCH 4/5] List Files through get, because query cannot see a folder before 0.16 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a folder on the live 0.15.5 server did nothing visible, with no error and nothing after a reload. The folder was real all along: FileNode/query masks its results with document_ids(false) — resources that are *not* containers — so it returns files and never folders, and says nothing about the omission. FileNode/get carries no such mask, so on those servers the whole tree comes from a single get with ids:null instead. That also stops ensureFolder making a fresh "ihasmail" folder on every signature save, having never been able to find the one already there. --- web/src/lib/__tests__/filenode.test.ts | 20 ++++++++++++++- web/src/lib/filenode.ts | 13 ++++++++++ web/src/lib/signatureImages.ts | 35 +++++++++++++++----------- web/src/store/files.ts | 33 ++++++++++++++---------- 4 files changed, 73 insertions(+), 28 deletions(-) diff --git a/web/src/lib/__tests__/filenode.test.ts b/web/src/lib/__tests__/filenode.test.ts index c932c94..14dd61e 100644 --- a/web/src/lib/__tests__/filenode.test.ts +++ b/web/src/lib/__tests__/filenode.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { client } from "@/jmap/client"; -import { directoryCreate, fileCreate, fileNodeProps, supportsNodeType, normalizeFileNodes } from "../filenode"; +import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "../filenode"; import type { FileNode, JmapSession } from "@/jmap/types"; /** @@ -117,3 +117,21 @@ describe("rights on a pre-0.16 server", () => { expect(node!.nodeType).toBe("directory"); }); }); + +/** + * Before 0.16, FileNode/query masks its results with `document_ids(false)` — + * only resources that are *not* containers. It therefore returns files and + * never folders, with no error to explain the omission: a folder created there + * exists but never comes back in a listing. FileNode/get carries no such mask. + */ +describe("directory-blind query", () => { + it("is worked around on older servers", () => { + client.session = session(OLD_SERVER); + expect(queryOmitsDirectories()).toBe(true); + }); + + it("is not worked around where query can see folders", () => { + client.session = session(NEW_SERVER); + expect(queryOmitsDirectories()).toBe(false); + }); +}); diff --git a/web/src/lib/filenode.ts b/web/src/lib/filenode.ts index a4270a3..fdcd40e 100644 --- a/web/src/lib/filenode.ts +++ b/web/src/lib/filenode.ts @@ -24,6 +24,19 @@ export function supportsNodeType(): boolean { const BASE_PROPS = ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"]; +/** + * Whether `FileNode/query` is blind to directories. + * + * Before 0.16 the query masks its results with `document_ids(false)`, which + * keeps only resources that are *not* containers — so it returns files and + * never folders, with no error to say so. A folder created there is real, and + * simply never comes back in a listing. `FileNode/get` has no such mask, so + * asking it for every id is the only way to see the whole tree. + */ +export function queryOmitsDirectories(): boolean { + return !supportsNodeType(); +} + /** Properties to request, asking for `nodeType` only where it exists. */ export function fileNodeProps(): string[] { return supportsNodeType() ? [...BASE_PROPS, "nodeType"] : BASE_PROPS; diff --git a/web/src/lib/signatureImages.ts b/web/src/lib/signatureImages.ts index 52d4d8c..5eeece6 100644 --- a/web/src/lib/signatureImages.ts +++ b/web/src/lib/signatureImages.ts @@ -6,7 +6,7 @@ */ import { CAP, client, setErrorMessage } from "@/jmap/client"; import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types"; -import { directoryCreate, fileCreate, supportsNodeType, normalizeFileNodes } from "@/lib/filenode"; +import { directoryCreate, fileCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode"; import { useSession } from "@/store/session"; import { toast } from "@/ui/toast"; @@ -17,19 +17,26 @@ const folderProps = () => (supportsNodeType() ? ["id", "name", "nodeType", "pare async function ensureFolder(accountId: string): Promise { let list: FileNode[] = []; - try { - const res = await client.chain([ - ["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], - ]); - list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse).list); - } catch { - // Older servers: no filter support — scan everything. - const res = await client.chain([ - ["FileNode/query", { accountId, limit: 1000 }, "q"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], - ]); - list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse).list); + if (queryOmitsDirectories()) { + // Query cannot see a directory on these servers, so it would never find the + // folder and we would make a fresh one on every save. Ask get for the lot. + const res = await client.call>("FileNode/get", { accountId, ids: null, properties: folderProps() }); + list = normalizeFileNodes(res.list); + } else { + try { + const res = await client.chain([ + ["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], + ]); + list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse).list); + } catch { + // Filters unsupported: scan everything and pick it out here. + const res = await client.chain([ + ["FileNode/query", { accountId, limit: 1000 }, "q"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], + ]); + list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse).list); + } } const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId); if (existing) return existing.id; diff --git a/web/src/store/files.ts b/web/src/store/files.ts index df6821d..cb8f7f7 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client"; -import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes } from "@/lib/filenode"; +import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories } from "@/lib/filenode"; import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types"; import { useSession } from "./session"; @@ -33,17 +33,24 @@ const byName = (a: FileNode, b: FileNode) => (a.nodeType === b.nodeType ? a.name /** Fetch all nodes (paged, no filter) and rebuild the full children map. */ async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial) => void): Promise { const all: FileNode[] = []; - let position = 0; - for (let guard = 0; guard < 100; guard++) { - const res = await client.chain([ - ["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"], - ]); - const q = res.get("q")?.[0] as unknown as QueryResponse; - const g = res.get("g")?.[0] as unknown as GetResponse; - all.push(...normalizeFileNodes(g.list)); - position += q.ids.length; - if (!q.ids.length || (q.total != null && position >= q.total)) break; + if (queryOmitsDirectories()) { + // Query would hand back files only, so every folder — including one just + // created — would be missing with nothing to say why. Ask get for the lot. + const res = await client.call>("FileNode/get", { accountId, ids: null, properties: fileNodeProps() }); + all.push(...normalizeFileNodes(res.list)); + } else { + let position = 0; + for (let guard = 0; guard < 100; guard++) { + const res = await client.chain([ + ["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"], + ]); + const q = res.get("q")?.[0] as unknown as QueryResponse; + const g = res.get("g")?.[0] as unknown as GetResponse; + all.push(...normalizeFileNodes(g.list)); + position += q.ids.length; + if (!q.ids.length || (q.total != null && position >= q.total)) break; + } } const nodes: Record = {}; const children: Record = { root: [] }; @@ -77,7 +84,7 @@ export const useFiles = create((set, get) => ({ if (!accountId) return; set({ loading: true }); try { - if (!filtersSupported) { + if (!filtersSupported || queryOmitsDirectories()) { await loadAllNodes(accountId, set); return; } From 0a49c914ebeccdab2a5c6b1fce832693073eb845 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Mon, 24 Aug 2026 10:11:08 -0700 Subject: [PATCH 5/5] Record the live Files and signature verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both entries had been sitting as pending QA. Everything they were waiting on has now run against the live 0.15.5 server: oversized, non-ASCII and inline-image signatures (with a test message reaching Gmail intact), and folder creation, listing, upload, rename, move and delete in Files. The Files entry now describes what actually differs before 0.16 — the query that cannot see folders, the missing nodeType, the coarser rights — since all three were found the hard way and none of them surfaces as an error. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9ef8f49..fe38f0d 100644 --- a/README.md +++ b/README.md @@ -131,10 +131,10 @@ Press `?` anywhere. Highlights: `c` compose · `/` search · `j`/`k` navigate · ## Known issues / pending QA -Verified against the mock server and, for the core mail flows, against a live Stalwart 0.15.5. Still pending live verification: +Verified against the mock server, and against a live Stalwart 0.15.5 for the mail flows, self-service credentials, Files and signatures. -- **HTML signatures** — Stalwart caps identity signatures at 2 KB. ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker (other clients see a text fallback). The end-to-end flow (save → compose → send with inline logo) is implemented but not yet confirmed on the live server. -- **Files** — the live server runs an older Stalwart build than `main`; `FileNode/query` there rejects `isTopLevel`/`parentId` filters, so ihasmail falls back to listing all nodes and building the tree client-side. Upload/rename/move/delete still need a live pass. +- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline. +- **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap`, lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24). - **Self-service credentials** — the **0.15.x REST path is confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path has only been exercised against the mock**, which enforces the same rules a real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it) — it still wants a pass against a real 0.16 server. Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens. - Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet). - Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.