Skip to content

Latest commit

 

History

History
322 lines (251 loc) · 10.5 KB

File metadata and controls

322 lines (251 loc) · 10.5 KB

Using Catapulte

This guide shows how to use a running Catapulte instance: submitting emails, reading their state, and subscribing to delivery events. For how to run and configure a server (storage, SMTP senders, queue, observability), see the readme.

Catapulte accepts an email, returns a tracking id immediately, and owns SMTP delivery, routing, retries, and lifecycle events from there.

  • Base URL in the examples below: http://localhost:3000.
  • All request/response bodies are JSON unless noted (attachment uploads may use multipart/form-data).

Authentication

If the server sets CATAPULTE_HTTP_API_KEY, every endpoint except the health probes requires a bearer token:

curl http://localhost:3000/emails \
  -H "Authorization: Bearer $CATAPULTE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ ... }'

A missing or wrong token returns 401. When the key is unset, the API is unauthenticated (only safe behind a trusted network boundary).

Submitting an email

POST /emails accepts one email and returns its tracking id:

{ "id": "018f4e3c-2d1a-7b3c-8f00-1234567890ab" }

The id is a UUIDv7. The email is queued; delivery happens asynchronously (watch lifecycle events to observe the outcome).

Required fields

Field Type Notes
sender string a valid email address
recipients array non-empty; each { "kind": "to" | "cc" | "bcc", "address": "<email>" }
body object a body variant (tagged by kind)

Optional fields

Field Type Notes
subject string
idempotency_key string retry-safe submission (see Idempotency)
correlation_id string echoed back on lifecycle events; use it to correlate without a synchronous id
variables object template variables; defaults to {}
attachments array see Attachments; defaults to []

Body variants

body is a tagged union on kind:

kind Fields Description
plain text and/or html (at least one) a ready-made plain-text and/or HTML body
mjml_inline source raw MJML source, rendered with variables
mjml_named name a template pre-registered on the server, rendered with variables
mjml_remote url MJML fetched over HTTP (supports mj-include), rendered with variables

Examples

Plain text + HTML:

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", "html": "<p>Hello Alice</p>" }
  }'

Inline MJML with variables:

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": "Hi {{ name }}",
    "body": { "kind": "mjml_inline", "source": "<mjml><mj-body><mj-text>Hi {{ name }}</mj-text></mj-body></mjml>" },
    "variables": { "name": "Alice" }
  }'

A pre-registered template (mjml_named) or a remote one (mjml_remote with a url) follow the same shape, swapping the body object.

Attachments

Up to 10 attachments per email. Each is either inline base64 or a remote URL (exactly one):

"attachments": [
  { "filename": "invoice.pdf", "content_type": "application/pdf", "inline_base64": "<base64>" },
  { "filename": "logo.png",    "content_type": "image/png",       "url": "https://cdn.example.com/logo.png" }
]

Remote URLs are fetched server-side subject to the operator's allow-list; an unreachable or disallowed URL fails the submission with 400.

Streaming uploads (multipart)

To avoid base64 overhead for large files, submit multipart/form-data with one envelope JSON part (the email without the attachments field) and one attachment part per file:

curl -X POST http://localhost:3000/emails \
  -F 'envelope={"sender":"noreply@example.com","recipients":[{"kind":"to","address":"alice@example.com"}],"subject":"Report","body":{"kind":"plain","text":"see attached"}};type=application/json' \
  -F 'attachment=@./report.pdf;type=application/pdf'

Each attachment part's filename and content type come from its Content-Disposition/Content-Type. The submit routes are exempt from the HTTP request timeout so large uploads over slow links are not truncated.

Idempotency

Pass an idempotency_key to make retries safe. If a submission reuses a key that already exists, Catapulte returns the existing email's id (200) and does not send a second copy.

Submitting a batch

POST /emails/batch accepts up to 100 emails and reports per-email outcomes (partial acceptance — valid emails are accepted even if others are rejected):

curl -X POST http://localhost:3000/emails/batch \
  -H "Content-Type: application/json" \
  -d '{
    "emails": [
      { "sender": "noreply@example.com", "recipients": [{ "kind": "to", "address": "a@example.com" }], "body": { "kind": "plain", "text": "hi" } },
      { "sender": "noreply@example.com", "recipients": [], "body": { "kind": "plain", "text": "hi" } }
    ]
  }'
{
  "results": [
    { "status": "accepted", "id": "018f4e3c-2d1a-7b3c-8f00-1234567890ab" },
    { "status": "rejected", "error": "recipients must not be empty" }
  ]
}

results is positional (aligned to the input emails). A per-email validation error is reported as rejected; an infrastructure failure aborts the whole batch with 500. Batch items use the inline/remote attachment form (no multipart).

Listing emails

GET /emails returns your submitted emails, newest-first, paginated.

Query param Notes
status queued | sent | failed
recipient filter by recipient address
template filter by named MJML template name; only matches emails submitted with kind: mjml_named
id exact email id (UUID)
after_ms, before_ms created-at bounds, Unix epoch ms
limit default 20, max 100
offset default 0
curl "http://localhost:3000/emails?status=failed&limit=50"
{
  "emails": [
    {
      "id": "018f4e3c-2d1a-7b3c-8f00-1234567890ab",
      "idempotency_key": null,
      "subject": "Welcome",
      "sender": "noreply@example.com",
      "recipients": [{ "kind": "to", "address": "alice@example.com" }],
      "created_at_ms": 1700000000000,
      "status": "sent"
    }
  ],
  "limit": 20,
  "offset": 0
}

Lifecycle events

Every email moves through a sequence of events. You can poll them or subscribe to them in real time.

Reading events

  • GET /events — across all emails. Filters: email_id, event_type, sender_name (the upstream SMTP server), error_class, after_ms, before_ms, limit, offset.
  • GET /emails/{id}/events — events for one email (same filters).

error_class is validated against the vocabulary below — an unknown value returns 400. event_type and sender_name are free-form.

curl "http://localhost:3000/emails/018f4e3c-2d1a-7b3c-8f00-1234567890ab/events"
{
  "events": [
    {
      "id": "018f...",
      "email_id": "018f4e3c-2d1a-7b3c-8f00-1234567890ab",
      "event_type": "delivery.succeeded",
      "payload": { "sender_name": "primary", "correlation_id": "order-12345" },
      "sender_name": "primary",
      "error_class": null,
      "created_at_ms": 1700000000050
    }
  ],
  "limit": 20,
  "offset": 0
}

Subscribing (webhook / NATS)

When the operator configures a webhook URL or a NATS subject, Catapulte pushes each event as JSON:

{
  "event_type": "delivery.succeeded",
  "email_id": "018f4e3c-2d1a-7b3c-8f00-aabbccddeeff",
  "payload": { "sender_name": "primary", "correlation_id": "order-12345" }
}
event_type Meaning payload fields
queued accepted and enqueued correlation_id
sending a delivery attempt is starting attempt, correlation_id
delivery.succeeded accepted by the upstream SMTP server sender_name, correlation_id
retrying attempt failed, will retry attempt, reason, error_class, sender_name, correlation_id
delivery.failed retries exhausted attempt, reason, error_class, sender_name, correlation_id

attempt counts from 1; sender_name/correlation_id may be null. error_class is present on retrying / delivery.failed only, and is one of template_resolve, template_interpolate, template_render, attachment, delivery, routing. (The pushed payload has no timestamp; the stored events from GET /events carry created_at_ms.) Webhooks are retried a few times on a non-2xx response.

Submitting over NATS (fire-and-forget)

If the operator enables the NATS inbound transport, publish the same JSON as POST /emails to the configured subject. NATS submission is fire-and-forget: there is no synchronous tracking id. Supply a correlation_id in the payload and observe the outcome via lifecycle events.

Listing senders

GET /senders reports the configured upstream SMTP senders and their usage within the current quota window:

{
  "senders": [
    { "name": "primary", "sent_in_range": 42, "failed_in_range": 3, "quota": { "count": 1000, "range": "daily" } }
  ]
}

quota is null when none is configured; range is hourly | daily | weekly | monthly.

Health

Always public (never require the API key):

  • GET /health/live200 {"status":"ok"} — process liveness.
  • GET /health/ready200 {"status":"ok"} when storage and the queue are reachable, 503 {"status":"unavailable"} otherwise.

Errors

Errors return a minimal JSON body (details are logged server-side, not returned):

{ "error": "invalid request" }
Status When
400 malformed JSON/multipart, validation failure (sender/recipients/body/attachment), bad UUID, unreachable/disallowed remote attachment, batch over 100
401 missing/invalid bearer token
500 storage / queue / attachment-store failure

Limits

Limit Value
Max request body 352 MiB
Max envelope JSON (multipart envelope part) 1 MiB
Max size per attachment 25 MiB
Max attachments per email 10
Max emails per batch 100
List page size default 20, max 100