Never overwrite filters we could not read

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.
This commit is contained in:
2026-08-26 15:03:53 -07:00
parent 450a38f7cd
commit 9c37af7b07
3 changed files with 129 additions and 10 deletions
+12 -2
View File
@@ -33,11 +33,21 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
}
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
const { rules } = sieve.rules();
const { rules, loaded } = sieve.rules();
if (rules === null) {
return (
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>Settings Filters & rules</b> to edit the script or switch to managed rules.</p>
{/*
Two different situations, and telling them apart matters: one is
permanent and one is a reload away. Saying "written by hand" when the
script merely failed to fetch sends someone looking for a problem
they do not have.
*/}
{loaded ? (
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>Settings → Filters & rules</b> to edit the script or switch to managed rules.</p>
) : (
<p>Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.</p>
)}
</Dialog>
);
}