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. diff --git a/web/src/lib/__tests__/filenode.test.ts b/web/src/lib/__tests__/filenode.test.ts new file mode 100644 index 0000000..14dd61e --- /dev/null +++ b/web/src/lib/__tests__/filenode.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { client } from "@/jmap/client"; +import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } 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(normalizeFileNodes(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 = 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 }, + ] 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 = normalizeFileNodes([{ 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); +}); + +/** + * 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"); + }); +}); + +/** + * 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/__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/filenode.ts b/web/src/lib/filenode.ts new file mode 100644 index 0000000..fdcd40e --- /dev/null +++ b/web/src/lib/filenode.ts @@ -0,0 +1,87 @@ +/** + * 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"]; + +/** + * 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; +} + +/** 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 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 normalizeFileNodes>(nodes: T[]): T[] { + if (supportsNodeType()) return nodes; + 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 { + return n.blobId != null || n.size != null || n.type != null; +} 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/lib/signatureImages.ts b/web/src/lib/signatureImages.ts index 16eaef5..5eeece6 100644 --- a/web/src/lib/signatureImages.ts +++ b/web/src/lib/signatureImages.ts @@ -6,30 +6,41 @@ */ import { CAP, client, setErrorMessage } from "@/jmap/client"; import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types"; +import { directoryCreate, fileCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } 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"], - ]); - list = (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"], - ]); - list = (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; - 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 +62,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 +82,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..cb8f7f7 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, normalizeFileNodes, queryOmitsDirectories } 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; @@ -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: PROPS }, "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); - 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,20 +84,20 @@ export const useFiles = create((set, get) => ({ if (!accountId) return; set({ loading: true }); try { - if (!filtersSupported) { + if (!filtersSupported || queryOmitsDirectories()) { await loadAllNodes(accountId, set); return; } 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 normalizeFileNodes(g.list)) nodes[n.id] = n; return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids }, loading: false, error: null }; }); } catch (err) { @@ -112,7 +119,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 +138,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)); 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.
} );