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.
To / Cc headersPlanned:
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:
SendHTML / SendTemplate and creates Batches with N Deliveries; together with the Admin API (Domains, Templates, API Keys), the Stats API and a health service, it forms the single HTTP API surface.to_validate, validates the recipient address, and either schedules or rejects them.kannon.stats.* events, persists them, and prunes them once past the retention window.All components can be enabled/disabled via CLI flags or config.
See
ARCHITECTURE.mdfor 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
mise.toml)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 mainonce against a fresh database, and after every upgrade that ships a migration.
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).
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
examples/docker-compose/kannon.yaml to configure your environment.make test β Run unit and integration tests (go test ./... -race -short)make test-e2e β Run the end-to-end suite in e2e/ (needs Docker)make bench β Run benchmarksmake generate β Generate DB (sqlc) and proto (buf) codemake lint β Run golangci-lint and the deadcode checkmake docker-up β Bring up the example Docker Compose stackKannon 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 therun-*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_tokenis the one nested key that can be set from the environment, asK_API_ADMIN_TOKENβ a secret belongs in a Secret rather than in a ConfigMap. It is required whenever--run-apiis set: a process asked to serve the API without it refuses to boot, rather than come up answering every Admin and Stats request withunauthenticated. 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.
examples/docker-compose/kannon.yaml for a full example.Deprecated aliases:
run-verifiercontinues to work as an alias forrun-validator,run-bounceforrun-tracker, and thebump:YAML section (plus theK_BUMP_PORTenv var) fortracker:. They will be removed in a future major version.
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):
stats.retentionaudit.enabled, never read by Kannon, pruned by audit.retentionSee db/migrations/ for full schema and migrations.
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
grpcurlneed 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.
pkg.kannon.mailer.apiv1.Mailer (proto)
SendHTML: Send a raw HTML emailSendTemplate: Send an email using a stored templatepkg.kannon.admin.apiv1.Api (proto)
GetDomains, GetDomain, CreateDomain, SetTrackingPolicyCreateTemplate, UpdateTemplate, DeleteTemplate, GetTemplate, GetTemplatesCreateAPIKey, ListAPIKeys, GetAPIKey, DeactivateAPIKeykannon.StatsApiV1 (proto)
GetStats, GetStatsAggregatedkannon.stats.apiv2.StatsApiV2 (proto)
GetAggregatedStats: hourly buckets served from aggregated_statspkg.kannon.admin.apiv1.HZService (proto)
HZ: per-dependency status map, "OK" or the error stringEvery 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.
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.
# 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"}'
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:
recipients: a list of objects, not of strings. Each carries its own fields (substituted per Delivery) and, optionally, its own tracking policy.global_fields: substituted once into the Batch template, for values shared by every Recipient. Recipient fields win where both define a placeholder.scheduled_time: optional RFC 3339 timestamp; the Batch is held in the Pool until then.tracking: optional Batch-level Tracking Policy. It may only narrow the Domain's ceiling; asking for more fails the call.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.
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:
to: Overrides the To header displayed in the email clientcc: Adds a Cc header to the emailThis is useful for scenarios where you want the email to appear addressed to a group or alias while delivering to individual recipients.
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 }}"
}
}
mailto: and plain http are refused, and a malformed template fails the whole call.POST with body List-Unsubscribe=One-Click and unsubscribe with no confirmation step. Setting this field asserts that, and Kannon cannot check it for you.{{ field }} placeholders are substituted per recipient and percent-encoded, so pass raw values. email is always available and holds the recipient's address unless you pass a field of that name yourself.unsubscribe_url_unresolved, while the rest of the send proceeds.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.
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.
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.
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"}'
k8s/deployment.yaml for a starting manifest: it runs every component in one pod and exposes the API (50051), the Tracker (8080) and the inbound SMTP listener (25).kannon-config with a config.yaml key. The manifest mounts it at /etc/kannon, and Kannon exits at boot if it is missing β most settings cannot be set from the environment.https://stats.<your-domain>/β¦ while the Tracker itself serves plain HTTP.examples/docker-compose/ for local or test deployments.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:
CreateDomain) and keep the dkim_pub_key it returns<SENDER_NAME> β your server IP<SENDER_NAME><YOUR_DOMAIN> β v=spf1 ip4:<YOUR SENDER IP> -allkannon._domainkey.<YOUR_DOMAIN> β k=rsa; p=<dkim_pub_key>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.
Kannon includes a demo sender mode for testing and development without actually sending emails. This is particularly useful for:
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
When demo mode is enabled:
error yields a retryable SMTP failure (code 512)This mode mocks the SMTP client and does not actually send emails.
IMPROVEMENTS:
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.
We welcome contributions! Please:
go build -o kannon .make testmake test-e2emake generate (sqlc + buf)make lintinternal/, pkg/ and x/; make test runs them with -race -short-shorte2e/ exercise the whole sending pipeline with the demo senderKannon is licensed under the Apache 2.0 License. See LICENSE for details.