Skip to content

Latest commit

 

History

927 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

bullmoose — a bull moose in stars and stripes, walking right

bullmoose

bullmoose is a self-hosted personal-data platform — email, contacts, calendar, and email-native agents — that runs serverless on Cloudflare's free tier. Your domain, your data, one core: modern clients speak JMAP, Apple devices sync over CardDAV/CalDAV, legacy apps reach mail through a POP3/SMTP shim, and agents are simply mailboxes with a runtime attached. A typical personal deployment costs $0/month (excluding your domain costs).

What it does · Who it's for · The stack · How it's built · Deploying your own · Agent-backed accounts · Status
Playbooks · Deploy checklist · Architecture · Cookbook

What it does

  • Mail on your own domain — a full JMAP server (RFC 8620/8621): send, receive, threads, search, push, drafts, vacation responses. Inbound arrives via Cloudflare Email Routing; outbound relays through AWS SES with DKIM/SPF/DMARC wired by the provisioning API (swappable to Cloudflare Email Sending once it exits beta).
  • Contacts and calendar as the source of truth — JSContact (RFC 9553/9610) and JSCalendar (RFC 8984) stored losslessly, with a capped on-demand recurrence engine (DST-correct, timezone-aware) and CLI importers to converge your existing data in (vCard exports, Google Calendar).
  • Native app sync — the anglebrackets worker serves CardDAV + CalDAV (the minimal verb subset real clients use), so Apple Contacts/Calendar sync against the same core your agents read. Idle device polls cost one row read.
  • Agents as mailboxes — bind a runtime to an address (editor@, analyst@) and it replies, extracts receipts into a spend ledger, or runs your own pipeline; SLA watchdogs auto-respond if an agent goes quiet. A read-only analytics MCP gives agents safe tools over the message log with zero external credentials.
  • Sharing and delegation, built correctly — cross-account grants (effective rights = token ∩ grant, every access audited) back both agent delegation and family sharing (AddressBook.shareWith); a write-only, envelope-encrypted credential vault holds third-party API keys and OAuth refresh tokens.
  • A real CLIbullmoose: login/autodiscovery, offline-capable sync to local SQLite, send (Markdown → MIME with inline images and big-file links), watch (live push), contacts/calendar import and agenda, token/grant/credential administration. Fully self-documenting — bullmoose help, bullmoose help --json, or the CLI reference.

Who it's for

  • Self-hosters who want mail + contacts + calendar on their own domain without running (or trusting) a mail VPS — there are no servers here to patch.
  • Families — multi-tenant schema, shared address books, per-device revocable app-passwords.
  • Agent builders who want email-native agents with real tools, scoped delegated access, and an auditable trail — not a bot with your whole inbox password.
  • Protocol tinkerers — a compact, readable implementation of the modern IETF personal-data stack (see the table below), e2e-tested against real clients and real data.

The stack, standard by standard

layer contacts calendar mail
JSON model JSContact — RFC 9553 JSCalendar — RFC 8984 JMAP Mail - RFC 8621
JMAP methods RFC 9610 draft-ietf-jmap-calendars (pragmatic core) RFC 8620/8621
Compat APIs vCard 6350 over CardDAV 6352 iCalendar 5545 over CalDAV 4791 POP3/SMTP via the popcorn shim
translation RFC 9555 JSCalendar ⇄ iCalendar RFC 5322 MIME

How it's built

Five stateless workers around one stateful actor: each account has a Durable Object owning a monotonic state sequence and a collection-agnostic changelog — mail, contacts, calendar, and agent queues all sync through the same commit//changes/push machinery. D1 holds metadata and JSON documents; R2 holds bytes (raw messages, attachments, contact photos). Rationale, diagrams, and the free-tier capacity story live in docs/architecture/.

The reusable logic lives in ten packages; the six services compose them into deployed workers. Both indexes link down into each component. Around them, infra/ is the bootstrap runbook, tools/ the e2e suites, and src/ the (unrelated) bullmoose.cc marketing site.

How it fits together

Two pictures. The first is what runs; the second is what happens to a message.

graph TB
    subgraph clients["Your clients"]
        MUA["Apple Mail · Thunderbird<br/>(IMAP-era apps)"]
        JMAP["JMAP clients<br/>· the webmail"]
        DAV["Apple Contacts<br/>· Calendar"]
        CLI["bullmoose CLI<br/>(one Go binary)"]
    end

    subgraph edge["Your Cloudflare account"]
        ING["ingest<br/>inbound mail"]
        API["jmap<br/>the API + webmail routes"]
        AUTH["oauth<br/>identity · passkeys"]
        AG["agent<br/>the runtime + MCP"]
        SUB["submit<br/>outbound relay"]
        BUR["bureau<br/>the credential vault"]
        DAVW["anglebrackets<br/>CardDAV · CalDAV"]
        PRV["provision<br/>the admin plane"]
        D1[("D1<br/>mail · contacts · calendar")]
        R2[("R2<br/>message bytes · files")]
    end

    POP["popcorn<br/>POP3/SMTP shim<br/>(your box)"]
    LOCAL["agent serve<br/>(your box, optional)"]
    SES["SES"]

    MUA --> POP --> API
    JMAP --> API
    DAV --> DAVW
    CLI --> API
    CLI --> PRV
    ING --> D1
    ING --> AG
    API --> D1
    API --> R2
    DAVW --> D1
    AG --> BUR
    AG --> SUB --> SES
    AUTH -.principal.-> API
    LOCAL -.claims the same queue.-> AG
Loading
sequenceDiagram
    participant W as The world
    participant CF as Email Routing
    participant I as ingest
    participant B as boundary
    participant D as D1 + R2
    participant A as agent
    participant H as You

    W->>CF: SMTP
    CF->>I: the raw message
    I->>B: sender sets · DMARC · sieve · bayes
    alt refused at the edge
        B-->>W: 5xx (zero storage)
    else held
        B->>D: quarantine mailbox (rescuable, never deleted)
    else delivered
        I->>D: bytes to R2, metadata to D1
        I->>A: enqueue an invocation per binding
        A->>H: a proposal — never an action
        H-->>A: approve · edit · decline
    end
Loading

Deploying your own

Two ways in. Both end at the same place: your domain, your Cloudflare account, your data.

The short way — one binary, one token (recommended)

No checkout, no Node, no build, no toolchain of any kind — one static binary and a Cloudflare API token. The CLI downloads a published, checksummed stack and applies it to your Cloudflare account over documented APIs, including the webmail itself (uploaded to R2 and served by a worker). Nothing shells out; there is no step at the end that hands you back to a package manager.

# 1. get the binary — detects your platform, verifies the checksum, installs
#    nothing if that fails (docs/install-cli.md for by-hand + platform notes)
curl -fsSL https://dl.bullmoose.cc/cli/install.sh | sh

# 2. see EXACTLY what would happen — read-only, nothing is created
export CLOUDFLARE_API_TOKEN=…            # scopes: docs/install-cloud.md
bullmoose cloud plan    --zone example.com

# 3. the same plan, one honest yes, then apply
bullmoose cloud install --zone example.com

# 4. install offers to connect this device — say yes and there is nothing
#    to copy: it derived the admin URL and minted the token itself.
bullmoose admin tenant add home
bullmoose admin domain add example.com --tenant <tenantId>   # MX + routing + DKIM
bullmoose cloud doctor  --zone example.com                   # did the mail path land?

The admin plane is one bearer token, and the installer is the only thing that ever sees it: it mints the token, derives the one address the provision worker can live at, and offers to write both down for you. Decline the offer and bullmoose admin init --token <the token> still works — the URL is derived either way.

cloud plan prints every resource by name — create / reuse / refuse — with refusals first. The installer never overwrites a resource it did not make, and a half-applied install is resumable: fix the cause, run the same command. Everything it mints (admin token, vault key, signing keys) is generated on your machine and lands only in your account. Full walkthrough: docs/install-cloud.md.

The long way — from source

For hacking on the platform itself — this is the only path that needs a toolchain. The workers are TypeScript and wrangler is what bundles and deploys them, so a source build needs Node 22+; that is a maintainer's concern, not a deployer's. The short way above consumes the artifacts this path produces, which is why it needs none of it.

Other prerequisites: a domain on a Cloudflare account (free plan works) and an AWS account for SES outbound. Authenticate wrangler once with npx wrangler login.

npm install && npm run typecheck

# resources → wire ids → schemas → secrets → deploy, in one idempotent pass.
# Preview everything first; drop --dry-run to execute. Re-runnable, and you
# can run a single phase, e.g. `node infra/bootstrap.mjs secrets`.
node infra/bootstrap.mjs --dry-run

# --url is derived from your Cloudflare account when omitted; pass it only
# to point somewhere else.
ADMIN_TOKEN=$(grep -m1 '^ADMIN_TOKEN=' .env | cut -d= -f2)
bullmoose admin init --token "$ADMIN_TOKEN"
bullmoose admin tenant create t_home --name "Home"
bullmoose admin domain add example.com --tenant t_home
bullmoose admin account create you@example.com --tenant t_home
bullmoose login you@example.com

The full checklist — SES identity verification, first-light testing, and what each phase does under the hood — is docs/DEPLOY.md.

Then: docs/playbooks/ are step-by-step client setups (Apple Mail + Calendar, a JMAP client like Mailtemi, family sharing), docs/carddav-setup.md is the Apple Contacts/Calendar detail, docs/README.md is the use-case cookbook (agents included), and tools/ holds the e2e suites everything is verified against.

Agent-backed accounts — cloud or local/homelab

An agent is an ordinary account plus a binding. Create the mailbox, bind a runtime to it, and every delivery enqueues an AgentInvocation; --sla arms a watchdog that answers if the agent goes quiet. The cloud worker and any homelab runner claim from that same queue — whoever claims first wins, the watchdog backstops both. So the account and binding are made identically; what differs is which runtime claims the work.

Cloud-backed — the deployed agent worker runs the binding (ingest poke + 5-minute sweep). Persona, defaultModel, and modelAliases (Workers AI free tier, openrouter — live since 2026-08-18, one key reaching any hosted model — or gateway once AI Gateway is wired) live in the binding config, beside the budget cap (budgets.spendPerMonth, µUSD) the paid drain refuses to spend past. Nothing to run:

bullmoose admin account create emily@example.com --tenant t_home --name "Editor Emily"
bullmoose admin agent bind emily@example.com --name editor --reply-mode draft \
  --config docs/examples/editor-emily.config.json

Homelab-backed — you run the runtime on your own box; it logs in as the account, watches the queue over JMAP push, and calls a local or self-hosted model (API keys by env reference, never in the file). Same account + binding, plus a long-running serve:

bullmoose admin account create hermes@example.com --tenant t_home --name "Hermes"
bullmoose admin agent bind hermes@example.com --name hermes-responder --sla 45

# on the homelab (one static Go binary — no runtime to install):
cat > hermes.json <<'JSON'
{ "binding": "hermes-responder",
  "persona": "You are Hermes, a terse, helpful assistant.",
  "model": { "provider": "openai-compatible", "baseURL": "http://localhost:11434/v1",
             "model": "llama3.3", "apiKeyEnv": "OLLAMA_KEY" },
  "reply": { "send": false } }
JSON
bullmoose login hermes@example.com
bullmoose agent serve --fleet hermes.json      # --once drains and exits (cron-friendly)
bullmoose agent install --fleet hermes.json    # optional: survive a reboot (opt-in, prints the unit first)

binding must match the bind --name; model.provider is mock | anthropic | openai-compatible (point baseURL at Ollama, a local gateway, anything). For a fully custom loop that skips bindings entirely, the watch --json bridge in docs/examples/hermes-bridge.sh is the pattern. Deeper: docs/architecture/agent-integration.md.

Where everything is

you want to… read
stand it up on your own domain docs/install-cloud.md — one binary, one token
get the CLI docs/install-cli.md · CLI reference
deploy from source docs/DEPLOY.md · infra/
set up Apple Mail / Contacts / Calendar docs/playbooks/ · docs/carddav-setup.md
understand the design docs/architecture/
work with agents docs/agents/ · docs/examples/
see it used for something real docs/README.md — the cookbook
follow a worked example on YOUR domain docs/examples/on-your-own-domain.md
check the standards claims conformance/

Status

Live and e2e-tested: the full mail surface, contacts + CardDAV, calendar + CalDAV, grants/sharing, the credential vault, agent pipelines, and the CLI.

Deliberately out of scope for now: calendar scheduling (iTIP/iMIP), WebDAV LOCK/COPY/MOVE, and CRDT merge for shared collections (ETags carry v1).

Capacity headroom and the shelved scaling valves are documented in docs/architecture/capacity-and-scaling.md.

About

Your email domain. Agent-native. Your private data.

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages