Every setting lived in localStorage, so none of them travelled between devices. The sharpest edge is the default identity: with none set the address that sorts first wins, so mail goes out from an address the recipient may not recognise -- and someone who sets it at work finds it unset at home, with nothing to say so. Reported in #54. They now live in a settings.json in the account's own JMAP Files, beside the signature images already kept there. ihasmail itself stays stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. localStorage stays as a cache rather than the source of truth, so the first frame is painted from it and the file corrects it a moment later. A private window has no cache and shows defaults for that one frame, which is the trade for not gating the whole app on a network round trip. Not everything should follow the account. A list-pane width picked on a 27" monitor is wrong on a laptop, and the notification toggles track a permission the browser grants per-device, so claiming it elsewhere would be a lie. Those stay local, written as a list of exceptions so that a setting added later syncs by default -- which is what adding one almost always means. Writes are coalesced: update() fires on every frame of a splitter drag, so a change waits 3s and the newest value wins. A tab going away flushes first, as does signing out, so a setting changed seconds before either is not lost. The ihasmail folder is now hidden from the Files view, contents and all. Hiding the folder alone would have been worse than showing it: the tree attaches a node whose parent is missing to the root, so the signature images would have spilled into the top level as if the user had put them there. Those images have been visible since signatures shipped. Requires 0.16 -- FileNode/query cannot see directories before that. On 0.15 settings stay local exactly as they were. Verified against the mock end to end: folder create, blob upload, node create, read back, update, re-read. Not yet exercised against the live 0.16.19.
86 lines
4.4 KiB
TypeScript
86 lines
4.4 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().accountFor(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().accountFor(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> {
|
|
const accountId = useSession.getState().accountFor(CAP.filenode) ?? useSession.getState().accountId;
|
|
if (!accountId) throw new Error("no account");
|
|
return client.fetchBlobText(accountId, blobId, type);
|
|
}
|
|
|
|
export type { QueryResponse };
|