The two errors do not cost the same
Shipped
gmailtriage sweeps a mailbox under rules you wrote, and the first release is mostly made of refusals: plan enumerates exactly which items each rule takes, apply refuses anything the plan did not name, rule validation rejects the patterns that select everything, and there is no default rule pack at all, because a shipped rule pack is somebody else’s opinion applied to your data.
Then I ran it against a real mailbox, and it proposed sweeping an active job application pipeline. Five items from a careers address, three of which carried multifactor codes. By every number available it looked exactly like a marketing cluster: high volume, one sender, repetitive subjects. Nothing in the counts said this one mattered. Only the domain and the subject lines did.
This guide builds the shape that came out of that: enumerate first, guard asymmetrically, and let the applier refuse.
Start from what cannot be undone
The Gmail API states the stakes in its own reference. users.messages.delete says: “Immediately and permanently deletes the specified message. This operation cannot be undone. Prefer messages.trash instead.” messages.trash merely “Moves the specified message to the trash.”
Choosing the recoverable operation is the single highest-leverage decision in a tool like this, and it is free. Every other guard below is defence in depth behind it. Find the reversible version of your destructive call, use only that, and write the undo path early while you still have the receipt format in your head.
The second structural decision is to separate deciding from doing. Terraform is the reference implementation of this idea: pass apply a saved plan file and “Terraform performs the operations in the saved plan without prompting you for confirmation”, because “Terraform interprets the act of passing the plan file as the approval”, and you cannot then add options, since the plan already holds the final results of those decisions. Approval attaches to an enumerated set of actions, not to an intention.
Why the guard uses substrings
// guards.mjs
// Sources that must never be swept in bulk, however much noise they also send.
//
// These are SUBSTRINGS, not \b-anchored words, and that is deliberate. Hosts
// concatenate words, so \bworkday\b does not match "myworkday.com": the
// character before "workday" is "y", which is itself a word character, so
// there is no boundary there to assert.
const WITHHELD_SOURCES = ['careers', 'recruit', 'jobs', 'workday', 'workable', 'greenhouse', 'lever'];
// Any cluster that has ever carried one of these is withheld too, whatever its
// sender looks like. A source that sometimes delivers a credential cannot be
// bulk-swept on the strength of the marketing it sends the rest of the time.
const WITHHELD_CONTENT = ['verification code', 'login code', 'one-time', 'receipt', 'invoice'];
const has = (haystack, needles) => needles.find((n) => haystack.toLowerCase().includes(n)) ?? null;
// Returns the reason this cluster is withheld, or null if it is sweepable.
export function withheldReason(cluster) {
const bySource = has(cluster.source, WITHHELD_SOURCES);
if (bySource) return `source contains "${bySource}"`;
for (const item of cluster.items) {
const byContent = has(item.subject, WITHHELD_CONTENT);
if (byContent) return `an item carries "${byContent}"`;
}
return null;
}
The first version of this used \b anchors, which is the reflex when you want “the word workday and not some longer word containing it”. MDN defines the assertion precisely: a word boundary is “where the next character is a word character and the previous character is not a word character, or vice versa”, and a word character is “Letters (A–Z, a–z), numbers (0–9), and underscore (_)”.
Hostnames are built by gluing words together, so the boundary is simply not there:
$ node -e "const g=/\bworkday\b/i; for (const d of ['workday.com','myworkday.com','candidates.workablemail.com']) console.log(d.padEnd(30), g.test(d))"
workday.com true
myworkday.com false
candidates.workablemail.com false
A dot is not a word character, so workday.com matches. Put any letter in front and the anchor fails. Three real senders slipped the guard this way.
Dropping to substrings over-matches, and that is the correct trade rather than a concession to it. The two errors here are not comparable quantities: a source withheld wrongly costs one hand-written rule to override, and a source swept wrongly can cost something with no undo at the human level, whatever the API’s undo does. When your two error types have costs that differ by orders of magnitude, tune the guard until the cheap error is common and the expensive one is rare, and write down in the code which is which, because the next person to read it will assume the over-matching is sloppiness.
Refuse the rules that select everything
// rules.mjs
export class RuleError extends Error {}
// `olderThanDays` is a legal field. It is also the one field that must never be
// a rule's ONLY constraint, which is why it needs its own refusal below rather
// than simply being left out.
const MATCH_FIELDS = new Set(['source', 'subject', 'olderThanDays']);
const TEXT_FIELDS = new Set(['source', 'subject']);
export function validateRule(rule, seenIds) {
if (!rule.id) throw new RuleError('a rule needs an id');
if (seenIds.has(rule.id)) throw new RuleError(`duplicate rule id "${rule.id}"`);
if (!rule.note) throw new RuleError(`rule "${rule.id}" has no note; an unexplained destructive rule is unreviewable`);
const fields = Object.keys(rule.match ?? {});
if (fields.length === 0) {
throw new RuleError(`rule "${rule.id}" matches on no field, so it selects everything`);
}
for (const field of fields) {
// A typo is not a narrower rule. It is a rule that silently never fires,
// or worse, one whose remaining constraints now select the whole corpus.
if (!MATCH_FIELDS.has(field)) {
throw new RuleError(`rule "${rule.id}" matches unknown field "${field}"; known fields are ${[...MATCH_FIELDS].join(', ')}`);
}
if (TEXT_FIELDS.has(field) && String(rule.match[field]).length < 2) {
throw new RuleError(`rule "${rule.id}" matches "${rule.match[field]}" on ${field}; a one-character match is not a match`);
}
}
// Age is a filter, never a selector: "everything older than 30 days" is the
// whole corpus with a delay on it.
if (rule.action === 'trash' && fields.length === 1 && fields[0] === 'olderThanDays') {
throw new RuleError(`rule "${rule.id}" trashes on age alone, which selects the whole corpus eventually`);
}
seenIds.add(rule.id);
return rule;
}
export function validateAll(rules) {
const seen = new Set();
return rules.map((r) => validateRule(r, seen));
}
Rejecting an unknown match field matters more than it looks. Silently ignoring sender when you meant source does not produce a rule that does less; it produces a rule with one fewer constraint, which in a destructive tool is a rule that does considerably more. Unknown-key tolerance is a fine default for a config file and a bad one for a weapon.
Enumerate, then act only on the enumeration
// plan.mjs
import { withheldReason } from './guards.mjs';
export class PlanError extends Error {}
const matches = (item, cluster, match) =>
Object.entries(match).every(([field, needle]) => {
const hay = field === 'source' ? cluster.source : item[field];
return String(hay).toLowerCase().includes(String(needle).toLowerCase());
});
// Enumerate exactly what would be destroyed. Writes nothing, touches nothing.
export function plan(clusters, rules) {
const actions = [];
const withheld = [];
for (const cluster of clusters) {
const reason = withheldReason(cluster);
if (reason) {
withheld.push({ source: cluster.source, count: cluster.items.length, reason });
continue;
}
for (const rule of rules) {
for (const item of cluster.items) {
if (matches(item, cluster, rule.match)) {
actions.push({ ruleId: rule.id, id: item.id, source: cluster.source, subject: item.subject });
}
}
}
}
return { actions, withheld };
}
// Apply exactly the plan and nothing else.
//
// The executor is handed one id at a time and the id is checked against the
// plan first, so a bug that widens selection between planning and applying
// cannot destroy anything: it can only make this throw.
export function apply(planned, executor) {
const authorised = new Set(planned.actions.map((a) => a.id));
const receipt = [];
for (const id of executor.targets()) {
if (!authorised.has(id)) {
throw new PlanError(`refusing to act on ${id}: the plan did not name it`);
}
executor.act(id);
receipt.push(id);
}
return receipt;
}
Every action carries the ruleId that produced it. This is what makes the plan reviewable rather than merely long: a reader scanning two hundred rows is not checking two hundred decisions, they are checking that each rule took the kind of thing it claims to take, and one wrong row points straight at the rule that made it.
The withheld list is output, not a silent skip. A tool that quietly declines to act on a third of your data is indistinguishable from one that is broken.
Run it
// demo.mjs
import { validateAll, RuleError } from './rules.mjs';
import { plan, apply, PlanError } from './plan.mjs';
const clusters = [
{
source: '[email protected]',
items: [
{ id: 'a1', subject: 'Weekend sale, 40% off' },
{ id: 'a2', subject: 'Last chance, sale ends tonight' },
{ id: 'a3', subject: 'New arrivals this week' },
],
},
{
// High volume, looks exactly like the cluster above by the numbers.
source: '[email protected]',
items: [
{ id: 'b1', subject: 'Your application was received' },
{ id: 'b2', subject: 'Your verification code is 402118' },
{ id: 'b3', subject: 'Interview scheduled' },
],
},
{
source: '[email protected]',
items: [
{ id: 'c1', subject: 'Your receipt from order 88120' },
{ id: 'c2', subject: 'Track your delivery' },
],
},
];
console.log('=== rules that get refused before they can run ===');
const bad = [
{ id: 'r-age', action: 'trash', note: 'tidy up', match: { olderThanDays: 30 } },
{ id: 'r-empty', action: 'trash', note: 'clear junk', match: {} },
{ id: 'r-typo', action: 'trash', note: 'newsletters', match: { sender: 'shopfront' } },
{ id: 'r-thin', action: 'trash', note: 'sales', match: { subject: 'a' } },
];
for (const rule of bad) {
try {
validateAll([rule]);
console.log(` ACCEPTED ${rule.id}`);
} catch (error) {
if (!(error instanceof RuleError)) throw error;
console.log(` REFUSED ${error.message}`);
}
}
console.log('\n=== the plan ===');
const rules = validateAll([
{ id: 'sales', action: 'trash', note: 'retail marketing I never read', match: { subject: 'sale' } },
{ id: 'shipping', action: 'trash', note: 'delivery pings', match: { subject: 'delivery' } },
]);
const planned = plan(clusters, rules);
for (const a of planned.actions) console.log(` trash ${a.id} [${a.ruleId}] ${a.subject}`);
console.log('\n withheld:');
for (const w of planned.withheld) console.log(` ${w.source.padEnd(28)} ${w.count} items, ${w.reason}`);
console.log('\n=== apply is bounded by the plan ===');
const trashed = [];
const honest = { targets: () => planned.actions.map((a) => a.id), act: (id) => trashed.push(id) };
console.log(` applied: ${apply(planned, honest).join(', ')}`);
// A widened selection between plan and apply cannot destroy anything.
const widened = { targets: () => [...planned.actions.map((a) => a.id), 'b2'], act: (id) => trashed.push(id) };
try {
apply(planned, widened);
} catch (error) {
if (!(error instanceof PlanError)) throw error;
console.log(` ${error.message}`);
}
node demo.mjs:
=== rules that get refused before they can run ===
REFUSED rule "r-age" trashes on age alone, which selects the whole corpus eventually
REFUSED rule "r-empty" matches on no field, so it selects everything
REFUSED rule "r-typo" matches unknown field "sender"; known fields are source, subject, olderThanDays
REFUSED rule "r-thin" matches "a" on subject; a one-character match is not a match
=== the plan ===
trash a1 [sales] Weekend sale, 40% off
trash a2 [sales] Last chance, sale ends tonight
withheld:
[email protected] 3 items, source contains "workday"
[email protected] 2 items, an item carries "receipt"
=== apply is bounded by the plan ===
applied: a1, a2
refusing to act on b2: the plan did not name it
The myworkday.com cluster is the real finding reproduced: three items, high volume, indistinguishable from marketing by count, withheld on a substring the anchored pattern would have missed. The parcel cluster is withheld on content rather than sender, which is the guard catching a case the sender list never anticipated. And the last line is a selection that widened after approval, hitting a wall instead of a mailbox.
Gotchas
Print file paths to stderr, not stdout. Written-path lines on stdout make every golden test host-dependent, because the path carries the machine’s temp directory. This is the same defect as padding a table cell with an absolute path: it passes locally and fails in CI, and the fix is to keep machine-specific strings out of the stream you compare.
Order your explanation checks by what the user can act on. When a proposal comes back empty it should say which of the possible causes applied, and the order those causes are tested in decides whether the advice is any good. Here the volume check has to run before the threshold check. Reversed, a two-item cluster from an actual human landed in the “closest candidates” list, so the empty-result explanation cheerfully suggested lowering the threshold until it would sweep a real person’s mail. A “you almost matched” list must only ever contain things that would genuinely become candidates.
Pseudonymising a corpus is not anonymising it. The baseline fixtures for this had to be invented rather than redacted. With every sender name replaced, a real mailbox still publishes which bank, which health system, which school district and which employers the owner deals with, and the cluster shapes alone carry that. If your fixtures come from somebody’s private data, commit a generator that reproduces the shape and add a test that fails when a real identifier appears. Redaction leaves the structure intact, and the structure is the disclosure.
Counts are not meaning, and no amount of statistics will fix that. The cluster that nearly got swept was, numerically, the most obvious junk in the mailbox. What separated it was the domain and the subject lines. If your ranking is built from volume and regularity, assume it will confidently rank the most important thing you own as the least, and put the semantic guard somewhere the ranking cannot outvote it.
Sources
- Gmail API, users.messages.delete — the permanent operation, and the reference’s own advice to prefer trash.
- Terraform CLI, apply — passing a saved plan is itself the approval, and the plan holds the final decisions.
- Word boundary assertion, MDN — what
\bactually asserts, and which characters count as word characters.