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)

Name the components you want in the config file:

services:
  api:
    enabled: true
  smtp:
    enabled: true
  sender:
    enabled: true
  dispatcher:
    enabled: true
git clone https://github.com/kannon-email/kannon.git
cd kannon
go build -o kannon .
./kannon --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 one YAML file. Every setting lives there β€” including which components the process runs β€” and any value can be taken from the environment by naming the variable it should come from:

database_url: env://KANNON_DATABASE_URL             # required: refuses to boot when unset
nats_url: env://KANNON_NATS_URL:-nats://nats:4222   # with a fallback
services:
  stats:
    enabled: env://KANNON_ENABLE_STATS:-false

That is what makes a single file describe a whole installation: every Deployment mounts the same ConfigMap and differs only in the variables it sets. See k8s/deployment.yaml and examples/docker-compose/kannon.yaml.

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

Which components run β€” under services, all of them off unless enabled:

YAML key Description
services.api.enabled API server (Mailer, Admin, Stats) β€” requires api.admin_token
services.smtp.enabled Inbound SMTP server
services.sender.enabled Sender worker
services.dispatcher.enabled Dispatcher worker
services.validator.enabled Validator worker
services.tracker.enabled Tracker worker (opens, clicks, bounces)
services.stats.enabled Stats worker
services.audit.enabled Audit writer β€” needs audit.enabled as well, see below

A process with nothing enabled refuses to boot rather than exit as if it had done its job, and a misspelled name under services is refused rather than ignored.

Top-level options:

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

Per-component options, 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

Access control:

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

[!IMPORTANT] Write admin_token: env://KANNON_ADMIN_TOKEN and keep the value in a Secret rather than in the ConfigMap holding the rest of the file. It is required whenever services.api.enabled 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 services.audit.enabled, 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, and they are not the same key: audit.enabled is the feature β€” whether decisions are published at all β€” while services.audit.enabled is the writer that turns them into rows. 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. The writer alone consumes nothing, and 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.

Migrating from K_ variables and --run-* flags

Both are removed β€” see ADR 0012. A --run-* flag left on a command line is now refused as an unknown flag; a K_ variable sets nothing, with no warning, so check for them before upgrading.

Was Now
--run-stats services.stats.enabled: true
--run-verifier, --run-bounce services.validator.enabled, services.tracker.enabled
K_DATABASE_URL=… in the environment database_url: env://K_DATABASE_URL in the file
K_API_ADMIN_TOKEN=… api.admin_token: env://K_API_ADMIN_TOKEN
the bump: section, K_BUMP_PORT tracker.port

The variable does not have to be renamed: the file can go on referring to whatever the deployment already sets. What changes is that the reference is written down, so which settings come from the environment is visible in one place β€” and it works for every key, where the K_ prefix only ever reached the four top-level ones plus K_API_ADMIN_TOKEN. K_API_PORT, K_SENDER_HOSTNAME and K_TRACKER_PORT were silently ignored for as long as they existed.

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:

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

Then start Kannon as usual, with services.api, services.sender, services.dispatcher, services.validator and services.stats enabled:

./kannon --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.