Refuse to save a script we only partly read

The transport fix stops the truncation that caused #76, but the save path
had no answer for a baseline that arrives incomplete. It is neither
unknown nor empty, so every existing guard passes it through: it parses
into a shorter rule list that looks exactly like a script with fewer
rules, and saving writes that back over the real one.

Check the script against the shape the generator emits instead. Every
rule comment parses, every enabled rule has an if and a closed body under
it, every block ends with a blank line. Structural rather than a
re-serialize-and-compare, so a script written by an older version whose
serializer differed is still editable.

The rule editor reports a short script as unreadable rather than showing
the rules that happened to parse, since a list that looks complete over a
script that is not is the most dangerous thing it could offer.

A cut at the end of a complete rule block is still a valid shorter script
and cannot be told apart from one; that residual is the proxy's to cover.
This commit is contained in:
2026-08-30 13:56:12 -07:00
parent 0277b5b6a8
commit 8d475e2b07
6 changed files with 227 additions and 15 deletions
+8 -6
View File
@@ -33,17 +33,19 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
}
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
const { rules, loaded } = sieve.rules();
const { rules, loaded, damage } = sieve.rules();
if (rules === null) {
return (
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
{/*
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.
Three different situations, and telling them apart matters: one is
permanent and two are a reload away. Saying "written by hand" when the
script merely failed to fetch -- or arrived in part -- sends someone
looking for a problem they do not have.
*/}
{loaded ? (
{damage ? (
<p>Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.</p>
) : 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>
+14 -1
View File
@@ -45,7 +45,7 @@ const RULE_MIME = "application/x-ihasmail-sieve-rule";
function RulesEditor() {
const sieve = useSieve();
const { script, rules, content } = sieve.rules();
const { script, rules, content, damage } = sieve.rules();
const [local, setLocal] = useState<SieveRule[] | null>(null);
const [editing, setEditing] = useState<SieveRule | null>(null);
const [saving, setSaving] = useState(false);
@@ -80,6 +80,19 @@ function RulesEditor() {
}
};
// Before the hand-written branch: a script that arrived in part is not a
// script someone chose to write themselves, and the way out of it is a reload
// rather than the "start with rules" button below, which would write over it.
if (damage) {
return (
<div className="warn-box">
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>Only part of your filter script arrived.</b></div>
<p style={{ margin: "0 0 8px" }}>It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.</p>
<button className="btn" onClick={() => window.location.reload()}>Reload</button>
</div>
);
}
if (rules === null) {
return (
<div className="warn-box">