Adding a filter from a message reported success while the script on the server never held more than two rules (#76). Rules were being destroyed, and the confirmation was a lie. Three links, each defensible alone: 1. load() recorded a *failed* blob fetch as `contents[id] = ""`. 2. sieveToRules("") returns [] -- "this script has no rules", which is indistinguishable from "we could not read this script". 3. Saving rewrites the whole script from that baseline, so every rule already in it was deleted. The write itself succeeded, which is why the UI said so. No fetch failure was even required: rules() did `contents[id] ?? ""`, so a script whose content had not loaded yet read as empty too. And saveScript cached the content it had just written and then called load(), which replaced the whole map -- discarding it if the refetch came back short. The fix is to keep "unknown" and "empty" apart at every step: - a failed fetch leaves the key absent rather than storing "" - load() merges rather than replacing, so a reload cannot throw away what saveScript just wrote - rules() returns null for content it does not have, which every caller already treats as "do not touch this script" - saveRules refuses outright when the baseline is unknown. Refusing is recoverable; overwriting is not. rules() now also reports whether the script was read, because "written by hand" and "could not be read" want different advice -- one is permanent, the other is a reload away, and telling someone the wrong one sends them hunting for a problem they do not have. Ruled out on the way: the rule codec round-trips fine, eight rules in and eight out. sieveToRules reads the `# rule:` JSON comments rather than parsing Sieve, so the generated script's shape was never the issue.
164 lines
6.7 KiB
TypeScript
164 lines
6.7 KiB
TypeScript
import { create } from "zustand";
|
|
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
|
import type { GetResponse, Id, SetResponse, SieveScript } from "@/jmap/types";
|
|
import { rulesToSieve, sieveToRules, type SieveRule } from "@/lib/sieve";
|
|
import { useSession } from "./session";
|
|
|
|
export const IHASMAIL_SCRIPT = "ihasmail";
|
|
|
|
interface SieveState {
|
|
accountId: Id | null;
|
|
available: boolean;
|
|
scripts: SieveScript[];
|
|
/** Content of each script by id. */
|
|
contents: Record<Id, string>;
|
|
loading: boolean;
|
|
error: string | null;
|
|
init(): Promise<void>;
|
|
load(): Promise<void>;
|
|
getContent(id: Id): Promise<string>;
|
|
/** Rules derived from the "ihasmail" script (null = the active script is hand-written). */
|
|
/** `loaded` distinguishes "this script is hand-written" from "we could not read it". */
|
|
rules(): { script: SieveScript | null; rules: SieveRule[] | null; content: string; loaded: boolean };
|
|
saveRules(rules: SieveRule[]): Promise<void>;
|
|
saveScript(id: Id | null, name: string, content: string, activate: boolean): Promise<Id>;
|
|
activate(id: Id | null): Promise<void>;
|
|
destroy(id: Id): Promise<void>;
|
|
validate(content: string): Promise<string | null>;
|
|
applyChanges(types: Set<string>): void;
|
|
}
|
|
|
|
export const useSieve = create<SieveState>((set, get) => ({
|
|
accountId: null,
|
|
available: false,
|
|
scripts: [],
|
|
contents: {},
|
|
loading: false,
|
|
error: null,
|
|
|
|
async init() {
|
|
const accountId = useSession.getState().accountFor(CAP.sieve);
|
|
const available = Boolean(accountId && client.hasCapability(CAP.sieve));
|
|
set({ accountId, available });
|
|
if (available) await get().load();
|
|
},
|
|
|
|
async load() {
|
|
const accountId = get().accountId;
|
|
if (!accountId) return;
|
|
set({ loading: true });
|
|
try {
|
|
const res = await client.call<GetResponse<SieveScript>>("SieveScript/get", { accountId, ids: null });
|
|
set({ scripts: res.list, loading: false, error: null });
|
|
// Preload contents.
|
|
//
|
|
// A fetch that fails must not be recorded as "". An empty script parses
|
|
// to an empty rule list, which reads as "this script has no rules" and is
|
|
// indistinguishable from "we could not read this script" -- and the next
|
|
// save then writes the whole script out from that empty baseline,
|
|
// destroying every rule in it. That is issue #76.
|
|
//
|
|
// Leaving the key absent instead means `rules()` reports the content as
|
|
// unknown, and `saveRules` refuses rather than guessing.
|
|
const fetched: Record<Id, string> = {};
|
|
await Promise.all(
|
|
res.list.map(async (s) => {
|
|
try {
|
|
fetched[s.id] = await client.fetchBlobText(accountId, s.blobId, "application/sieve");
|
|
} catch {
|
|
/* leave absent: unknown, not empty */
|
|
}
|
|
}),
|
|
);
|
|
// Merged, not replaced: saveScript caches the content it just wrote, and
|
|
// a reload whose fetch failed must not throw that away.
|
|
set((st) => ({ contents: { ...st.contents, ...fetched } }));
|
|
} catch (err) {
|
|
set({ loading: false, error: (err as Error).message });
|
|
}
|
|
},
|
|
|
|
async getContent(id) {
|
|
const cached = get().contents[id];
|
|
if (cached != null) return cached;
|
|
const s = get().scripts.find((x) => x.id === id);
|
|
if (!s) return "";
|
|
const text = await client.fetchBlobText(get().accountId!, s.blobId, "application/sieve");
|
|
set((st) => ({ contents: { ...st.contents, [id]: text } }));
|
|
return text;
|
|
},
|
|
|
|
rules() {
|
|
const { scripts, contents } = get();
|
|
const script = scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? scripts.find((s) => s.isActive) ?? null;
|
|
if (!script) return { script: null, rules: [], content: "", loaded: true };
|
|
const content = contents[script.id];
|
|
// Not loaded, or the fetch failed. `null` means "cannot say", which every
|
|
// caller already treats as "do not edit this script" -- as opposed to `[]`,
|
|
// which means "this script genuinely has no rules" and invites a save that
|
|
// would overwrite whatever is really in it.
|
|
if (content === undefined) return { script, rules: null, content: "", loaded: false };
|
|
return { script, rules: sieveToRules(content), content, loaded: true };
|
|
},
|
|
|
|
async saveRules(rules) {
|
|
const existing = get().scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? null;
|
|
// The last line of defence. Writing rules replaces the whole script, so
|
|
// doing it from a baseline we never managed to read deletes whatever was
|
|
// there. Refusing is recoverable; overwriting is not.
|
|
if (existing && get().contents[existing.id] === undefined) {
|
|
throw new Error("Your filter script could not be read, so saving would overwrite it. Reload and try again.");
|
|
}
|
|
await get().saveScript(existing?.id ?? null, IHASMAIL_SCRIPT, rulesToSieve(rules), true);
|
|
},
|
|
|
|
async saveScript(id, name, content, activate) {
|
|
const accountId = get().accountId!;
|
|
const up = await client.upload(accountId, new Blob([content], { type: "application/sieve" }), { type: "application/sieve" });
|
|
const args: Record<string, unknown> = { accountId };
|
|
if (id) args.update = { [id]: { name, blobId: up.blobId } };
|
|
else args.create = { s: { name, blobId: up.blobId } };
|
|
if (activate) args.onSuccessActivateScript = id ?? "#s";
|
|
const res = await client.call<SetResponse<SieveScript>>("SieveScript/set", args);
|
|
const err = id ? res.notUpdated?.[id] : res.notCreated?.s;
|
|
if (err) throw new Error(setErrorMessage(err));
|
|
const newId = id ?? res.created!.s!.id;
|
|
set((s) => ({ contents: { ...s.contents, [newId]: content } }));
|
|
await get().load();
|
|
return newId;
|
|
},
|
|
|
|
async activate(id) {
|
|
const accountId = get().accountId!;
|
|
const args: Record<string, unknown> = { accountId };
|
|
if (id) args.onSuccessActivateScript = id;
|
|
else args.onSuccessDeactivateScript = true;
|
|
// A no-op set with activation hooks.
|
|
await client.call<SetResponse>("SieveScript/set", args);
|
|
await get().load();
|
|
},
|
|
|
|
async destroy(id) {
|
|
const accountId = get().accountId!;
|
|
const res = await client.call<SetResponse>("SieveScript/set", { accountId, destroy: [id] });
|
|
const err = res.notDestroyed?.[id];
|
|
if (err) throw new Error(setErrorMessage(err));
|
|
await get().load();
|
|
},
|
|
|
|
async validate(content) {
|
|
const accountId = get().accountId!;
|
|
try {
|
|
const up = await client.upload(accountId, new Blob([content], { type: "application/sieve" }), { type: "application/sieve" });
|
|
const res = await client.call<{ error: { type: string; description?: string } | null }>("SieveScript/validate", { accountId, blobId: up.blobId });
|
|
return res.error ? (res.error.description ?? res.error.type) : null;
|
|
} catch (err) {
|
|
return (err as Error).message;
|
|
}
|
|
},
|
|
|
|
applyChanges(types) {
|
|
if (types.has("SieveScript")) void get().load();
|
|
},
|
|
}));
|