Catapulte
June 13, 2026 · View on GitHub
Make sending email easy.
Using the API? See the usage guide for how to submit emails (plain, HTML, MJML, attachments, batches), read their state, and subscribe to lifecycle events. The rest of this readme covers running and configuring a server.
User stories
Within each persona, stories are ordered by priority (most important first).
API consumer
- As an API consumer, I can ask an email (text or html) to be sent through a SMTP server, and get back a tracking id, so that I don't have to manage SMTP and retries myself.
- As an API consumer, I can ask an email to be sent from inline mjml plus variables, so that I keep template sources in my own repo.
- As an API consumer, I can ask an email with attachments to be sent through a SMTP server, so that I can send invoices, receipts or reports.
- As an API consumer, I can list emails I previously submitted with filters (status
queued/sent/failed, time range, recipient, template, tracking id), paginated, so that I can check delivery state and debug without keeping my own mirror of the data. - As an API consumer, I can pass an idempotency key on submission, so that retrying a failed request doesn't send the email twice.
- As an API consumer, I can submit a batch of emails in a single request and get back one tracking id per email, so that I can fan out a campaign without N round-trips. Partial acceptance is allowed: per-email validation errors are returned alongside the accepted ids.
- As an API consumer, I can ask an email to be sent from a pre-registered template name + variables, so that callers don't ship template bytes on every request.
- As an API consumer, I can ask an email to be sent from a remote mjml template fetched over http (with
mj-include) + variables, so that templates can live in a CMS or shared repo. - As an API consumer, I can list the lifecycle events for emails I submitted (
queued,sending,delivery.succeeded,delivery.failed,retrying), with filters (tracking id, event type, time range), paginated, so that I can debug a delivery without subscribing to the live event stream.
Operator
- As an operator, I can configure multiple SMTP servers with routing rules, so that I can fail over or split traffic per sender domain.
- As an operator, I can set per-server quotas (rate and daily cap), so that I stay within provider limits without dropping traffic.
- As an operator, I can list lifecycle events across all submissions (not scoped to one consumer) with filters (event type, time range, upstream server, error class), paginated, so that I can investigate incidents and audit traffic. (global listing with event-type and time-range filters and pagination is supported; filtering by upstream server and error class is not yet.)
- As an operator, I can expose multiple ingress transports for API consumers (HTTP for request/response CRUD, NATS for fire-and-forget submissions, more later), so that consumers can pick the integration style that fits their stack. Each transport can be enabled or disabled independently. NATS submissions don't return a tracking id synchronously: the consumer supplies a correlation id and observes outcome via lifecycle events.
Event subscriber
- As an event subscriber, I receive a
delivery.succeededevent when an email is accepted by the upstream SMTP, so that I can update my own state. - As an event subscriber, I receive a
delivery.failedevent after retries are exhausted, so that I can alert or compensate. The event carries the last error and the attempt count. - As an event subscriber, I receive events over whichever transport the operator has enabled globally (webhook to a configured URL, or NATS on a configured subject), so that I can plug catapulte into the bus my stack already speaks without managing per-subscription transport config.
Quick Start
The easiest way to run Catapulte is using Docker Compose. Several examples are provided in the compose directory:
- Local Development:
docker-compose -f compose/local-dev.yml upStarts Catapulte with an in-memory database and Mailpit for local SMTP testing. - SQLite (Persistent):
docker-compose -f compose/sqlite.yml upStarts Catapulte with a persistent SQLite database. - Postgres & NATS:
docker-compose -f compose/postgres-nats.yml upA more robust setup using Postgres for storage and NATS for the email queue and events. - MinIO (S3 attachments):
docker-compose -f compose/minio.yml upRuns Catapulte with a local MinIO instance as the S3-compatible attachment backend. - Redis (attachments):
docker-compose -f compose/redis.yml upRuns Catapulte with a Redis instance as the attachment backend. - Observability (OpenTelemetry):
docker-compose -f compose/observability.yml upExports traces and gauge metrics over OTLP to an OpenTelemetry Collector; the collector derives RED metrics from spans (spanmetrics) and exposes everything to Prometheus athttp://localhost:9090.
Verifying the Setup
You can run an automated smoke test against all compose configurations by running:
just test-compose
This script will bring up each configuration, submit a test email, verify it reached Mailpit, and then shut down the services.
The container image and all compose files define a healthcheck that runs catapulte healthcheck. The subcommand probes /health/ready over HTTP and exits non-zero when a downstream dependency is unavailable. Operators deploying to Kubernetes can use an exec probe ["catapulte", "healthcheck"] or a standard httpGet probe on /health/ready.
Readiness scope. /health/ready probes the storage backend and the queue backend (a live connection check when the queue is NATS; storage-backed and in-memory queues are covered by the storage probe). It deliberately does not probe the SMTP senders or the attachment store: SMTP outages are absorbed by the retry pipeline rather than making intake unready, and attachment-store outages only affect attachment-bearing submissions, not all traffic. /health/live is a process-liveness check that always returns 200. The NATS probe verifies the client connection is up; it does not re-validate that the JetStream stream/consumer still exists, so a stream deleted after startup is not currently reflected in readiness.
Usage
See the usage guide for the full HTTP and NATS API: submitting emails (plain/HTML, inline/named/remote MJML, attachments, batches), idempotency and correlation ids, listing emails and lifecycle events, subscribing to events over webhook or NATS, and the request/response shapes and limits.
A minimal submission:
curl -X POST http://localhost:3000/emails \
-H "Content-Type: application/json" \
-d '{
"sender": "noreply@example.com",
"recipients": [{ "kind": "to", "address": "alice@example.com" }],
"subject": "Welcome",
"body": { "kind": "plain", "text": "Hello Alice" }
}'
# => {"id":"018f4e3c-2d1a-7b3c-8f00-1234567890ab"}
Configuration
All configuration is done via environment variables.
General
| Variable | Description | Default |
|---|---|---|
CATAPULTE_GC_SWEEP_INTERVAL_SECS | Interval in seconds between garbage collection sweeps | 3600 |
CATAPULTE_GC_GRACE_PERIOD_SECS | Minimum age for data to be eligible for garbage collection | 3600 |
Storage Backend
| Variable | Description | Default |
|---|---|---|
CATAPULTE_STORAGE_BACKEND | Storage engine: sqlite or postgres | sqlite |
CATAPULTE_SQLITE_URL | SQLite connection string (e.g. sqlite://catapulte.db) | - |
CATAPULTE_POSTGRES_URL | Postgres connection string (e.g. postgres://user:pass@host/db) | - |
CATAPULTE_POSTGRES_MAX_CONNECTIONS | Maximum size of the Postgres connection pool | 10 |
CATAPULTE_POSTGRES_ACQUIRE_TIMEOUT_SECS | Seconds to wait for a free pooled connection before erroring | 30 |
Inbound Transports
HTTP
| Variable | Description | Default |
|---|---|---|
CATAPULTE_HTTP_ADDRESS | Bind address for the HTTP server | - |
CATAPULTE_HTTP_API_KEY | Static bearer token required on all HTTP routes except health checks; unset = no auth | - |
CATAPULTE_HTTP_REQUEST_TIMEOUT_SECS | Request deadline for read/list and health endpoints; the email submit routes are exempt so large attachment uploads over slow links are not truncated | 30 |
Authentication: set CATAPULTE_HTTP_API_KEY to a secret value and include Authorization: Bearer <key> on every request. The health endpoints (/health/live, /health/ready) are always public regardless of this setting. When the variable is unset the API is unauthenticated — suitable only when running behind a trusted network boundary.
NATS
Inbound NATS is enabled by setting CATAPULTE_INBOUND_NATS_URL. When set, _STREAM, _SUBJECT, and _CONSUMER are required.
| Variable | Description | Default |
|---|---|---|
CATAPULTE_INBOUND_NATS_URL | NATS server URL (on/off switch, leave unset to disable) | - |
CATAPULTE_INBOUND_NATS_STREAM | (Required) JetStream stream name | - |
CATAPULTE_INBOUND_NATS_SUBJECT | (Required) Subject for fire-and-forget submissions | - |
CATAPULTE_INBOUND_NATS_CONSUMER | (Required) Pull consumer name | - |
CATAPULTE_INBOUND_NATS_ACK_WAIT_SECS | Redelivery timeout | 30 |
CATAPULTE_INBOUND_NATS_MAX_DELIVER | Maximum delivery attempts | 5 |
CATAPULTE_INBOUND_NATS_BACKOFF_SECS | Comma-separated retry backoff steps in seconds | 1,5,30 |
Outbound SMTP (Senders)
Multiple SMTP servers can be configured for routing.
CATAPULTE_SENDERS: Comma-separated list of sender names (e.g.primary,secondary).
For each {NAME} in the list:
| Variable | Description | Default |
|---|---|---|
CATAPULTE_SENDER_{NAME}_HOST | (Required) SMTP hostname | - |
CATAPULTE_SENDER_{NAME}_PORT | SMTP port | 587 |
CATAPULTE_SENDER_{NAME}_USERNAME | SMTP username | - |
CATAPULTE_SENDER_{NAME}_PASSWORD | SMTP password | - |
CATAPULTE_SENDER_{NAME}_TLS | starttls, tls, or none | starttls |
CATAPULTE_SENDER_{NAME}_PRIORITY | Lower numbers are tried first | 100 |
CATAPULTE_SENDER_{NAME}_QUOTA_COUNT | Max emails allowed in range | - |
CATAPULTE_SENDER_{NAME}_QUOTA_RANGE | hourly, daily, weekly, or monthly | - |
CATAPULTE_SENDER_{NAME}_MATCH_DOMAIN | Optional domain to strictly route traffic for | - |
Connection pooling: each configured sender reuses its SMTP connections instead of dialing the server for every message, so the per-send connection setup cost is paid once and then amortised. Expect each sender to keep up to one idle connection open per process for about a minute between sends. Pool size and idle timeout are not configurable yet (there is no concurrent in-flight sending today that would make a larger pool useful); the knobs will arrive with intra-worker concurrency. If a pooled connection was dropped by the server while idle, the next send on it fails and is retried through the normal queue retry and alternate-sender fallback.
Email Queue
| Variable | Description | Default |
|---|---|---|
CATAPULTE_QUEUE_BACKEND | storage, memory, or nats | storage |
NATS Queue (if backend is nats)
| Variable | Description | Default |
|---|---|---|
CATAPULTE_QUEUE_URL | (Required) NATS server URL | - |
CATAPULTE_QUEUE_STREAM | JetStream stream name | CATAPULTE_EMAILS |
CATAPULTE_QUEUE_SUBJECT | JetStream subject | catapulte.emails.queued |
CATAPULTE_QUEUE_CONSUMER | Pull consumer name | catapulte-worker |
CATAPULTE_QUEUE_ACK_WAIT_SECS | Redelivery timeout | 30 |
CATAPULTE_QUEUE_MAX_DELIVER | Maximum delivery attempts | 3 |
CATAPULTE_QUEUE_BACKOFF | Comma-separated retry backoff steps in seconds | 30,60,120 |
Worker
| Variable | Description | Default |
|---|---|---|
CATAPULTE_WORKER_CONCURRENCY | Maximum number of emails the worker processes concurrently | 1 |
Choosing a safe concurrency value. Every in-flight send touches both the DB (event publish, ack/nack, set_attachments) and an SMTP connection, so the practical ceiling is min(SMTP pool size = 10, CATAPULTE_POSTGRES_MAX_CONNECTIONS) with headroom left for the HTTP submit path and background GC. A formula like concurrency = pool_size - 2 is a reasonable starting point.
SQLite caveat. SQLite uses a single connection (max_connections(1)), so raising concurrency above 1 just serializes all DB calls on that one connection and risks acquire timeouts rather than improving throughput. Only raise CATAPULTE_WORKER_CONCURRENCY when using Postgres, and raise CATAPULTE_POSTGRES_MAX_CONNECTIONS to match.
Quota note. Sender quotas are counted from committed Sent events and are checked before sending. With concurrency > 1 the read-to-send window widens, so a quota may be overshot by up to ~concurrency before the next event is committed. This is accepted: quotas are best-effort and eventually consistent by design.
Event Publishers (Observability)
| Variable | Description | Default |
|---|---|---|
CATAPULTE_WEBHOOK_URL | URL to POST lifecycle events to | - |
CATAPULTE_WEBHOOK_TIMEOUT_MS | Webhook call timeout | 5000 |
CATAPULTE_NATS_EVENTS_URL | NATS server for event publishing | - |
CATAPULTE_NATS_EVENTS_SUBJECT | Subject for lifecycle events | catapulte.lifecycle |
Template Management
Template Resolver
| Variable | Description | Default |
|---|---|---|
CATAPULTE_RESOLVER_ALLOWED_DOMAINS | Allowed domains for remote MJML fetching | - |
CATAPULTE_RESOLVER_TEMPLATES_DIR | Directory containing .mjml templates | - |
CATAPULTE_RESOLVER_TOKENS | Comma-separated names of auth entries (e.g. github,gitlab); absent or empty means no auth | - |
CATAPULTE_RESOLVER_TOKEN_<NAME>_HOST | Exact host the named entry's token is attached to (must also be in ALLOWED_DOMAINS) | - |
CATAPULTE_RESOLVER_TOKEN_<NAME>_BEARER_TOKEN | (Optional) Sent as Authorization: Bearer <token> only to the matching host; treated as secret, never logged | - |
CATAPULTE_RESOLVER_TOKEN_<NAME>_HEADERS | Comma-separated list of header names to attach to requests to the matching host (e.g. Accept,PRIVATE-TOKEN) | - |
CATAPULTE_RESOLVER_TOKEN_<NAME>_HEADER_<FRAGMENT>_VALUE | Value for the named header; FRAGMENT is the header name uppercased with - replaced by _ (e.g. ACCEPT, PRIVATE_TOKEN); treated as secret, never logged | - |
Each auth entry must configure at least a bearer token or one header. Defining a host with neither is a configuration error.
Examples
Private GitHub raw content via bearer token plus a custom Accept header:
CATAPULTE_RESOLVER_TOKENS=github
CATAPULTE_RESOLVER_TOKEN_GITHUB_HOST=api.github.com
CATAPULTE_RESOLVER_TOKEN_GITHUB_BEARER_TOKEN=ghp_xxxx
CATAPULTE_RESOLVER_TOKEN_GITHUB_HEADERS=Accept
CATAPULTE_RESOLVER_TOKEN_GITHUB_HEADER_ACCEPT_VALUE=application/vnd.github.raw
GitLab via a PRIVATE-TOKEN header and no bearer:
CATAPULTE_RESOLVER_TOKENS=gitlab
CATAPULTE_RESOLVER_TOKEN_GITLAB_HOST=gitlab.com
CATAPULTE_RESOLVER_TOKEN_GITLAB_HEADERS=PRIVATE-TOKEN
CATAPULTE_RESOLVER_TOKEN_GITLAB_HEADER_PRIVATE_TOKEN_VALUE=glpat-xxxx
MJML Include Loader
| Variable | Description | Default |
|---|---|---|
CATAPULTE_INCLUDE_LOADER_FS_ROOT | Local root for <mj-include> | - |
CATAPULTE_INCLUDE_LOADER_HTTP_ALLOW | Allowed origins for HTTP includes | - |
CATAPULTE_INCLUDE_LOADER_HTTP_DENY | Blocked origins for HTTP includes | - |
Attachments
Attachment Store
Catapulte supports storing attachments on the local filesystem (fs, default), in any S3-compatible object store such as MinIO or Cloudflare R2 (s3), or in Redis (redis). The garbage collector sweeps all backends, removing orphaned objects older than the configured grace period.
| Variable | Description | Default |
|---|---|---|
CATAPULTE_ATTACHMENT_BACKEND | Attachment backend: fs, s3, or redis | fs |
CATAPULTE_ATTACHMENT_FS_ROOT | Directory for attachment storage (when backend is fs) | - |
CATAPULTE_ATTACHMENT_S3_ENDPOINT | (Required) S3-compatible endpoint URL (e.g. http://localhost:9000 for MinIO) | - |
CATAPULTE_ATTACHMENT_S3_REGION | AWS region or region hint for the endpoint | us-east-1 |
CATAPULTE_ATTACHMENT_S3_BUCKET | (Required) Bucket name | - |
CATAPULTE_ATTACHMENT_S3_ACCESS_KEY_ID | (Required) Access key ID | - |
CATAPULTE_ATTACHMENT_S3_SECRET_ACCESS_KEY | (Required) Secret access key | - |
CATAPULTE_ATTACHMENT_S3_PATH_STYLE | Use path-style addressing (keep true for MinIO and most self-hosted gateways) | true |
CATAPULTE_ATTACHMENT_S3_PREFIX | Object key prefix / folder within the bucket | - |
CATAPULTE_ATTACHMENT_REDIS_URL | (Required) Redis connection URL (e.g. redis://localhost:6379, or rediss:// for TLS) | - |
CATAPULTE_ATTACHMENT_REDIS_PREFIX | Key prefix / namespace for stored blobs | - |
Attachment Fetcher
| Variable | Description | Default |
|---|---|---|
CATAPULTE_ATTACHMENT_FETCHER_ALLOWED_DOMAINS | Allowed domains for fetching | - |
CATAPULTE_ATTACHMENT_FETCHER_ALLOW_HTTP | Allow non-HTTPS fetches | false |
CATAPULTE_ATTACHMENT_FETCHER_MAX_BYTES | Max size per attachment | 25MiB |
CATAPULTE_ATTACHMENT_FETCHER_FETCH_TIMEOUT_MS | Fetch timeout | 30000 |
Observability (OTLP Tracing and Metrics)
All variables accept a CATAPULTE_OTEL_ prefix that takes precedence over the standard OTEL_* equivalents when both are set.
A ready-to-run example wiring Catapulte to an OpenTelemetry Collector (with the spanmetrics connector) and Prometheus lives at compose/observability.yml; see compose/otel-collector.yaml for the collector pipeline.
Traces
| Variable | OTEL_* fallback | Description | Default |
|---|---|---|---|
CATAPULTE_OTEL_TRACES_EXPORTER | OTEL_TRACES_EXPORTER | Traces backend: otlp to enable export, none to disable | none |
CATAPULTE_OTEL_EXPORTER_OTLP_PROTOCOL | OTEL_EXPORTER_OTLP_PROTOCOL | Wire protocol: grpc or http/protobuf | grpc |
CATAPULTE_OTEL_EXPORTER_OTLP_ENDPOINT | OTEL_EXPORTER_OTLP_ENDPOINT | (Required when traces enabled) Collector endpoint URL (e.g. http://collector:4317) | - |
CATAPULTE_OTEL_EXPORTER_OTLP_HEADERS | OTEL_EXPORTER_OTLP_HEADERS | Additional headers sent with each export request, k=v,k=v format | - |
CATAPULTE_OTEL_SERVICE_NAME | OTEL_SERVICE_NAME | service.name resource attribute | catapulte |
CATAPULTE_OTEL_SERVICE_INSTANCE_ID | OTEL_SERVICE_INSTANCE_ID | service.instance.id resource attribute — distinguishes replicas in metrics/traces | $HOSTNAME, else a random UUID |
The service.version resource attribute is always set to the binary's compiled-in crate version. service.instance.id is what keeps each replica's traces and gauge time-series distinct when you run more than one instance.
Metrics
Catapulte emits gauges over OTLP. RED metrics (request rate, error rate, duration) are collector-derived from traces; the application only pushes application-level gauges directly.
| Variable | Description | Default |
|---|---|---|
CATAPULTE_OTEL_METRICS_EXPORTER | Metrics backend: otlp to enable export, none to disable | none |
CATAPULTE_OTEL_METRIC_EXPORT_INTERVAL_SECS | How often the sampler pushes gauges to the collector (seconds). The standard OTEL_METRIC_EXPORT_INTERVAL is intentionally not aliased — it uses milliseconds which creates a unit mismatch | 60 |
When metrics are enabled the endpoint, protocol, and headers are reused from the traces configuration (CATAPULTE_OTEL_EXPORTER_OTLP_*). No separate metrics endpoint variable is needed.
Emitted gauges:
| Metric | Labels | Description |
|---|---|---|
catapulte.queue.pending | backend (sqlite, postgres, memory, nats) | Number of email queue entries eligible to be claimed |
catapulte.sender.sent_in_range | sender | Emails sent by this sender within its quota window |
catapulte.sender.quota_limit | sender | Configured quota count for the sender (omitted when no quota is set) |
Out of scope (for now)
Bounce and complaint ingestion, scheduled sends, recipient suppression lists, multi-tenant auth. Listed so they aren't mistaken for missing stories.
License
Catapulte is licensed under the GNU Affero General Public License v3.0. See LICENSE.