A (very vibe coded) email management agent for Fastmail

Files
README.md

Rose #

A local agent that watches Fastmail traffic and turns repeated decisions into deterministic rules, so the LLM is invoked as little as possible over time.

The idea #

Most emails don't need a language model to handle correctly — they need a rule. Rose's job is to notice the pattern, let you approve it once, and never ask the LLM again for that kind of email.

                     ┌──────────────────────┐
  Fastmail (JMAP) ──▶│  plugin match?       │──yes──▶ extract fields ──▶ call a module (deterministic,
                     │  (regex, no LLM)     │                            no LLM: HTTP request built from
                     └─────────┬────────────┘                            typed fields per a TOML-defined
                               │ no                                       module spec, e.g. YNAB)
                               ▼
                     ┌──────────────────────┐
                     │  LLM triage          │──notify──▶ Discord thread ──▶ you reply ──▶ LLM converse
                     │  (no_op | notify     │                                  │                │
                     │   only — no mutating │                                  ▼                ▼
                     │   action possible)   │                          one-off action    "make this a rule"
                     └─────────┬────────────┘                          (fires once)      → author + test + save
                               │ no_op                                                       a new plugin
                               ▼
                          silently dismissed

Three ways an email gets handled, in order of how much they cost:

  1. A plugin matches. Regex extracts fields, a module (a declarative TOML file describing an HTTP API, not hand-written Rust) makes the call. Zero LLM calls. This is the steady state you build toward.
  2. Nothing matches, but it's obviously notify-worthy or obviously not. One LLM call decides no_op or notify — and that's architecturally the only thing it can decide here: the schema the LLM answers into has no mutating option, so "let it through" can never accidentally become "send a payment."
  3. You have a conversation about it. Reply in the Discord thread Rose created. She can take a one-off action, or — if you say "make this a rule" — author a new plugin, test it against the actual email that triggered the conversation, and only save it if the test passes.

A plugin that starts failing (email format changed, etc.) doesn't just error silently: Rose asks the LLM for a fix, verifies it against every existing fixture and the failing case, and only auto-applies it if the fix can't mutate anything external. A fix that touches money waits for you.

Architecture #

Crate Role
rose-core Pure domain types (Email, Action, TriageDecision, …). No I/O.
rose-plugins Plugin manifest schema, regex/Rhai extraction, fixture-based tester.
rose-modules Generic HTTP module system — new integrations are TOML, not Rust.
rose-secrets SecretStore trait. FileSecretStore (age-encrypted, portable) by default; KeychainSecretStore opt-in on macOS.
rose-jmap Fastmail JMAP client (session, polling, backlog queries).
rose-discord Discord bot: notifications, buttons, thread conversations.
rose-llm Anthropic client — every call uses forced tool-use for structured, schema-validated output.
rose-store SQLite state: seen emails, thread↔email map, plugin enable state, conversation history.
rose-daemon Orchestrator tying all of the above together, plus self-heal.
rose-cli The rose binary.

Plugins and modules are runtime data, not source: defaults.rs embeds the shipped examples (plugins/power-bill, modules/ynab.toml) into the binary at compile time, and rose init materializes them under the user's data directory. A packaged install never reads from the source tree at runtime — everything under plugins/ and modules/ at the repo root exists purely as the canonical, tested copy that gets embedded and as a development convenience.

Setup #

1. Toolchain #

rustup toolchain install 1.96.0   # matches rust-toolchain.toml

If your cc doesn't resolve to a toolchain with libiconv available (this can happen with certain Nix setups where the ambient C compiler wrapper points at an SDK missing that library), .cargo/config.toml pins the linker explicitly — check it if you hit a library not found for -liconv error; it's a no-op otherwise.

2. Run rose init #

cargo run -p rose-cli -- init

This creates Rose's data directory (XDG-conventional: ~/.local/share/rose on Linux, ~/Library/Application Support/rose on macOS — override with ROSE_DATA_DIR, or it's picked up automatically from systemd's STATE_DIRECTORY under a packaged service), sets up the secret store (generating an encryption identity on first use), and prompts for the four credentials below — only for whichever ones aren't already set, so it's safe to rerun after rotating a token. It does not install any plugins or modules — a fresh install starts with none; see "Authoring a plugin" below.

You'll need, before running it:

Fastmail API token — Settings → Privacy & Security → New API token, scoped to Mail only.

Discord bot:

  1. https://discord.com/developers/applications → New Application.
  2. Bot tab → Add Bot → copy the token.
  3. Bot tab → enable Message Content Intent (Rose reads thread replies).
  4. OAuth2 → URL Generator → scope bot, permissions: Send Messages, Create Public Threads, Read Message History. Open the generated URL to invite it to your server.
  5. Right-click the channel you want Rose to post in → Copy Channel ID (enable Developer Mode in Discord settings first if you don't see this).

Anthropic API key.

rose init writes everything to <data dir>/rose.env (0600 permissions).

3. Run it #

rose init prints the exact rose.env path it wrote (it lives under whatever rose init reported as the data directory). Source it, then run any subcommand:

set -a; source /path/from/rose-init/output/rose.env; set +a
cargo run -p rose-cli -- test       # run every plugin's fixtures — start here
cargo run -p rose-cli -- run        # the live daemon
cargo run -p rose-cli -- backlog    # one-shot paced backlog scan (good for cron)

Optional env vars: ROSE_DATA_DIR (overrides the whole resolved data directory), ROSE_PLUGINS_DIR / ROSE_MODULES_DIR / ROSE_STORE_PATH / ROSE_IDENTITY_PATH / ROSE_SECRETS_PATH (override individual paths under it), ROSE_SECRET_BACKEND (file — default — or keychain, macOS-only), ROSE_BACKLOG_BATCH_SIZE (default 5 — how many emails one backlog scan surfaces at once; deliberately paced, not a dump).

Running as a service #

See packaging/ for a systemd unit and a NixOS module (nixosModules.default in flake.nix). Both run rose init once interactively to generate the secret-store identity and collect credentials, then the service itself just runs rose run under StateDirectory=rose — the daemon needs no path configuration under a properly configured unit, since it picks up systemd's STATE_DIRECTORY automatically.

Authoring a plugin #

Want a worked example first? cargo run -p rose-cli -- plugin example power-bill installs the reference power-bill plugin (and its YNAB module dependency) — not something rose init does automatically, since a fresh install shouldn't silently contain a plugin you didn't write. Run plugin example with no name to see what's available.

To start from scratch:

cargo run -p rose-cli -- plugin new my-plugin

Writes <plugins dir>/my-plugin/manifest.toml, disabled by default. Fill in:

  • [match] — ANDed conditions (from_domain, subject_contains, subject_regex, body_contains, body_regex). At least one is required.
  • [extract] — regex fields with a named transform (text, integer, dollars_to_milliunits_expense/_income, three date formats), or a sandboxed Rhai script for shapes regex can't capture.
  • [action] — no_op, notify_discord, or call_module (references a module + operation defined under modules/).

Add fixtures under <plugins dir>/my-plugin/fixtures/*.toml (see plugins/power-bill/fixtures/ for the shape — both positive matches and negative "should NOT match" cases matter). Then:

cargo run -p rose-cli -- test

A plugin only becomes trustworthy once its fixtures pass — this is also exactly the mechanism self-heal uses to verify a proposed fix before applying it.

Adding an integration #

New external API → a new modules/<name>.toml, not a new crate. See modules/ynab.toml: base URL, auth (currently bearer_token referencing a secret-store entry, or none), and named operations (HTTP method, path template, required/optional typed fields, JSON body template). The executor's only capability is "send the HTTP request this operation declares with these typed fields substituted in" — it can't do anything a module definition doesn't explicitly describe.