Merge pull request #15 from LINUXexpert-org/signature-byte-limit

Make signatures and Files work on Stalwart before 0.16
This commit is contained in:
LINUXexpert.org
2026-08-24 10:13:30 -07:00
committed by GitHub
8 changed files with 389 additions and 46 deletions
+3 -3
View File
@@ -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.
+137
View File
@@ -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<FileNode>[];
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<FileNode>[]);
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<FileNode>[]);
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<FileNode>[]);
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<FileNode>[]);
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<FileNode>[]);
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<FileNode>[]);
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);
});
});
+57 -1
View File
@@ -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("<div>plain</div>")).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(`<div>${filler.repeat(3000)}</div>`);
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(`<div>${"🎉".repeat(3000)}</div>`);
// 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(`<div>${"a &amp; b <c> ".repeat(400)}</div>`);
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("<div>Grüße, John</div>");
expect(m.textSignature).toBe("Grüße, John");
expect(m.htmlSignature).not.toContain("…");
});
});
+87
View File
@@ -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<string, unknown> {
// 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<string, unknown> {
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<T extends Partial<FileNode>>(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<FileNode>): boolean {
return n.blobId != null || n.size != null || n.type != null;
}
+48 -4
View File
@@ -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 `<br>`; 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 = `<!--ihasmail:sig=${blobId}:text/html-->`;
const budget = SIGNATURE_LIMIT - marker.length - 11; // <div></div>
let fallback = escapeHtml(text).replace(/\n/g, "<br>");
if (fallback.length > budget) fallback = `${fallback.slice(0, Math.max(0, budget - 1))}`;
return { htmlSignature: `${marker}<div>${fallback}</div>`, textSignature: text.length > SIGNATURE_LIMIT ? `${text.slice(0, SIGNATURE_LIMIT - 1)}` : text };
const budget = SIGNATURE_LIMIT - byteLength(marker) - "<div></div>".length;
const fallback = renderWithinBytes(text, budget, (t) => escapeHtml(t).replace(/\n/g, "<br>"));
return {
htmlSignature: `${marker}<div>${fallback}</div>`,
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;
}
+27 -16
View File
@@ -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<string> {
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<FileNode>).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<FileNode>).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<GetResponse<FileNode>>("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<FileNode>).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<FileNode>).list);
}
}
const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId);
if (existing) return existing.id;
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId: null, name: FOLDER, nodeType: "directory" } } });
const set = await client.call<SetResponse<FileNode>>("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<string> {
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<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type } } });
const res = await client.call<SetResponse<FileNode>>("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<FileNode> | undefined;
@@ -71,7 +82,7 @@ export async function storeSignatureHtml(html: string): Promise<string> {
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<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type: "text/html" } } });
const res = await client.call<SetResponse<FileNode>>("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<FileNode> | undefined;
+24 -17
View File
@@ -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<string>): 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<FilesState>) => void): Promise<void> {
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<FileNode>;
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<GetResponse<FileNode>>("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<FileNode>;
all.push(...normalizeFileNodes(g.list));
position += q.ids.length;
if (!q.ids.length || (q.total != null && position >= q.total)) break;
}
}
const nodes: Record<Id, FileNode> = {};
const children: Record<string, Id[]> = { root: [] };
@@ -77,20 +84,20 @@ export const useFiles = create<FilesState>((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<FileNode>;
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<FilesState>((set, get) => ({
async mkdir(parentId, name) {
const accountId = get().accountId!;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId, name, nodeType: "directory" } } });
const res = await client.call<SetResponse<FileNode>>("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<FilesState>((set, get) => ({
});
const res = await client.call<SetResponse<FileNode>>("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));
@@ -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<Identity>; on
const [busy, setBusy] = useState(false);
const ref = useRef<RichEditorHandle>(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<Identity>; 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<Identity>; on
<span className="hint">Images are stored in your Files (folder ihasmail) and embedded when you send.</span>
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
</div>
{tooLong && <div className="warn-box mt-8">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.</div>}
{tooLong && <div className="warn-box mt-8">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.</div>}
</div>
</Dialog>
);