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:
@@ -131,10 +131,10 @@ Press `?` anywhere. Highlights: `c` compose · `/` search · `j`/`k` navigate ·
|
|||||||
|
|
||||||
## Known issues / pending QA
|
## 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.
|
- **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** — 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.
|
- **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.
|
- **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).
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("signature compaction", () => {
|
||||||
it("strips office cruft and non-essential styles but keeps colours and links", () => {
|
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();
|
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 & 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("…");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,8 +6,45 @@
|
|||||||
*/
|
*/
|
||||||
import { escapeHtml, htmlToText } from "./text";
|
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;
|
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_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 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"]);
|
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 } {
|
export function buildMarkerSignature(blobId: string, fullHtml: string): { htmlSignature: string; textSignature: string } {
|
||||||
const text = htmlToText(fullHtml);
|
const text = htmlToText(fullHtml);
|
||||||
const marker = `<!--ihasmail:sig=${blobId}:text/html-->`;
|
const marker = `<!--ihasmail:sig=${blobId}:text/html-->`;
|
||||||
const budget = SIGNATURE_LIMIT - marker.length - 11; // <div></div>
|
const budget = SIGNATURE_LIMIT - byteLength(marker) - "<div></div>".length;
|
||||||
let fallback = escapeHtml(text).replace(/\n/g, "<br>");
|
const fallback = renderWithinBytes(text, budget, (t) => escapeHtml(t).replace(/\n/g, "<br>"));
|
||||||
if (fallback.length > budget) fallback = `${fallback.slice(0, Math.max(0, budget - 1))}…`;
|
return {
|
||||||
return { htmlSignature: `${marker}<div>${fallback}</div>`, textSignature: text.length > SIGNATURE_LIMIT ? `${text.slice(0, SIGNATURE_LIMIT - 1)}…` : text };
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,30 +6,41 @@
|
|||||||
*/
|
*/
|
||||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||||
import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types";
|
import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types";
|
||||||
|
import { directoryCreate, fileCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
|
|
||||||
const FOLDER = "ihasmail";
|
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> {
|
async function ensureFolder(accountId: string): Promise<string> {
|
||||||
let list: FileNode[] = [];
|
let list: FileNode[] = [];
|
||||||
try {
|
if (queryOmitsDirectories()) {
|
||||||
const res = await client.chain([
|
// Query cannot see a directory on these servers, so it would never find the
|
||||||
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"],
|
// folder and we would make a fresh one on every save. Ask get for the lot.
|
||||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "nodeType", "parentId"] }, "g"],
|
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties: folderProps() });
|
||||||
]);
|
list = normalizeFileNodes(res.list);
|
||||||
list = (res.get("g")?.[0] as unknown as GetResponse<FileNode>).list;
|
} else {
|
||||||
} catch {
|
try {
|
||||||
// Older servers: no filter support — scan everything.
|
const res = await client.chain([
|
||||||
const res = await client.chain([
|
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"],
|
||||||
["FileNode/query", { accountId, limit: 1000 }, "q"],
|
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"],
|
||||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "nodeType", "parentId"] }, "g"],
|
]);
|
||||||
]);
|
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
|
||||||
list = (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);
|
const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId);
|
||||||
if (existing) return existing.id;
|
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;
|
const err = set.notCreated?.d;
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new Error(setErrorMessage(err));
|
||||||
return set.created!.d!.id;
|
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 up = await client.upload(accountId, file, { type });
|
||||||
const folderId = await ensureFolder(accountId);
|
const folderId = await ensureFolder(accountId);
|
||||||
const name = `${Date.now()}-${file.name.replace(/[^\w.-]+/g, "_")}`;
|
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;
|
const err = res.notCreated?.f;
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new Error(setErrorMessage(err));
|
||||||
const created = res.created?.f as Partial<FileNode> | undefined;
|
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 up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" });
|
||||||
const folderId = await ensureFolder(accountId);
|
const folderId = await ensureFolder(accountId);
|
||||||
const name = `signature-${Date.now()}.html`;
|
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;
|
const err = res.notCreated?.f;
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new Error(setErrorMessage(err));
|
||||||
const created = res.created?.f as Partial<FileNode> | undefined;
|
const created = res.created?.f as Partial<FileNode> | undefined;
|
||||||
|
|||||||
+24
-17
@@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client";
|
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 type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
|
||||||
import { useSession } from "./session";
|
import { useSession } from "./session";
|
||||||
|
|
||||||
@@ -23,7 +24,6 @@ interface FilesState {
|
|||||||
applyChanges(types: Set<string>): void;
|
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). */
|
/** Whether the server supports parentId/isTopLevel query filters (detected at runtime). */
|
||||||
let filtersSupported = true;
|
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. */
|
/** 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> {
|
async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<FilesState>) => void): Promise<void> {
|
||||||
const all: FileNode[] = [];
|
const all: FileNode[] = [];
|
||||||
let position = 0;
|
if (queryOmitsDirectories()) {
|
||||||
for (let guard = 0; guard < 100; guard++) {
|
// Query would hand back files only, so every folder — including one just
|
||||||
const res = await client.chain([
|
// created — would be missing with nothing to say why. Ask get for the lot.
|
||||||
["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"],
|
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties: fileNodeProps() });
|
||||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: PROPS }, "g"],
|
all.push(...normalizeFileNodes(res.list));
|
||||||
]);
|
} else {
|
||||||
const q = res.get("q")?.[0] as unknown as QueryResponse;
|
let position = 0;
|
||||||
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
for (let guard = 0; guard < 100; guard++) {
|
||||||
all.push(...g.list);
|
const res = await client.chain([
|
||||||
position += q.ids.length;
|
["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"],
|
||||||
if (!q.ids.length || (q.total != null && position >= q.total)) break;
|
["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 nodes: Record<Id, FileNode> = {};
|
||||||
const children: Record<string, Id[]> = { root: [] };
|
const children: Record<string, Id[]> = { root: [] };
|
||||||
@@ -77,20 +84,20 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
try {
|
try {
|
||||||
if (!filtersSupported) {
|
if (!filtersSupported || queryOmitsDirectories()) {
|
||||||
await loadAllNodes(accountId, set);
|
await loadAllNodes(accountId, set);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filter = parentId ? { parentId } : { isTopLevel: true };
|
const filter = parentId ? { parentId } : { isTopLevel: true };
|
||||||
const res = await client.chain([
|
const res = await client.chain([
|
||||||
["FileNode/query", { accountId, filter, sort: [{ property: "nodeType", isAscending: false }, { property: "name", isAscending: true }], limit: 1000 }, "q"],
|
["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 q = res.get("q")?.[0] as unknown as QueryResponse;
|
||||||
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const nodes = { ...s.nodes };
|
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 };
|
return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids }, loading: false, error: null };
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -112,7 +119,7 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
|
|
||||||
async mkdir(parentId, name) {
|
async mkdir(parentId, name) {
|
||||||
const accountId = get().accountId!;
|
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;
|
const err = res.notCreated?.d;
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new Error(setErrorMessage(err));
|
||||||
await get().loadChildren(parentId);
|
await get().loadChildren(parentId);
|
||||||
@@ -131,7 +138,7 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
const res = await client.call<SetResponse<FileNode>>("FileNode/set", {
|
const res = await client.call<SetResponse<FileNode>>("FileNode/set", {
|
||||||
accountId,
|
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;
|
const err = res.notCreated?.f;
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new Error(setErrorMessage(err));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { parseAddressList, formatAddressList } from "@/lib/address";
|
|||||||
import { htmlToText } from "@/lib/text";
|
import { htmlToText } from "@/lib/text";
|
||||||
import { sanitizeEditorHtml } from "@/lib/html";
|
import { sanitizeEditorHtml } from "@/lib/html";
|
||||||
import { externalizeDataImages, storeSignatureHtml, uploadSignatureImage } from "@/lib/signatureImages";
|
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() {
|
export function IdentitiesSettings() {
|
||||||
const identities = useMail((s) => s.identities);
|
const identities = useMail((s) => s.identities);
|
||||||
@@ -57,8 +57,9 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const ref = useRef<RichEditorHandle>(null);
|
const ref = useRef<RichEditorHandle>(null);
|
||||||
const compact = compactHtml(sanitizeEditorHtml(html));
|
const compact = compactHtml(sanitizeEditorHtml(html));
|
||||||
const sigLen = compact.length;
|
// The server's limit is on encoded bytes, so that is what to count and show.
|
||||||
const tooLong = sigLen > SIGNATURE_LIMIT || htmlToText(compact).length > SIGNATURE_LIMIT;
|
const sigLen = byteLength(compact);
|
||||||
|
const tooLong = signatureTooLong(compact, htmlToText(compact));
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -67,7 +68,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
|
|||||||
const clean = compactHtml(externalized);
|
const clean = compactHtml(externalized);
|
||||||
let htmlSignature = clean;
|
let htmlSignature = clean;
|
||||||
let textSignature = htmlToText(clean);
|
let textSignature = htmlToText(clean);
|
||||||
if (clean.length > SIGNATURE_LIMIT || textSignature.length > SIGNATURE_LIMIT) {
|
if (signatureTooLong(clean, textSignature)) {
|
||||||
const blobId = await storeSignatureHtml(clean);
|
const blobId = await storeSignatureHtml(clean);
|
||||||
({ htmlSignature, textSignature } = buildMarkerSignature(blobId, 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">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>
|
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user