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.succeeded event 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.failed event 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 up Starts Catapulte with an in-memory database and Mailpit for local SMTP testing.
  • SQLite (Persistent): docker-compose -f compose/sqlite.yml up Starts Catapulte with a persistent SQLite database.
  • Postgres & NATS: docker-compose -f compose/postgres-nats.yml up A more robust setup using Postgres for storage and NATS for the email queue and events.
  • MinIO (S3 attachments): docker-compose -f compose/minio.yml up Runs Catapulte with a local MinIO instance as the S3-compatible attachment backend.
  • Redis (attachments): docker-compose -f compose/redis.yml up Runs Catapulte with a Redis instance as the attachment backend.
  • Observability (OpenTelemetry): docker-compose -f compose/observability.yml up Exports traces and gauge metrics over OTLP to an OpenTelemetry Collector; the collector derives RED metrics from spans (spanmetrics) and exposes everything to Prometheus at http://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

VariableDescriptionDefault
CATAPULTE_GC_SWEEP_INTERVAL_SECSInterval in seconds between garbage collection sweeps3600
CATAPULTE_GC_GRACE_PERIOD_SECSMinimum age for data to be eligible for garbage collection3600

Storage Backend

VariableDescriptionDefault
CATAPULTE_STORAGE_BACKENDStorage engine: sqlite or postgressqlite
CATAPULTE_SQLITE_URLSQLite connection string (e.g. sqlite://catapulte.db)-
CATAPULTE_POSTGRES_URLPostgres connection string (e.g. postgres://user:pass@host/db)-
CATAPULTE_POSTGRES_MAX_CONNECTIONSMaximum size of the Postgres connection pool10
CATAPULTE_POSTGRES_ACQUIRE_TIMEOUT_SECSSeconds to wait for a free pooled connection before erroring30

Inbound Transports

HTTP

VariableDescriptionDefault
CATAPULTE_HTTP_ADDRESSBind address for the HTTP server-
CATAPULTE_HTTP_API_KEYStatic bearer token required on all HTTP routes except health checks; unset = no auth-
CATAPULTE_HTTP_REQUEST_TIMEOUT_SECSRequest deadline for read/list and health endpoints; the email submit routes are exempt so large attachment uploads over slow links are not truncated30

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.

VariableDescriptionDefault
CATAPULTE_INBOUND_NATS_URLNATS 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_SECSRedelivery timeout30
CATAPULTE_INBOUND_NATS_MAX_DELIVERMaximum delivery attempts5
CATAPULTE_INBOUND_NATS_BACKOFF_SECSComma-separated retry backoff steps in seconds1,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:

VariableDescriptionDefault
CATAPULTE_SENDER_{NAME}_HOST(Required) SMTP hostname-
CATAPULTE_SENDER_{NAME}_PORTSMTP port587
CATAPULTE_SENDER_{NAME}_USERNAMESMTP username-
CATAPULTE_SENDER_{NAME}_PASSWORDSMTP password-
CATAPULTE_SENDER_{NAME}_TLSstarttls, tls, or nonestarttls
CATAPULTE_SENDER_{NAME}_PRIORITYLower numbers are tried first100
CATAPULTE_SENDER_{NAME}_QUOTA_COUNTMax emails allowed in range-
CATAPULTE_SENDER_{NAME}_QUOTA_RANGEhourly, daily, weekly, or monthly-
CATAPULTE_SENDER_{NAME}_MATCH_DOMAINOptional 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

VariableDescriptionDefault
CATAPULTE_QUEUE_BACKENDstorage, memory, or natsstorage

NATS Queue (if backend is nats)

VariableDescriptionDefault
CATAPULTE_QUEUE_URL(Required) NATS server URL-
CATAPULTE_QUEUE_STREAMJetStream stream nameCATAPULTE_EMAILS
CATAPULTE_QUEUE_SUBJECTJetStream subjectcatapulte.emails.queued
CATAPULTE_QUEUE_CONSUMERPull consumer namecatapulte-worker
CATAPULTE_QUEUE_ACK_WAIT_SECSRedelivery timeout30
CATAPULTE_QUEUE_MAX_DELIVERMaximum delivery attempts3
CATAPULTE_QUEUE_BACKOFFComma-separated retry backoff steps in seconds30,60,120

Worker

VariableDescriptionDefault
CATAPULTE_WORKER_CONCURRENCYMaximum number of emails the worker processes concurrently1

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)

VariableDescriptionDefault
CATAPULTE_WEBHOOK_URLURL to POST lifecycle events to-
CATAPULTE_WEBHOOK_TIMEOUT_MSWebhook call timeout5000
CATAPULTE_NATS_EVENTS_URLNATS server for event publishing-
CATAPULTE_NATS_EVENTS_SUBJECTSubject for lifecycle eventscatapulte.lifecycle

Template Management

Template Resolver

VariableDescriptionDefault
CATAPULTE_RESOLVER_ALLOWED_DOMAINSAllowed domains for remote MJML fetching-
CATAPULTE_RESOLVER_TEMPLATES_DIRDirectory containing .mjml templates-
CATAPULTE_RESOLVER_TOKENSComma-separated names of auth entries (e.g. github,gitlab); absent or empty means no auth-
CATAPULTE_RESOLVER_TOKEN_<NAME>_HOSTExact 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>_HEADERSComma-separated list of header names to attach to requests to the matching host (e.g. Accept,PRIVATE-TOKEN)-
CATAPULTE_RESOLVER_TOKEN_<NAME>_HEADER_<FRAGMENT>_VALUEValue 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

VariableDescriptionDefault
CATAPULTE_INCLUDE_LOADER_FS_ROOTLocal root for <mj-include>-
CATAPULTE_INCLUDE_LOADER_HTTP_ALLOWAllowed origins for HTTP includes-
CATAPULTE_INCLUDE_LOADER_HTTP_DENYBlocked 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.

VariableDescriptionDefault
CATAPULTE_ATTACHMENT_BACKENDAttachment backend: fs, s3, or redisfs
CATAPULTE_ATTACHMENT_FS_ROOTDirectory 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_REGIONAWS region or region hint for the endpointus-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_STYLEUse path-style addressing (keep true for MinIO and most self-hosted gateways)true
CATAPULTE_ATTACHMENT_S3_PREFIXObject 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_PREFIXKey prefix / namespace for stored blobs-

Attachment Fetcher

VariableDescriptionDefault
CATAPULTE_ATTACHMENT_FETCHER_ALLOWED_DOMAINSAllowed domains for fetching-
CATAPULTE_ATTACHMENT_FETCHER_ALLOW_HTTPAllow non-HTTPS fetchesfalse
CATAPULTE_ATTACHMENT_FETCHER_MAX_BYTESMax size per attachment25MiB
CATAPULTE_ATTACHMENT_FETCHER_FETCH_TIMEOUT_MSFetch timeout30000

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

VariableOTEL_* fallbackDescriptionDefault
CATAPULTE_OTEL_TRACES_EXPORTEROTEL_TRACES_EXPORTERTraces backend: otlp to enable export, none to disablenone
CATAPULTE_OTEL_EXPORTER_OTLP_PROTOCOLOTEL_EXPORTER_OTLP_PROTOCOLWire protocol: grpc or http/protobufgrpc
CATAPULTE_OTEL_EXPORTER_OTLP_ENDPOINTOTEL_EXPORTER_OTLP_ENDPOINT(Required when traces enabled) Collector endpoint URL (e.g. http://collector:4317)-
CATAPULTE_OTEL_EXPORTER_OTLP_HEADERSOTEL_EXPORTER_OTLP_HEADERSAdditional headers sent with each export request, k=v,k=v format-
CATAPULTE_OTEL_SERVICE_NAMEOTEL_SERVICE_NAMEservice.name resource attributecatapulte
CATAPULTE_OTEL_SERVICE_INSTANCE_IDOTEL_SERVICE_INSTANCE_IDservice.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.

VariableDescriptionDefault
CATAPULTE_OTEL_METRICS_EXPORTERMetrics backend: otlp to enable export, none to disablenone
CATAPULTE_OTEL_METRIC_EXPORT_INTERVAL_SECSHow 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 mismatch60

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:

MetricLabelsDescription
catapulte.queue.pendingbackend (sqlite, postgres, memory, nats)Number of email queue entries eligible to be claimed
catapulte.sender.sent_in_rangesenderEmails sent by this sender within its quota window
catapulte.sender.quota_limitsenderConfigured 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.