# `rules.jsonc` — per-user message classification rules (design proposal)

> Draft for discussion. Nothing implemented yet. Captures the mechanism we need to agree on before writing code.

## Why

Per-sender flagging (the existing `flaggedSenders` / `flaggedDomains` in `allowlist.jsonc`) is unreliable: spammers rotate addresses faster than the user can list them. A pattern-based system that runs on first sight of a message is more durable. It also covers the cases where the user wants:

- Auto-move newsletters to a folder
- Star anything from a specific person
- Drop everything matching a phishing keyword pattern
- Mark certain mailing-list traffic as read on arrival

The mechanism should be **declarative** (JSONC, no code), **deterministic** (same input → same output), and **fast** (no body-parse for header-only rules).

## Where the file lives

- **Per-user, synced**: `My Drive/home/.rmfmail/rules.jsonc` — your personal rules, available on every device.
- **Disjoint from `contact-rules.jsonc`**: that one is *global, app-shipped* — applies to everyone, deployed on every release. `rules.jsonc` is *yours alone*.
- **Disjoint from `allowlist.jsonc`**: keep `senders/domains/recipients` (remote-content allow) and `flaggedSenders/flaggedDomains` (sender-watch list) where they are. `rules.jsonc` is a *new* layer on top.

## Schema (proposed)

```jsonc
{
  "version": 1,
  "rules": [
    {
      "name": "Newsletter auto-archive",       // human label, shown in match logs
      "when": "incoming",                       // "incoming" | "outgoing" | "always"; default "incoming"
      "match": [
        { "header": "list-id" },                // exists check (any non-empty value)
        { "header": "subject", "regex": "(?i)(unsubscribe|newsletter|digest)" }
      ],
      "matchMode": "any",                       // "all" | "any"; default "all"
      "actions": [
        { "type": "moveTo", "folder": "Newsletters" },
        { "type": "markRead", "value": true }
      ],
      "stop": true                              // first match wins; remaining rules skipped
    },
    {
      "name": "Star anything from boss",
      "match": [
        { "header": "from", "contains": "boss@example.com" }
      ],
      "actions": [
        { "type": "flag", "value": true }       // sets \\Flagged
      ]
    },
    {
      "name": "Junk obvious phishing-domain shapes",
      "match": [
        { "senderDomain": { "regex": "^[a-z0-9-]{1,3}\\.[a-z]{2,}$" } },
        { "header": "subject", "regex": "(?i)(verify your|account suspended|click here to)" }
      ],
      "actions": [ { "type": "moveTo", "folder": "Junk" } ],
      "stop": true
    }
  ]
}
```

### Match conditions

Each entry in `match[]` is one predicate:

| Field | Operator | Example | Notes |
|---|---|---|---|
| `header: "name"` | bare = exists | `{ "header": "list-id" }` | true when header is present and non-empty |
| `header: "name"` + `regex` | regex match | `{ "header": "from", "regex": "(?i)@example\\.com$" }` | case-insensitive by default; explicit `(?i)` allowed |
| `header: "name"` + `contains` | substring | `{ "header": "subject", "contains": "[urgent]" }` | case-insensitive |
| `header: "name"` + `equals` | exact | `{ "header": "to", "equals": "team@bob.ma" }` | case-insensitive |
| `senderDomain: { regex \| contains \| equals }` | extracts domain from From | shortcut so users don't have to remember the from-header trick |
| `body: { regex \| contains }` | matches against text body | slower (requires body fetch); only run if no header rule rejected first |
| `account: "id"` | scope to one account | `{ "account": "bobma" }` | combined with other predicates via matchMode |
| `folder: "name"` | scope to incoming folder | rare; useful for cross-folder rules |

### Actions

| Type | Param | Effect |
|---|---|---|
| `moveTo` | `folder: "name"` | IMAP move to that folder; queued in sync-actions |
| `delete` | — | Move to Trash (true delete is rarely what users want) |
| `markRead` | `value: true \| false` | Set/clear `\\Seen` |
| `flag` | `value: true \| false` | Set/clear `\\Flagged` (the per-message ⚑/★) |
| `addLabel` | `label: "name"` | Gmail label add (Gmail only) |
| `tag` | `tag: "name"` | Local-only tag (no IMAP equivalent; for filtering UI) |
| `notify` | `message: "text"` | Desktop notification with custom message |
| `priority` | `value: true \| false` | Mark this message as from a priority sender. Row gets `.priority` class; CSS highlights it (e.g. gold left border, bolder sender name) — strongest while unread, fades when read. |

Deferred (require more design):
- `forwardTo` — auto-forward (loop risk, deliverability)
- `sendReply` — auto-reply with template (vacation responder territory)

### Evaluation order

1. Rules apply in array order.
2. Multiple rules can match a single message; all their actions accumulate.
3. `stop: true` on a matching rule halts further evaluation for that message.
4. Conflicts (e.g. two `moveTo` in the same evaluation) → last one wins.

## When rules run

- **Incoming**: during sync, after metadata is inserted into the local DB but before the row is shown in the list. Sync emits a `folderCountsChanged` event after rules apply, so the user sees the post-rule state.
- **Manual "apply rules to existing"**: a Settings button (or `rmfmail -applyrules` CLI) walks every cached message and applies. Useful for cleaning up an existing INBOX after adding new rules.
- **Live edit**: when `rules.jsonc` saves, the in-memory rule set reloads. Existing messages aren't re-evaluated until next sync (or manual apply).

## Migration

When `rules.jsonc` first loads, if `flaggedSenders` / `flaggedDomains` in `allowlist.jsonc` are non-empty AND the user has no `rules.jsonc` yet, auto-generate a starter file with one rule per entry:

```jsonc
{
  "version": 1,
  "rules": [
    {
      "name": "Migrated: warn on sender boss@spam.com",
      "match": [{ "header": "from", "contains": "boss@spam.com" }],
      "actions": [{ "type": "tag", "tag": "warn" }]
    }
  ]
}
```

The `tag` action is intentionally weak — same effect as today's per-sender `flaggedSenders` (just paints a warning banner), no destructive action. User can then refine to `moveTo: Junk` etc.

## Priority senders (companion to watch list)

Symmetric concept: a user-curated list of *important* senders whose unread messages should visually stand out in the list. Same data shape as `flaggedSenders` (now "watch list"), opposite intent.

Two ways to source the list:

- **A. Reuse `contacts.jsonc → preferred[]`** — every preferred contact's incoming mail is "priority" by default. Single concept; simpler. May couple two things that should be separate (autocomplete-preferred vs. visually-prominent).
- **B. New explicit list** — `prioritySenders` / `priorityDomains` (added to `allowlist.jsonc` for symmetry with the watch list, OR a new `priority` array in `contacts.jsonc`). Independent of autocomplete preference.

Either way, the rendering is the same:

- Row gains `.priority` class when sender / domain matches.
- CSS: `.ml-row.priority.unread` → distinct styling (gold accent, bolder name, possibly a small star/dot indicator that's *not* the per-message ⚑/★).
- After the message is read, the priority cue de-emphasizes (CSS `.ml-row.priority:not(.unread)` is more subtle) — Bob's intent: "at least as long as the messages are unread".

Once `rules.jsonc` lands, the same effect can be expressed via a rule + the `priority` action above. The dedicated list could either:
- Be removed (rules-only), OR
- Stay as a fast-path the rule engine doesn't need to consult on every message.



1. **Is the action set right?** Listed: `moveTo / delete / markRead / flag / addLabel / tag / notify`. Anything missing? Anything you'd cut to keep the surface tight?
2. **Body matching:** include `body: { regex / contains }`? Slower (requires body-fetch before list shows the row), but useful for catching specific phishing patterns. Could gate behind `requiresBody: true` per-rule so the sync path knows to fetch first.
3. **Per-account scoping:** is `account` predicate enough, or do you want a top-level `account` field on the rule?
4. **First-match-wins by default vs all-match-applies:** I have all-match with explicit `stop`. Should it be the other way (first-match-wins, with explicit `continue`)?
5. **Editing UI:** JSONC editor only (matches today's pattern), or build a structured rule builder later?
6. **Legacy migration:** auto-translate `flaggedSenders` / `flaggedDomains` to rules with `tag: "warn"` action? Keep both systems running indefinitely? Or deprecate the old one and force migration?
7. **Concept of "test mode":** would you want a `--dryrun` action set ("log what would happen, don't actually move/flag")? Useful for shipping a rule confidently.
8. **Performance bound:** how many rules is "a lot"? My internal sense is 50-200 is fine for header-only matching. Body-matching needs to be careful.

## Status

- Priority-sender feature **shipped as a standalone facility** ahead of the rules engine. Implementation lives in `mailx-store/db.ts` (in-memory index) + `mailx-service/index.ts` (`getPriorityLists` / `setPrioritySender` / `setPriorityDomain`), with Android parity in `mailx-store-web/web-service.ts`. Visual: `.ml-row.priority` class with gold left bar + bold sender name. View → "Priority senders only" toggle. Right-click "Mark sender as priority" in the viewer.
- **TODO when rules engine ships:** add a `priority` action so priority can be expressed as a rule, not only as a contacts.jsonc property. Keep the contacts.jsonc field as a fast path that doesn't require running the engine on every row render. Alternatively, deprecate the field and migrate to a rule.

## Implementation order (once approved)

1. Schema + parser in `mailx-store/rules.ts` (pure function, fully testable)
2. Evaluator: `evaluate(message, rules) → ActionList`
3. Hook into desktop sync (mailx-imap) — fire after metadata insert
4. Hook into Android sync (mailx-store-web) — same place
5. JSONC editor file-list adds `rules.jsonc`
6. Rename `flaggedSenders/flaggedDomains` → keep field names for compat; UI already uses "watch list" wording
7. Migration step on first launch with new file
8. `rules.md` doc deployed to GDrive (same mechanism as the other `.md` files in 1.0.498+)
