Kannon πŸ’₯

CI

Kannon Logo

A Cloud Native SMTP mail sender for Kubernetes and modern infrastructure.

[!NOTE] Due to limitations of AWS, GCP, etc. on port 25, this project will not work on cloud providers that block port 25.

Table of Contents

Features

Planned:

Architecture

A single SendHTML / SendTemplate API call creates one Batch with N Deliveries (one per Recipient). Deliveries flow through the Pool, are built into Envelopes by the Dispatcher, and transmitted by the SMTPSender. See CONTEXT.md for the full shared language (Batch, Recipient, Delivery, Envelope, Domain, Template) and the per-Delivery outcome state machine (Validated β†’ Delivered / Bounced, plus Opened / Clicked engagement events).

Kannon is composed of several microservices and workers:

All components can be enabled/disabled via CLI flags or config.

See ARCHITECTURE.md for a full breakdown of modules, NATS streams, topics, consumers, and message flows.

flowchart TD
    subgraph Core
        API["API (Mailer / Admin / Stats / HZ)"]
        SMTPServer["SMTPServer (inbound DSN/bounce)"]
        SMTPSender["SMTPSender (outbound)"]
        Dispatcher["Dispatcher"]
        Validator["Validator"]
        Tracker["Tracker (open/click)"]
        Stats["Stats"]
    end
    DB[(PostgreSQL)]
    NATS[(NATS)]
    API <--> DB
    Dispatcher <--> DB
    SMTPSender <--> DB
    Validator <--> DB
    Stats <--> DB
    API <--> NATS
    SMTPSender <--> NATS
    Dispatcher <--> NATS
    SMTPServer <--> NATS
    Stats <--> NATS
    Validator <--> NATS
    Tracker <--> NATS

Quickstart

Prerequisites

Standalone Mode (Recommended for Development/Testing)

Run all Kannon components in a single process with embedded NATS (only PostgreSQL required):

git clone https://github.com/kannon-email/kannon.git
cd kannon
go build -o kannon .
./kannon migrate main --config ./config.yaml   # create/upgrade the schema
./kannon standalone --config ./config.yaml

This mode:

Note: the schema is never migrated automatically at boot. Run kannon migrate main once against a fresh database, and after every upgrade that ships a migration.

Local Run (Manual Component Selection)

git clone https://github.com/kannon-email/kannon.git
cd kannon
go build -o kannon .
./kannon --run-api --run-smtp --run-sender --run-dispatcher --config ./config.yaml

Note: This mode requires an external NATS server configured in your config file (or use_embedded_nats: true).

Docker Compose

See examples/docker-compose/ for ready-to-use files. The compose file runs the migration as a separate migrator service before starting Kannon.

docker compose -f examples/docker-compose/docker-compose.yaml up
# or: make docker-up

Makefile Targets

Configuration

Kannon reads configuration from a YAML file and, for a handful of top-level keys, from the environment. CLI flags select which components run. Precedence: CLI flag > env > YAML.

The config file is --config <path>, defaulting to $HOME/.kannon.yaml.

Top-level options (these are the only keys that can be set from the environment):

YAML key Env var Type Default Description
database_url K_DATABASE_URL string (required) PostgreSQL connection string
nats_url K_NATS_URL string (required) NATS server URL β€” not needed when NATS is embedded
use_embedded_nats K_USE_EMBEDDED_NATS bool false Run an in-process NATS server. kannon standalone forces this on
debug K_DEBUG bool false Enable debug logging

Per-component options (YAML only, under their own section):

YAML key Type Default Description
api.port int 50051 API listen port
sender.hostname string (required) Hostname announced for outgoing mail
sender.max_jobs int 10 Max parallel sending jobs
sender.demo_sender bool false Enable demo sender mode for testing
smtp.address string :25 Inbound SMTP server listen address
smtp.domain string localhost Inbound SMTP server domain
smtp.read_timeout duration 10s SMTP read timeout
smtp.write_timeout duration 10s SMTP write timeout
smtp.max_payload int 1048576 Max SMTP message size, in bytes
smtp.max_recipients int 50 Max recipients per inbound SMTP message
tracker.port int 8080 Open/click tracking HTTP server port
stats.retention duration 8760h (1 year) How long raw per-Delivery stats are kept
audit.enabled bool false Record every authorization decision (see below)
audit.retention duration 720h (30 days) How long an Audit Record is kept

Component selection (CLI flag, or the same name as a top-level YAML key):

Flag / YAML key Default Description
--run-api false Enable API server
--run-smtp false Enable inbound SMTP server
--run-sender false Enable sender worker
--run-dispatcher false Enable dispatcher worker
--run-validator false Enable validator worker
--run-tracker false Enable tracker worker
--run-stats false Enable stats worker
--run-audit false Enable audit writer β€” needs audit.enabled

[!IMPORTANT] Environment variables work for the four top-level keys in the first table, and for K_API_ADMIN_TOKEN. Every other nested key (K_API_PORT, K_SENDER_HOSTNAME, K_SMTP_ADDRESS, …) and the run-* flags (K_RUN_API, …) are silently ignored β€” set them in the YAML file, or pass the --run-* flags on the command line.

Access control:

YAML key Env var Type Default Description
api.admin_token K_API_ADMIN_TOKEN string (required) Credential authenticating the Admin API and both Stats APIs

[!IMPORTANT] api.admin_token is the one nested key that can be set from the environment, as K_API_ADMIN_TOKEN β€” a secret belongs in a Secret rather than in a ConfigMap. It is required whenever --run-api is set: a process asked to serve the API without it refuses to boot, rather than come up answering every Admin and Stats request with unauthenticated. Workers that do not serve the API need no token.

[!WARNING] The admin token is a single shared secret that authorizes everything on every Domain β€” creating Domains, minting API Keys, rewriting Templates and reading any Domain's per-Delivery statistics. It names no operator, so an Audit Record can only say that a holder acted, and it is revoked by changing it and restarting. Give it to as few callers as possible, and keep the API listener off untrusted networks.

Audit trail (off by default):

Set audit.enabled and run a process with --run-audit, and Kannon writes an Audit Record for every authorization decision it reaches β€” permitted, refused, and the case where nothing authenticated a request that reached a guarded operation. Records land in the audit_records table and are deleted automatically, hourly, once older than audit.retention. Decisions also go on the NATS subjects kannon.audit.allowed and kannon.audit.denied, so refusals can be alerted on without querying the table.

Both halves are needed. audit.enabled alone publishes Records that nobody writes down, and they expire off the stream after seven days β€” the API logs a warning when it sees that happening. --run-audit alone consumes nothing, and the worker says so and stops rather than idling. Leave audit.enabled unset and nothing is collected at all: the API process does not even connect to NATS on account of the feature.

An Audit Record holds the identifier of the credential that acted, the Action, the Resource path, the outcome, the instant, the Grants the credential held, and β€” when the request carried an X-Kannon-Attribution header β€” the person that header named. That claim is personal data, which is why its retention is yours to set. The caller's IP address is deliberately not collected. Kannon never reads this table back, so nothing in it can influence an authorization decision. See ADR 0010.

Deprecated aliases: run-verifier continues to work as an alias for run-validator, run-bounce for run-tracker, and the bump: YAML section (plus the K_BUMP_PORT env var) for tracker:. They will be removed in a future major version.

Database Schema

Kannon requires a PostgreSQL database, migrated with dbmate via kannon migrate main. Main tables (physical names retained for backward compatibility; see CONTEXT.md for the corresponding domain entities):

See db/migrations/ for full schema and migrations.

API Overview

Kannon exposes a single HTTP server (default port 50051) built with Connect, serving the Connect, gRPC and gRPC-Web protocols over HTTP/1.1 and h2c. The simplest client is plain curl with JSON; any gRPC client works too.

The server does not register gRPC server reflection, so tools like grpcurl need the schema passed explicitly: grpcurl -import-path .proto -proto kannon/mailer/apiv1/mailerapiv1.proto …. The proto sources live in .proto/; proto/ holds the generated Go code.

Services & Methods

Authentication

Every API but health authenticates, and each with the credential that fits what it does.

Admin API and both Stats APIs β€” the operator's admin token, in a header of its own:

X-Kannon-Admin-Token: <api.admin_token>

It authorizes everything on every Domain, so a caller holding it can create Domains, mint API Keys and read any Domain's statistics. A request without it, or with the wrong one, is refused with unauthenticated.

[!NOTE] The health service (pkg.kannon.admin.apiv1.HZService) stays open: it discloses no tenant data and is polled by probes that carry no credential.

Naming who asked

A front-end holding the admin token serves its own people, and Kannon cannot see them. It may name one per request, on the same three surfaces:

X-Kannon-Attribution: alice@corp.com

The name is recorded and never consulted: Kannon has nothing to check it against, so it can no more widen what the request may do than it can be verified. Every operation carrying one is logged as attributed operation, with the authenticated credential beside the claim β€” one was checked and the other was asserted, and the record keeps them apart. The header is optional; sending nothing records the credential alone.

A claim must be at most 256 bytes of UTF-8 with no control characters. A malformed one is refused with invalid_argument rather than dropped, so a front-end never believes a name was recorded when it was not. An API Key cannot make a claim at all: the Mailer API does not read the header, and a key resolves to sender, which may not name anybody.

Mailer API β€” Basic Auth with a Domain and one of its API Keys:

token = base64(<your domain>:<your api key>)

Pass it in the Authorization header (gRPC metadata):

Authorization: Basic <your token>

An API Key is shown in full only in the CreateAPIKey response β€” it is stored hashed, so a lost key must be replaced rather than recovered.

Sending Mail

Bootstrapping a Domain and an API Key

# Both calls are on the Admin API, so both carry the admin token.
ADMIN_TOKEN='<api.admin_token>'

# 1. Register the sender Domain. The response carries the DKIM public key to publish.
curl -sX POST http://localhost:50051/pkg.kannon.admin.apiv1.Api/CreateDomain \
  -H 'Content-Type: application/json' \
  -H "X-Kannon-Admin-Token: $ADMIN_TOKEN" \
  -d '{"domain":"mail.yourdomain.com"}'

# 2. Mint an API Key for it. `key` is returned once and never again.
curl -sX POST http://localhost:50051/pkg.kannon.admin.apiv1.Api/CreateAPIKey \
  -H 'Content-Type: application/json' \
  -H "X-Kannon-Admin-Token: $ADMIN_TOKEN" \
  -d '{"domain":"mail.yourdomain.com","name":"backend"}'

Example: SendHTML

TOKEN=$(printf '%s' 'mail.yourdomain.com:<your api key>' | base64)

curl -sX POST http://localhost:50051/pkg.kannon.mailer.apiv1.Mailer/SendHTML \
  -H 'Content-Type: application/json' \
  -H "Authorization: Basic $TOKEN" \
  -d @- <<'JSON'
{
  "sender": { "email": "no-reply@mail.yourdomain.com", "alias": "Your Name" },
  "subject": "Test",
  "html": "<html><body><h1>Hello {{ name }}</h1><p>Plan: {{ plan }}</p></body></html>",
  "recipients": [
    { "email": "user@example.com", "fields": { "name": "Ada" } },
    { "email": "other@example.com", "fields": { "name": "Grace" } }
  ],
  "global_fields": { "plan": "pro" },
  "attachments": [{ "filename": "file.txt", "content": "<base64-encoded-content>" }],
  "headers": { "to": ["team@example.com"], "cc": ["cc@example.com"] },
  "scheduled_time": "2026-01-01T09:00:00Z"
}
JSON

Fields worth calling out:

The response reports what was actually queued, so a partial send needs no polling:

{
  "messageId": "...",
  "templateId": "...",
  "scheduledTime": "2026-01-01T09:00:00Z",
  "acceptedCount": 1,
  "rejectedCount": 1,
  "rejectedRecipients": [{ "email": "bad@", "reason": "invalid_email" }]
}

reason is a stable token β€” invalid_email, tracking_above_ceiling, unsupported_tracking_mode, unsubscribe_url_unresolved β€” and the set grows over time, so treat an unrecognised value as a refusal of unknown cause.

Headers

The optional headers field allows overriding the To and adding a Cc header on sent emails. The SMTP envelope recipient (actual delivery target) remains the pool recipient, but the visible mail headers will use the values from headers:

This is useful for scenarios where you want the email to appear addressed to a group or alias while delivering to individual recipients.

One-click unsubscribe

The optional one_click_unsubscribe field carries your own unsubscribe endpoint in the List-Unsubscribe and List-Unsubscribe-Post headers (RFC 8058), which the large receivers require of bulk senders. Kannon personalises the URL, emits it and DKIM-signs it β€” it never calls it, keeps no suppression list, and records nothing when a recipient uses it.

{
  "one_click_unsubscribe": {
    "url_template": "https://yourdomain.com/unsub?email={{ email }}"
  }
}

State it per send: it is deliberately not a per-domain default, since an unsubscribe header does not belong on a password reset or a receipt.

Link tracking

When the Tracking Policy governing a message allows link tracking, every <a href="..."> in the HTML is rewritten into a https://stats.<your-domain>/c/<token> redirect that records the click and forwards the recipient to the original URL.

A single link can opt out, which is what unsubscribe and preference links usually want:

<a href="https://yourdomain.com/preferences" data-no-track>Manage preferences</a>

Such a link is delivered with its href exactly as authored, and the data-no-track attribute is removed from the delivered HTML β€” whatever the Tracking Policy says, so it never reaches the recipient even when link tracking is off anyway.

The attribute name is case-insensitive and works by presence: any value opts the link out, so data-no-track, data-no-track="", data-no-track="true" and even data-no-track="false" all mean the same thing. To track a link again, remove the attribute.

Links a redirect cannot serve are never rewritten and need no attribute: mailto:, tel:, sms:, and in-page anchors such as #section.

Open tracking

When the Tracking Policy governing a message allows open tracking, a hidden 1-pixel image is inserted immediately before the closing </body> tag, served from https://stats.<your-domain>/o/<token>. HTML with no closing tag β€” a bare fragment such as <h1>Hello</h1> β€” has no end of body to place it at, so it is delivered without an open pixel.

See the proto files for all fields and options.

Reading statistics

ADMIN_TOKEN='<api.admin_token>'

# Raw per-Delivery events (v1)
curl -sX POST http://localhost:50051/kannon.StatsApiV1/GetStats \
  -H 'Content-Type: application/json' \
  -H "X-Kannon-Admin-Token: $ADMIN_TOKEN" \
  -d '{"domain":"mail.yourdomain.com","take":50}'

# Hourly aggregates (v2)
curl -sX POST http://localhost:50051/kannon.stats.apiv2.StatsApiV2/GetAggregatedStats \
  -H 'Content-Type: application/json' \
  -H "X-Kannon-Admin-Token: $ADMIN_TOKEN" \
  -d '{"domain":"mail.yourdomain.com"}'

Deployment

Kubernetes

Docker Compose

Domain & DNS Setup

To send mail, you must register a sender domain and configure DNS. In the records below, <SENDER_NAME> is sender.hostname from your config, and <YOUR_DOMAIN> is the Domain registered through the Admin API:

  1. Register a domain via the Admin API (CreateDomain) and keep the dkim_pub_key it returns
  2. Set up DNS records:
    • A record: <SENDER_NAME> β†’ your server IP
    • Reverse DNS: your server IP β†’ <SENDER_NAME>
    • SPF TXT: <YOUR_DOMAIN> β†’ v=spf1 ip4:<YOUR SENDER IP> -all
    • DKIM TXT: kannon._domainkey.<YOUR_DOMAIN> β†’ k=rsa; p=<dkim_pub_key>
    • A record: stats.<YOUR_DOMAIN> β†’ the host serving the Tracker, if you use open/click tracking (tracking URLs are always built as https://stats.<YOUR_DOMAIN>/…, and the Tracker serves plain HTTP on tracker.port, so terminate TLS in front of it)

The DKIM selector is fixed to kannon.

Testing & Demo Mode

Kannon includes a demo sender mode for testing and development without actually sending emails. This is particularly useful for:

Enabling Demo Mode

Set sender.demo_sender: true in your config file β€” there is no CLI flag or env var for it:

sender:
  hostname: kannon.example.com
  max_jobs: 10
  demo_sender: true # Enable demo sender mode

Then start Kannon as usual:

./kannon --run-api --run-sender --run-dispatcher --run-validator --run-stats --config ./config.yaml

Demo Sender Behavior

When demo mode is enabled:

This mode mocks the SMTP client and does not actually send emails.

IMPROVEMENTS:

Local Environment for Integration Development

The examples/docker-compose/ stack is the fastest way to develop against Kannon: it starts PostgreSQL, NATS, a one-shot migrator, and Kannon with every component enabled and demo_sender: true.

docker compose -f examples/docker-compose/docker-compose.yaml up -d

The API is then available at localhost:50051. Follow Sending Mail to create a Domain, mint an API Key, send, and read the stats back β€” the whole pipeline runs, statistics are collected, and nothing leaves your machine.

To customise it, edit examples/docker-compose/kannon.yaml (Kannon config) or examples/docker-compose/docker-compose.yaml (infrastructure), then docker compose … down && docker compose … up.

When moving to production, set demo_sender: false and make sure outbound port 25 is reachable. Your integration code does not change.

Development & Contributing

We welcome contributions! Please:

Developer Documentation

Local Development

Testing

License

Kannon is licensed under the Apache 2.0 License. See LICENSE for details.