Skip to main content

StateSet Mail

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.
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, with HMAC-SHA256 signatures and durable retries.
  • 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.