Inbox Rules Builder
Gmail filters, firewall ACLs, routing tables: they all do the same thing. Walk an ordered list of rules top to bottom, and stop at the first one that matches. Move a rule up, and it starts catching messages a rule below it used to catch. The order is not decoration, it is the behavior: first match wins.
The form below is one repeatable ordered choice: a single metadata tree, rendered through one template, with no bespoke engine code behind the reorder-and-reroute effect. Each row is a rule with genuinely different fields per condition type. Reorder two rules and the live test inbox on the right re-routes a message the instant the order changes.
What you are looking at
The test inbox on the right is six fixed, fictional messages, routed entirely in your browser. There is no real mailbox here, no mail server or account is involved, and no rule or message is ever sent anywhere.
This page specializes a shape the library already demonstrates more generically on Choices, and that Checkout also shows, opt-in and lower stakes, inside its split-payment section. See Related Source below for why this page exists as the dedicated showcase for it.
The rule list, live
Add a rule of any of the four condition types, give it an action, and reorder with the up/down arrows. The rules you build here live only in this page's own memory: no rule set is ever saved anywhere outside your browser tab, and the "mailbox" on the right stays fixed demo data, never a real inbox, no matter what you add.
Metadata walkthrough
Since this page sets aside: false, there is no "On this page" outline. Use this short list instead:
- The rule list, a repeatable ordered choice
- Four condition branches, four shapes
- The per-rule action
- The reorder control
- The live routing module
- The unreachable-rule check
- What It Demonstrates
- How It Works
- Related Source
The rule list, a repeatable ordered choice
The whole form is one node: maxOccurs > 1 combined with a choice, in explicit selection mode, with preserveOrder: true so each occurrence gets a real, reload-surviving order field to reorder against:
const metadata: Metadata[] = [
{
name: 'rules',
type: 'rule',
fieldOptions: { label: 'Routing rules' },
description: 'Applied top to bottom. The first rule that matches a message wins.',
// Zero rules is valid data: every message simply stays in the Inbox until a rule is added.
minOccurs: 0,
maxOccurs: RULES_MAX_OCCURS,
explicitChoiceSelection: true,
displayOrder: 'added',
// Required for the up/down reorder control to have anything to write: every branch below
// already carries the shared action block as children, so this needs no metadata reshape.
preserveOrder: true,
choice: [
{
name: 'fromSender',
type: 'rule',
fieldOptions: { label: 'From sender' },
children: [
{
name: 'email',
type: 'email',
fieldOptions: { label: 'Sender email' },
placeholder: '[email protected]',
restriction: { pattern: EMAIL_PATTERN },
},
...createRuleActionFields(),
],
},
{
name: 'subjectContains',
type: 'rule',
fieldOptions: { label: 'Subject contains' },
children: [
{ name: 'text', type: 'text', fieldOptions: { label: 'Text in subject' }, placeholder: 'e.g. Invoice' },
...createRuleActionFields(),
],
},
{
name: 'largerThan',
type: 'rule',
fieldOptions: { label: 'Larger than' },
children: [
{ name: 'size', type: 'number', fieldOptions: { label: 'Size' }, placeholder: '1' },
{
name: 'unit',
type: 'select',
fieldOptions: { label: 'Unit' },
options: UNIT_OPTIONS,
restriction: { enumeration: UNIT_OPTIONS.map(option => option.key) },
},
...createRuleActionFields(),
],
},
{
name: 'hasAttachment',
type: 'rule',
fieldOptions: { label: 'Has attachment' },
// No filter field of its own: the shared action block below already gives this branch
// children, so it satisfies the engine's default required check with nothing artificial.
children: [
...createRuleActionFields(),
],
},
],
},
];Four condition branches, four shapes
Each branch is a genuinely different condition, not a cosmetic relabel: an email leaf with a pattern (From sender), a plain text leaf (Subject contains), a number-plus-unit pair (Larger than), and a branch with no filter field of its own (Has attachment). That last branch still needs children for preserveOrder to work, so it carries only the shared action block below, which is already enough: no presence marker is written or needed, unlike the checkout's truly childless express/home-delivery/PayPal branches.
{
name: 'hasAttachment',
type: 'rule',
fieldOptions: { label: 'Has attachment' },
// No filter field of its own: the shared action block below already gives this branch
// children, so it satisfies the engine's default required check with nothing artificial.
children: [
...createRuleActionFields(),
],
},The per-rule action
Every rule carries an action: move to folder, apply label, forward, or delete. It is modeled as one select with three companion leaves (folder / label / forwardTo) revealed by computedProps, mirroring the checkout's business/VAT toggle. The twist here is that each rule occurrence has its own action, so there is no single shared flag to key a reveal off: each companion field's own validation closure reads its own occurrence's action straight off the form, by path:
// A companion field (folder / label / forwardTo) only ever needs to validate while its own
// occurrence's action still points at it. Reading the sibling action through the validation ctx,
// rather than a single shared flag, is what makes this correct for every occurrence independently:
// a module-level boolean could only ever represent one rule's action, not N of them.
function companionValidation(expectedAction: string, formatCheck?: (value: unknown) => true | string): GenericValidateFunction {
return (value, ctx) => {
// ctx.field is the human-readable label used in error messages; ctx.name carries the actual
// registered path, which is what a sibling lookup needs.
const action = readPath(ctx.form, actionPathFor(ctx.name));
if (action !== expectedAction)
return true;
return formatCheck ? formatCheck(value) : true;
};
}
// The shared action block every branch carries: a required action select plus its three
// companion fields, each hidden and de-required unless its own action is selected. This also
// gives every branch, including "Has attachment", non-empty children (see the hasAttachment
// branch below), so none of them need a presence marker to satisfy the engine's required check.
function createRuleActionFields(): Metadata[] {
return [
{
name: 'action',
type: 'select',
fieldOptions: { label: 'Then' },
options: ACTION_OPTIONS,
restriction: { enumeration: ACTION_OPTIONS.map(option => option.key) },
},
{
name: 'folder',
type: 'text',
fieldOptions: { label: 'Folder' },
placeholder: 'e.g. Large files',
minOccurs: 0,
validation: companionValidation('folder'),
computedProps: [(thisField) => {
const action = siblingAction(thisField);
thisField.hide = action !== 'folder';
thisField.minOccurs = action === 'folder' ? 1 : 0;
}],
},
{
name: 'label',
type: 'text',
fieldOptions: { label: 'Label' },
placeholder: 'e.g. Newsletters',
minOccurs: 0,
validation: companionValidation('label'),
computedProps: [(thisField) => {
const action = siblingAction(thisField);
thisField.hide = action !== 'label';
thisField.minOccurs = action === 'label' ? 1 : 0;
}],
},
{
name: 'forwardTo',
type: 'email',
fieldOptions: { label: 'Forward to' },
placeholder: '[email protected]',
minOccurs: 0,
validation: companionValidation('forward', emailFormat),
computedProps: [(thisField) => {
const action = siblingAction(thisField);
thisField.hide = action !== 'forward';
thisField.minOccurs = action === 'forward' ? 1 : 0;
}],
},
];
}The reorder control
The up/down buttons write the two affected occurrences' order values directly through the normal field-write path, exactly as the checkout's split-payment ledger row does. The engine's renderedChoiceOccurrences re-sorts with no remount. The row only decides which pair of occurrences to swap and what their new order values should be; the arithmetic itself lives in a pure helper so it stays correct even when order values are not contiguous (for example, right after an earlier removal):
const rules = computed(() => collectRules((values as { rules?: unknown }).rules));
const upSwap = computed(() => planReorderSwap(rules.value, myKey.value, 'up'));
const downSwap = computed(() => planReorderSwap(rules.value, myKey.value, 'down'));/**
* Works out which two occurrences swap order values when a rule moves up or down, operating on
* the order-sorted rule list rather than raw +-1 arithmetic. This is what keeps the swap correct
* even when order values are not contiguous, for example right after a removal elsewhere in the
* list. Returns null at a boundary (no neighbor in that direction), which a caller uses to decide
* whether its own up/down control is disabled.
*/
export function planReorderSwap(rules: Rule[], targetKey: string, direction: ReorderDirection): [ReorderWrite, ReorderWrite] | null {
const sorted = [...rules].sort((a, b) => a.order - b.order);
const targetIndex = sorted.findIndex(rule => rule.key === targetKey);
if (targetIndex === -1)
return null;
const neighborIndex = direction === 'up' ? targetIndex - 1 : targetIndex + 1;
if (neighborIndex < 0 || neighborIndex >= sorted.length)
return null;
const target = sorted[targetIndex];
const neighbor = sorted[neighborIndex];
return [
{ key: target.key, order: neighbor.order },
{ key: neighbor.key, order: target.order },
];
}The live routing module
A small, pure, synchronous TypeScript module (mirroring the checkout's own ledger math) walks the rules in their current displayed order and returns, per sample message, the first rule that matches and its action. A template-level computed reads the form's values directly, never through computedProps, and feeds the result into the inbox panel on every add, edit, remove, or reorder:
/**
* Whether a single rule catches a single message. An empty or unfilled rule value never matches,
* so a half-filled rule can never silently act as a catch-all.
*/
export function matches(rule: Rule, mail: Mail): boolean {
switch (rule.branchKey) {
case 'fromSender': {
const ruleValue = rule.email?.trim().toLowerCase();
if (!ruleValue)
return false;
const from = mail.from.toLowerCase();
return ruleValue.startsWith('@') ? from.endsWith(ruleValue) : from === ruleValue;
}
case 'subjectContains': {
const ruleValue = rule.text?.trim().toLowerCase();
if (!ruleValue)
return false;
return mail.subject.toLowerCase().includes(ruleValue);
}
case 'largerThan': {
const threshold = thresholdBytes(rule.size, rule.unit);
if (threshold == null)
return false;
return mail.sizeBytes > threshold;
}
case 'hasAttachment':
return mail.hasAttachment;
default:
return false;
}
}/**
* Routes every message against the rules in the order given (first match wins), reporting the
* catching rule and its 1-based display position, or none if nothing matches.
*/
export function routeInbox(rules: Rule[], inbox: Mail[]): RoutedMail[] {
return inbox.map((mail) => {
const index = rules.findIndex(rule => matches(rule, mail));
if (index === -1)
return { mail };
return { mail, rule: rules[index], ruleDisplayPosition: index + 1 };
});
}// Reads straight off the form's own reactive values on every change (add, edit, remove, or
// reorder), never through computedProps: this is a read-only display projection, not field state.
const routedInbox = computed(() => routeInbox(collectRules(values.rules), TEST_INBOX));The inbox panel itself just renders whatever it is handed, with a muted treatment for "no rule matched" since that is a perfectly valid outcome, never an error:
<p
v-if="entry.rule"
class="mt-2 inline-flex items-center gap-1 rounded-full bg-emerald-50 dark:bg-emerald-950 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300"
:data-testid="`test-inbox-${entry.mail.key}-matched`"
>
→ {{ destinationLabel(entry.rule) }}
<span class="text-emerald-500 dark:text-emerald-400">rule {{ entry.ruleDisplayPosition }}</span>
</p>
<p
v-else
class="mt-2 inline-flex items-center rounded-full border border-dashed border-slate-300 dark:border-slate-600 px-2.5 py-1 text-xs text-slate-500 dark:text-slate-400"
:data-testid="`test-inbox-${entry.mail.key}-nomatch`"
>
No rule matched, stays in Inbox
</p>The unreachable-rule check
Because order is causally decisive, a rule placed after another rule of the same condition type that already catches everything it would catch can never fire. A derived function reads the live rule list and flags that rule by naming the earlier rule that subsumes it. This never blocks submit: a dead rule is not invalid data, so the result is a display-only flag, never a validation vehicle:
/**
* Finds every rule that can never fire because an earlier rule of the same condition type already
* catches everything it would catch. Only same-type subsumption counts, never across condition
* types. Returns a map from each unreachable rule's own key to the key of the rule that subsumes
* it, so a caller can name the catching rule by its own displayed position. When more than one
* earlier rule would subsume the same later rule, the earliest one wins, since that is the rule
* that actually fires first. Reads its input only, never mutates it.
*/
export function findUnreachableRules(rules: Rule[]): Map<string, string> {
const sorted = [...rules].sort((a, b) => a.order - b.order);
const unreachable = new Map<string, string>();
for (let laterIndex = 1; laterIndex < sorted.length; laterIndex++) {
const later = sorted[laterIndex];
for (let earlierIndex = 0; earlierIndex < laterIndex; earlierIndex++) {
const earlier = sorted[earlierIndex];
if (subsumes(earlier, later)) {
unreachable.set(later.key, earlier.key);
break;
}
}
}
return unreachable;
}// A dead rule is not invalid data: this is a derived display flag only, never a validation
// vehicle, so it never touches meta.valid or the submit gate.
const unreachableBy = computed(() => findUnreachableRules(rules.value).get(myKey.value));
const isUnreachable = computed(() => unreachableBy.value !== undefined);
// The subsuming rule's own displayed position, e.g. "rule 3", read off the same order-sorted
// list the badge text names. When more than one earlier rule could subsume this one,
// findUnreachableRules already picked the earliest, so there is only ever one to resolve here.
const subsumingDisplayPosition = computed(() => {
const subsumingKey = unreachableBy.value;
if (!subsumingKey)
return undefined;
const index = rules.value.findIndex(rule => rule.key === subsumingKey);
return index === -1 ? undefined : index + 1;
});What It Demonstrates
- A repeatable ordered
choice(maxOccurs > 1+explicitChoiceSelection+preserveOrder) as the entire form, not a footnote - Four condition branches with genuinely different fields, including a branch with no filter field of its own
- A per-rule action with a conditionally revealed companion field via
computedProps, specified per occurrence rather than off one shared flag - Position badges from
globalIndex + 1, and up/down reordering that writes the affectedordervalues directly - A live side panel routing a fixed test inbox first-match-wins, re-routing on every reorder
- A cross-row unreachable-rule check that warns without blocking submit
How It Works
The rule list is a choice with maxOccurs > 1, explicitChoiceSelection, and preserveOrder: true. Each "Add rule" button pushes an occurrence into its branch's field array; reordering rewrites two occurrences' order values and the engine re-sorts renderedChoiceOccurrences with no remount, the same technique the Choices page demonstrates generically and the checkout's split-payment ledger already uses end to end.
The action block's companion fields are the one piece that is not a direct carryover: because every rule occurrence has its own action, each companion field's validation closure reads its own occurrence's action straight off the form by path, rather than a single module-level flag the way the checkout's business/VAT toggle can afford to.
Routing and the unreachable check both live in one small, pure, framework-free module, read through the form's own reactive values, never through computedProps. That keeps the routing math testable on its own, with no form needed, and keeps the engine itself out of the display path entirely. Submit is gated on the form's aggregate meta.valid; an unreachable rule never affects it, since a dead rule is dead configuration, not invalid data.
Related Source
- InboxRulesExample.vue (the metadata tree and the host)
- InboxRulesTemplate.vue (this page's own neutral template)
- InboxRuleRow.vue (one rule row: reorder, the unreachable badge)
- inboxRoutingMath.ts (the pure routing and unreachable-rule module)
- TestInboxPanel.vue (the sticky inbox rail)
- Choices for the repeatable ordered choice mechanics this page specializes into a single, genuinely consequential form
- Checkout's split-payment section, the other existing demonstration of a repeatable ordered choice, there as an opt-in finale rather than the whole page