Skip to content

Mail rules

Mail rules let you describe what a label means in plain English instead of building a filter out of match conditions. An LLM classifies every inbound message against your rule prompts, and each match can skip the inbox, carry a severity that steers triage, or kick off an agent, all without you touching a regex.

Rules live on the Rules tab of the Mail page, a header tab next to the account tabs. Each rule is a card:

  • Label: the tag applied on a match, shown as a chip with a running “matched N e-mails” counter.
  • Prompt: the natural-language description the classifier judges messages against, for example “cold outreach pitching a tool or service we didn’t ask about.”
  • Then: a Skip inbox / Keep in inbox toggle, an optional severity picked from the same levels triage uses (silent, normal, important, ultra), and an optional run agent picker naming an agent definition to invoke on a match.

Rules save immediately and broadcast a live-invalidate signal, so the Rules tab and any other open Mail window pick up the change without a refresh.

A background consumer watches every new inbound message and classifies its subject, snippet, and body against every rule’s prompt in one pass. On a match it commits, in a single transaction:

  • the label into comms.mail_labels and appended to the message’s label_ids,
  • INBOX removed if the rule says to skip it (the thread moves to the Mail page’s Skipped the inbox section),
  • the rule’s match counter incremented,
  • an idempotency stamp so redelivery is a no-op.

Sent mail never gets classified (a loop guard against labeling your own outgoing copies), and a newly created rule only classifies mail from that point forward, it doesn’t replay history.

Under a real model, one generateText call returns strict JSON naming every matched label, parsed and validated before anything commits; a failing call redelivers rather than silently dropping the message. Under PROVIDERS=fake, matching is deterministic: a rule matches when any significant keyword from its prompt (four or more letters, common stopwords excluded) appears in the message text, which makes rules easy to steer in tests and demos without depending on a live model.

Rule-applied labels write back to the real provider best-effort, after the local commit. Because Gmail rejects label names and wants real label ids, each label name resolves through comms.mail_labels; a label Subspace hasn’t seen before is created via Gmail’s users.labels.create and its id persisted (or, if a race means it already exists, matched by name from users.labels.list). If the write-back fails it’s logged and nothing rolls back: local, name-keyed labels stay authoritative, and the next history sync reconciles the provider’s ids back to names.

Severity overrides triage, deterministically

Section titled “Severity overrides triage, deterministically”

A rule can carry a severity, and when it does, a match routes the message’s mail-received event directly to that level instead of asking the LLM classifier to decide. This is the per-label equivalent of a triage level’s fallback rules, and because the mail rules consumer runs before triage, its decision short-circuits the classifier for that message entirely (recorded as decidedBy: 'rule').

A few edge cases worth knowing:

  • If an LLM classification somehow raced ahead of the rule, the rule’s severity overrides it, but only as an upgrade to a notifying route still fires the notification; a downgrade can’t unsend one that already went out.
  • Leaving severity unset means the classifier decides, same as any message with no matching rule.
  • A severity naming a level that’s since been renamed or deleted falls through to the classifier silently rather than erroring.

A matched rule with an agent attached starts a run on that agent definition once the transaction commits. The prompt template receives {{label}} {{threadId}} {{from}} {{subject}} {{snippet}} {{bodyText}}; a prompt with none of those placeholders still gets the mail context appended as a block, so the agent always has the message in front of it.

The seeded agents/mail-draft-reply definition demonstrates a grounded, gated reply flow: it searches the knowledge base for relevant context, then calls mail.proposeReply {threadId, body, sources?}, an ungated tool (proposing a draft is safe, sending isn’t) that files a needs-confirm card in the task queue.

The thread view on the Mail page surfaces the pending card as a violet proposed reply frame: the read-only draft body, a sources line where [[page]] tokens link back to the grounding pages, and two actions.

  • Approve & send resolves the task and dispatches the existing send path (correctly threaded, with Re: and the right In-Reply-To) before marking the card done. A failed send leaves the card pending so you can retry.
  • Dismiss closes the card without sending anything.

The raw mail.send tool itself is approval-gated (needsApproval: true), so an agent can never send mail directly, only through this same pause-and-confirm path, and every send call claims an idempotency key before it fires so a crash-replayed call can’t send twice.