Email campaigns on your own servers, from a small site to a large sender.

Postwind runs email campaigns on your own servers, from a small site to a large sender. One binary and ClickHouse; add nodes when you need more. No third-party provider and no per-message fees. Your code and your agents drive it through an HTTP API and MCP.

Linux x86_64 and aarch64 · Docker · Helm · Docker Compose

Pre-release: the roadmap is implemented; no release has been published yet.

tools/call · launch_campaign
{
  "name": "launch_campaign",
  "arguments": {
    "campaign_id": "01900000-0000-7000-8000-000000000030",
    "confirm": true
  }
}
{
  "structuredContent": {
    "outcome": "launched",
    "preflight": null,
    "campaign": {
      "status": "sending",
      "total_recipients": 12840
    }
  }
}

Who it is for

Backend and DevOps teams

You need campaign email from your own infrastructure, with no third-party sending provider in the loop. Postwind is one static binary with a TOML config, a ClickHouse database and an OpenAPI contract to generate your client from.

Teams building AI agents

You need an MCP server your agents can use to run campaigns. Postwind exposes every API operation as an MCP tool over Streamable HTTP, with the same keys and scopes as the API, and asks for confirm: true before anything irreversible.

Technical founders

You are choosing a self-hosted option and will read the code and the spec before you commit. Postwind ships with a written specification, a glossary and architecture decision records. The OpenAPI document is generated from the code.

One binary, six subservices

The config switches each subservice on or off, so the same binary runs as a monolith on one server or as a set of roles in a cluster. Typical cluster roles: api+mcp, web, delivery+smtp.inbound.

The postwind binary stores everything in ClickHouse, sends mail to the recipient's MX, and receives bounces and complaints back over inbound SMTP. postwind one static binary Core API MCP SMTP out SMTP in Web Recipient MX recipient's mail server ClickHouse entities · queue · events · stats SMTP outbound · direct / relay DSN · ARF → SMTP inbound reads · writes · leases The postwind binary stores everything in ClickHouse, sends mail to the recipient's MX, and receives bounces and complaints back over inbound SMTP. Recipient MX recipient's mail server SMTP outbound DSN ARF postwind one static binary Core API MCP SMTP out SMTP in Web ClickHouse entities · queue · events · stats

Core

Projects, keys, sending domains, lists, subscribers, templates, campaigns. The delivery engine: queue, shards, retries, limits.

API

HTTP JSON API under /api/v1 for your code. The OpenAPI 3.1 document is generated from the code and served at /api/openapi.json.

MCP

A Model Context Protocol server (Streamable HTTP) for agents. The same operations as the API, through the same use cases.

SMTP outbound

The delivery transport. direct finds the recipient’s MX and sends from an address in the IP pool assigned to the sending domain; relay hands mail to an external SMTP server.

SMTP inbound

Receives bounces (DSN), complaints (ARF) and mailto: unsubscribes on the bounce subdomain.

Web

Public pages and links: unsubscribe (including one-click, RFC 8058), subscription confirmation, open pixel, click redirect.

Config flags: api, mcp, web, smtp.inbound, delivery. MCP is served on the API listener at /mcp. Core and SMTP outbound run under the delivery flag.

Control it from code or from an agent

Every operation exists twice: as an HTTP endpoint and as an MCP tool with the same name as the endpoint’s operationId. Both call the same use case, so the rules live in one place. 57 operations, 57 tools.

A project key sees only its own project; an admin key passes project_id. Scopes: read, write, send, keys. Errors are problem+json (RFC 9457) with a stable code, the same code in the API and in the tool error.

HTTP API reference MCP tools reference

API

Create a draft, then launch it. Idempotency-Key makes a retry safe: a repeat with the same key and body within 24 h returns the first answer.
# 1. Create a campaign (a draft; nothing is sent yet)
curl -X POST http://localhost:8080/api/v1/projects/$PROJECT_ID/campaigns \
  -H "Authorization: Bearer pw_prj_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f1b8a3e-2c4d-4e5f-9a6b-0c1d2e3f4a5b" \
  -d '{
    "name": "Spring sale",
    "list_ids": ["01900000-0000-7000-8000-000000000010"],
    "template_id": "01900000-0000-7000-8000-000000000020"
  }'
HTTP/1.1 201 Created
Location: /api/v1/projects/.../campaigns/01900000-0000-7000-8000-000000000030

{
  "id": "01900000-0000-7000-8000-000000000030",
  "status": "draft",
  "name": "Spring sale",
  "list_ids": ["01900000-0000-7000-8000-000000000010"],
  "template_id": "01900000-0000-7000-8000-000000000020",
  "total_recipients": null,
  "launched_at": null,
  ...
}
# 2. Launch it
curl -X POST http://localhost:8080/api/v1/projects/$PROJECT_ID/campaigns/$CAMPAIGN_ID/launch \
  -H "Authorization: Bearer pw_prj_..." \
  -H "Idempotency-Key: 9d2e7c41-5b6a-4f7e-8c9d-1e2f3a4b5c6d"
HTTP/1.1 200 OK

{
  "id": "01900000-0000-7000-8000-000000000030",
  "status": "sending",
  "total_recipients": 12840,
  "template_version": 3,
  "launched_at": "2026-11-09T10:15:04Z",
  ...
}

MCP

The same launch as a tool call. Without confirm: true the call checks everything the launch checks, returns the preflight and changes nothing.
POST /mcp HTTP/1.1
Host: localhost:8080
Authorization: Bearer pw_prj_...
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "launch_campaign",
    "arguments": {
      "campaign_id": "01900000-0000-7000-8000-000000000030",
      "confirm": true
    }
  }
}
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "structuredContent": {
      "outcome": "launched",
      "effect": "...",
      "preflight": null,
      "campaign": {
        "id": "01900000-0000-7000-8000-000000000030",
        "status": "sending",
        "total_recipients": 12840,
        ...
      }
    },
    "content": [{ "type": "text", "text": "{ ... the same JSON ... }" }]
  }
}

Deliverability by default

A campaign sends only from a sending domain whose records have been verified. Postwind computes the records, applies them through your DNS provider or prints them for manual entry, and rechecks them on a schedule.

DKIM on every message

Each sending domain gets its own RSA 2048 key pair. The private key is stored encrypted and never returned by the API. Messages are signed rsa-sha256, relaxed/relaxed, including the List-Unsubscribe headers.

SPF, DKIM, DMARC and MX checked before the first send

Postwind resolves the records itself and marks the domain verified only when every required record is in place. A verified domain is rechecked every 6 hours by default; a record that disappears blocks new launches.

IP pools and PTR

Name your outgoing addresses in pools and assign a pool to each sending domain. The SPF record is built from the pool’s addresses. Verification checks that every address has a PTR record pointing at its hostname and that the hostname resolves back (FCrDNS).

Bounces and complaints handled

Inbound SMTP receives DSN bounces and ARF complaints on the bounce subdomain. A hard bounce or a complaint suppresses the address at once; repeated soft bounces can suppress it too. Deferred messages are retried at 1 m, 5 m, 15 m, 1 h, 4 h, 12 h and 24 h.

Per-destination rate limits

Sending is paced per outgoing address and recipient site. A rate limit or reputation reply from the receiver slows that pair down automatically. Warm-up of a new address is manual in v1: you raise the per-domain limits in the config as reputation grows.

Headers bulk senders are expected to have

Message-ID, Date, Feedback-ID, Precedence: bulk, List-Unsubscribe and List-Unsubscribe-Post. DMARC alignment holds in relaxed mode: From on the domain, MAIL FROM on its bounce subdomain.

No pool, no open port 25, no PTR? Use a relay transport instead: Postwind still signs with your DKIM key and still receives the bounces.

Unsubscribe that works

Unsubscribe is mandatory in Postwind, not a template option. Every campaign message carries the headers mail clients use and a link in the body. Postwind applies an unsubscribe at the moment it sends each message.

List-Unsubscribe and one-click

Every campaign message has List-Unsubscribe with an HTTPS link and a mailto: address, and List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058). A click on the mail client’s button unsubscribes without opening a page.

A link in every body

A template without an unsubscribe link gets an automatic footer, in HTML and in text. The subscriber chooses: this list, or everything from this sender.

Applied immediately

Suppression is checked before each message is sent, not only when the campaign is launched. Someone who unsubscribes while a campaign is running does not get its message.

The link does not expire

An unsubscribe link keeps working while the key that signed it stays in security.token_keys. The operator guide says to keep an old key there for about a year after rotation. Replying to the mailto: address works too, through inbound SMTP.

The same through the API

Unsubscribe, resubscribe with a recorded consent source, suppress, or erase a subscriber on request. Addresses that bounced or complained are never sent to again.

ClickHouse only

Entities, the delivery queue, events and statistics live in one ClickHouse database. Cluster nodes coordinate through leases in ClickHouse Keeper (KeeperMap tables). There is no Redis, no ZooKeeper, no etcd and no message broker to run.

Scale out without a broker

Several nodes on one database split a campaign into shards and take leases on them. One delivery is sent by one node; when a node dies, another picks up its shards.

Events, not counters

Every fact is an append-only event: delivery.sent, message.opened, campaign.launched. Statistics are materialized views over the events and can be rebuilt from them.

Operations you already know

Forward-only migrations, compatible with the previous binary for rolling updates. Backups are ClickHouse backups. Tested against ClickHouse 26.8 LTS.

One node is fine too

On one server, set coordination.mode = "local" and skip Keeper. The Docker Compose file runs Postwind and ClickHouse with Keeper on one host.

Load test: 1 million deliveries in 501 seconds on two nodes against a fake MX server, with no rate limits.

Runs where you run

A static binary for Linux, a distroless container image, a Helm chart and a Docker Compose file. One TOML config; any key can be overridden with an environment variable (POSTWIND__SECTION__KEY). postwind config check validates it before you start.

Binary

One static binary with no C dependencies, built for x86_64-unknown-linux-musl and aarch64-unknown-linux-musl. postwind --version prints the version and the git commit.

Docker

Built on gcr.io/distroless/static-debian13:nonroot, runs as UID 65532. Ports: 8080 API, MCP and metrics; 8081 and 443 public web; 25 inbound SMTP.

Helm

One Deployment per role (api, web, delivery) over an external ClickHouse. Pod Security Standard restricted; NET_BIND_SERVICE is the only added capability, for ports 25 and 443.

Docker Compose

Postwind and ClickHouse with Keeper on one host. docker compose up -d, then postwind keys create-admin for the first key.

Observability

/healthz, /readyz, Prometheus /metrics, JSON logs with a trace_id per request and per delivery.

Built-in TLS for public pages

The public web listener can issue and renew its own certificates through ACME (TLS-ALPN-01) for the public host and tracking hosts, with certificates shared between nodes in ClickHouse.

CPU requirement. TLS and DKIM run on a cryptography library that needs AES-NI, PCLMULQDQ, AVX, AVX2, BMI1 and ADX on x86_64 (Intel from Broadwell, AMD from Zen, roughly 2014 onward) or NEON, AES, PMULL and SHA-2 on aarch64 (Graviton, Ampere, Apple M, Raspberry Pi 5). On a CPU without them Postwind starts, but without the direct transport and without DKIM signing.

Download and install

Status: pre-release

Postwind is at draft v0.1. All nine roadmap phases, P0 to P8, are implemented and merged into main.

There is no release yet, and none is scheduled. No binaries, container images or version tags have been published. The specification is written in Russian; identifiers, API names, config keys, tool descriptions and the OpenAPI document are in English.

Postwind will be published as ready-made packages. The download page lists them.

  1. P0 Foundation
  2. P1 Tenancy
  3. P2 Audience
  4. P3 Templates
  5. P4 Domains & DNS
  6. P5 Delivery core
  7. P6 Public web
  8. P7 Inbound SMTP
  9. P8 Cluster & ops

Run it, send from it

Read the API and MCP docs, see what the first release will carry, and tell us what you are building.