Files
ihasmail-inbuxa/web/src/lib/signatureImages.ts
T
jcoffey-dev e014521fb6 Keep your own settings out of someone else's account
Switching to an account somebody shared pointed the whole app at it. The
rule was "use the selected account if it can do this", and a shared file
account can, by definition, do files.

ihasmail keeps its settings in the account's Files -- that is what makes
them follow you between devices -- so changing any setting while looking
at somebody's shared folder wrote `settings.json` into *their* storage,
creating the `ihasmail` folder there to do it. Signature images went the
same way, and push registration would have gone to whichever account was
on screen. Reading someone else's data by mistake is bad; writing yours
into theirs is worse, and one line was doing both.

There are two questions, and they had one answer:

  - what am I looking at -- follows the switcher, because switching to a
    shared account is how you read what was shared
  - what is mine -- never does

So `accountFor` keeps the first meaning and `ownAccountFor` is the
second, used by settings sync, signature images and push. A `??
accountId` fallback in `loadStoredSignature` went with it: the reader's
own signature, reached through whoever happened to be selected.

A third rule was hiding in the first. A capability the selected account
does not advertise fell back to the selected account anyway, so a session
naming no primary for something aimed it at whoever was selected --
somebody else. It now answers with nothing, which is honest: the feature
is unavailable, rather than pointed at a stranger.

What this does not settle is whether the mail, calendar and contacts the
switcher appeared to offer were ever really reachable, or only asked for
and refused. That depends on what Stalwart advertises on a shared
account, which needs a look at a sharee's session; if it advertises
capabilities nobody shared, more is needed here than routing.
2026-08-27 09:59:00 -07:00

88 lines
4.5 KiB
TypeScript

/**
* Signature images: Stalwart caps identity signatures at 2 KB, so pictures can't
* be embedded as data: URLs. Instead we store them in JMAP Files (persistent
* blobs) under an "ihasmail" folder and reference them by blob URL; the composer
* turns such references into inline cid: parts when sending.
*/
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { FileNode, QueryResponse, SetResponse } from "@/jmap/types";
import { fileCreate } from "@/lib/filenode";
import { ensureFolder, nodeBlobId } from "@/lib/appFolder";
import { useSession } from "@/store/session";
import { toast } from "@/ui/toast";
/** Upload an image for use in a signature; returns a same-origin blob URL. */
export async function uploadSignatureImage(file: File): Promise<string> {
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
if (!accountId || !client.hasCapability(CAP.filenode)) {
toast.error("Images in signatures need the Files feature, which this account doesn't have.");
throw new Error("filenode unavailable");
}
if (file.size > 512 * 1024) {
toast.error("Please use an image under 512 KB for signatures.");
throw new Error("too large");
}
try {
const type = file.type || "image/png";
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: 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;
// Prefer the node's (persistent) blobId if the server returned one.
const blobId = created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId;
return client.downloadUrl(accountId, blobId, name, type, true);
} catch (err) {
toast.error(`Could not store image: ${(err as Error).message}`);
throw err;
}
}
/** Store the full HTML of an over-sized signature in Files; returns the blob id. */
export async function storeSignatureHtml(html: string): Promise<string> {
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
if (!accountId || !client.hasCapability(CAP.filenode)) throw new Error("This signature is too long for the server and the Files feature (needed to store long signatures) is not available.");
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: 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;
return created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId;
}
/** Replace data: URL images (pasted pictures) in signature HTML with stored blob URLs. */
export async function externalizeDataImages(html: string): Promise<string> {
if (!html.includes("data:image/")) return html;
const doc = new DOMParser().parseFromString(`<div id="r">${html}</div>`, "text/html");
const root = doc.getElementById("r")!;
const imgs = Array.from(root.querySelectorAll("img")).filter((i) => i.getAttribute("src")?.startsWith("data:image/"));
for (const img of imgs) {
const m = /^data:(image\/[\w.+-]+);base64,(.*)$/s.exec(img.getAttribute("src")!);
if (!m) {
img.remove();
continue;
}
const bin = atob(m[2]!);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const file = new File([bytes], `image.${m[1]!.split("/")[1]?.replace("jpeg", "jpg") ?? "png"}`, { type: m[1]! });
img.setAttribute("src", await uploadSignatureImage(file));
}
return root.innerHTML;
}
/** Load the full HTML of a marker signature. */
export async function loadStoredSignature(blobId: string, type = "text/html"): Promise<string> {
// No `?? accountId` fallback: a signature is the reader's own, and the
// selected account may be somebody else's shared one.
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
if (!accountId) throw new Error("no account");
return client.fetchBlobText(accountId, blobId, type);
}
export type { QueryResponse };