A small, self-hosted email helpdesk. Support mail arrives, becomes a ticket, an agent replies, the thread continues. Built for a team of 1–5.
Named for /var/spool/mail, and for the thing you wind thread onto.
These documents are the design record. They explain why the code is shaped the way it is — the code itself explains what it does. If you change a decision recorded here, change the document in the same commit.
| Document | Covers |
|---|---|
| architecture.md | Stack, storage model, database configuration, what's deliberately absent |
| ingest.md | Inbound mail: rejection, threading, MIME splitting, idempotency |
| outbound.md | Outbound mail: Mailgun, the delivery queue, delivered_at, backfill |
| compression.md | zstd layer, dictionaries, when and how to train them |
| queue.md | Tuber, consumers, the scheduler, the Active Job adapter |
| auth.md | OIDC, the allowlist, the three configuration states |
| tags.md | Ticket tags, the spam tag, blocked senders, what the inbox hides |
| ui-contract.md | The model API the views are built against |
| ui.md | The screens: design tokens, screen anatomy, Stimulus and Turbo conventions |
| deploy.md | Docker image, versioning, release, what CI runs |
| versioning-rollout.md | Brief for porting this versioning scheme to the sibling repos |
| todo.md | What's built, what's next, open decisions |
Read architecture.md first — it explains the one constraint everything else follows from: the primary SQLite file is the entire application state. There is no Active Storage, no blob directory, no second datastore. That property is why attachments live in a table, why there is a compression layer at all, and why Action Mailbox was rejected.
Then read ingest.md. The email plumbing is roughly 80% of the difficulty and 20% of the code.
bin/setup # bundle, prepare databases
bin/dev # web + worker + scheduler + tailwind + tuber (Procfile.dev)
bin/rails test # the suite
bin/rails test:system
bin/standardrb # style; --fix to autocorrect
bin/ci # everything CI runs
bin/build # the production image, locally, with GIT_SHA baked inThe README's screenshots are generated from the seeds rather than curated:
SCREENSHOTS=1 bin/rails test test/system/screenshots_test.rb rewrites
docs/images/. That test skips without the variable, so CI never takes a
picture and the images only change when someone means them to.
Style is Standard, matching splat.
There is no house style to learn and no .rubocop.yml to argue with — run
bin/standardrb --fix and move on. The two per-file exemptions in
.standard.yml carry their reasoning inline.
Tuber is expected on localhost:11300; override with TUBER_URL. Without a
provider configured, Spool runs open in development and every request acts as a
stand-in agent — see auth.md.