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.
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).
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 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
env://NAME is required: a process whose variable is not set refuses to boot, naming both the key and the variable.env://NAME:-default falls back, and treats a variable set to the empty string as unset β POSIX ${NAME:-default}.https://env://HOST/v1 is left alone, and a literal is escaped as \env://NAME.env:/NAME, ENV://NAME, env://my-name and env://NAME:default (the default is introduced by :-) all stop the boot with a message, instead of handing the code the text you wrote.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_TOKENand keep the value in a Secret rather than in the ConfigMap holding the rest of the file. It is required wheneverservices.api.enabledis 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 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.
examples/docker-compose/kannon.yaml for a full example.K_ variables and --run-* flagsBoth 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.
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: a ConfigMap holding the whole configuration, and one pod running every component, exposing the API (50051), the Tracker (8080) and the inbound SMTP listener (25).KANNON_ENABLE_* variables that pod needs β one file describes the installation, and each Deployment says which part of it it is.database_url, write the reference with an empty fallback β database_url: env://KANNON_DATABASE_URL:- β since a bare env://NAME is required of every process that reads the file, whether or not it ever connects.--config points at a file that is not there, and refuses to start if the file enables no component.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:
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
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.