Skip to main content
StateSet Mail both sends and receives transactional email and drives automated marketing campaigns — profiles, lists, segments, templates, flows, opens, clicks, list-unsubscribe — on top of any open-source mail server. The included Docker stack uses Stalwart Mail Server for a fully-OSS pipeline. Agents call a small JSON API; StateSet Mail handles the SMTP/IMAP plumbing, persistence, retries, bounce correlation, webhook delivery, template rendering, audience resolution, send throttling, and tracking so the agent doesn’t have to.
That is the whole integration for transactional mail. Everything below — threading, campaigns, bounce handling — happens on the other side of that call.
to, cc and bcc are arrays even for a single recipient, and either text or html must be present — a message with neither is rejected with a 400 rather than sent empty. Send an idempotency_key derived from the thing you are mailing about: a repeat within the retention window returns the original send rather than mailing the customer twice.
Written with Axum and Tokio, it runs as a single Rust binary against one SQLite database.

What it covers

Transactional email

Persistent SQLite-backed send queue with exponential-backoff retries, IMAP receive, bounce correlation, idempotency keys, and HMAC-signed webhooks.

Marketing automation

Profiles, static lists, saved-rule segments, Handlebars and MJML templates, campaigns with A/B variants, and event-triggered flows.

Agent inbox

A conversation layer with triage state — status, assignee, tags, read flags — plus FTS5 full-text search and attachment retrieval.

MCP server

Tools, resources, and prompts over stdio or Streamable HTTP. Every marketing and inbox operation is agent-callable.

Transactional core

  • Send via a persistent SQLite-backed queue with exponential-backoff retries — process restarts don’t lose mail.
  • Receive via an IMAP polling worker. New messages land in /v1/inbox/messages.
  • Bounce correlation — DSN replies are parsed, matched against the outbound Message-ID we assigned, and the original send is marked bounced. Hard (5.x) bounces are auto-added to the suppression list.
  • Webhooks for message.sent, message.failed, message.bounced, and inbox.received, signed with x-stateset-signature: sha256=<hex> and retried durably.
  • Tool schemas at /v1/tools in both OpenAI and Anthropic formats.
  • Idempotency keys so a retried tool call doesn’t double-send.

Marketing automation

  • Profiles — contacts with arbitrary JSON properties, consent state, and timezone.
  • Lists (static) and Segments (saved JSON rule trees over properties and the event log). Property rules compile down to a single SQL WHERE for O(matching) evaluation.
  • Templates — Handlebars merge tags for subject, html_body, text_body, and preheader. Validated at create time, rendered per recipient at send time. Templates also accept mjml_source and auto-compile to inline-styled responsive HTML.
  • Campaigns — one-shot broadcast to a list or segment, with a per-domain token bucket to avoid hammering a single MX. Live stats: sent, failed, suppressed, opens, unique opens, clicks, unique clicks, unsubscribes, bounces.
  • A/B testing — an optional variants array of {label, template_id, weight, subject_override?}. Recipients are hash-bucketed deterministically by (campaign_id, profile_id), so re-runs see the same assignment. Stats split per variant.
  • Flows — event-triggered automations (signup → send welcome → wait 24h → send tips). Multi-step, durable, and they survive restarts.
  • Open / click tracking — a 1×1 pixel plus a link rewriter that proxies through the service and writes to a tracking event log.
  • One-click unsubscribe — RFC 8058 List-Unsubscribe and List-Unsubscribe-Post headers plus a footer link. Honoring it suppresses the address and flips the profile’s consent to unsubscribed.
  • Suppression list — auto-populated from hard bounces, unsubscribes, and complaints. Checked at enqueue and at send time.
  • Hosted signup forms — public GET /f/:id renders an HTML form, POST /f/:id collects subscribers, with optional double opt-in. Admin CRUD under /v1/forms.
  • Engagement-based segments — an engagement leaf rule supporting opened | clicked | sent | bounced | unsubscribed × occurred | did_not_occur | count, optionally scoped to a specific campaign.
  • Time-series analyticsGET /v1/campaigns/:id/timeseries?metric=opens|clicks|sent|bounced|unsubscribed&bucket=hour|day.

Production hardening

  • HMAC-signed tracking URLs — every /t/o/, /t/c/, /t/u/ URL carries ?s=<16-byte hmac>. Unsigned or mismatched requests get a 404 with no DB writes, closing the open-redirect risk on click tracking.
  • Per-IP tracking rate limit — a token bucket (TRACKING_IP_PER_MIN) blocks pixel-fetch amplification. TRUST_PROXY_HOPS tells the tracking routes how far back to walk X-Forwarded-For; it defaults to 0 (socket peer only).
  • Transactional worker claims — the campaign and flow workers take SQLite’s RESERVED write lock before flipping status, so workers never double-fire a step.
  • Crash recovery — rows a worker claimed but never completed are reclaimed. The outbound worker reaps stale sending rows back to pending, mirroring the flow worker’s orphan sweep. Nothing is silently lost on restart.
  • Graceful shutdown — on SIGINT/SIGTERM the HTTP server drains, then every background worker is signalled and joined before exit.
  • SSRF-guarded webhooks — outbound webhook targets are validated at create time and re-resolved before each send. Loopback, private, link-local, and metadata addresses are refused, and redirects are disabled.
  • Send-time windowssend_window_start_hour, send_window_end_hour, and send_timezone on campaigns, plus timezone on profiles. Messages outside the window defer to the next local window start with no attempt recorded.
  • Activity log — an append-only table capturing (actor fingerprint, action, resource_type, resource_id, payload) on every mutating handler. The actor is sha256(api_key)[:8] — enough to attribute, not enough to recover the key.
  • ObservabilityGET /v1/admin/queues for per-queue status counters and GET /metrics for Prometheus text-format gauges.
This is a single-node design. The transactional worker claims prevent double-firing within one process, not across replicas.

Inbound webhook adapters

Each adapter verifies the source’s signature, then translates the payload into an event that drives flows and segments:

Web dashboard

/dashboard is a three-pane Gmail-like UI — sidebar, list, detail — powered by HTMX, so clicking a message swaps the detail pane without a full page reload. Tailwind via CDN, no JS build step. It covers the Inbox (bounces get a red badge; opening a message marks it seen), Sent (the full outbound queue with status badges, attempt counts, SMTP response codes, and the rendered HTML in a sandboxed iframe), Compose, and an Overview home with live counters and a recent-activity feed. Auth is an HMAC-signed session cookie: POST /dashboard/login validates the API key against STATESET_MAIL_API_KEYS and sets a 7-day HttpOnly cookie (Secure when PUBLIC_BASE_URL is HTTPS). The actor fingerprint matches the activity log, so dashboard-initiated actions show up attributed.

Next steps

Quickstart

Bring up the stack and round-trip a message.

Conversations

The agent inbox: triage, threading, and search.

MCP server

Tools, resources, and prompts for agents.

Configuration

Every environment variable.
Last modified on August 29, 2026