- To
- Jane <jane@example.com>
- Subject
- Bienvenue sur Kourrier, Jane !
Bonjour Jane,
Votre compte a bien été créé. Vous pouvez dès maintenant vous connecter.
Your services publish what happened. Declarative routes, versioned in Git, decide which e-mails go out. Asynchronous, self-hosted, provider-agnostic, written in Go.
user.signed_up
Change the event and watch it go through.
route · welcome
Matchedsend welcome to Jane in FR
route · order-receipt
send order-receipt to the customer
Written in CEL, versioned in Git. No route, no e-mail.
Bonjour Jane,
Votre compte a bien été créé. Vous pouvez dès maintenant vous connecter.
Queued, then sent by a worker through SES.
Services publish domain events in the CloudEvents format. Kourrier consumes them and works out which e-mails to send. Adding a notification is adding a route, reviewed in a pull request like the rest of your platform.
Before: e-mail inside the business transaction
One transaction
Signup service
Saves the user
…and sends the e-mail
Template, locale, SMTP
SMTP
Slow or down
After: publish the fact, route it in Git
Signup service
Publishes user.signed_up
NATS
Event bus
Kourrier
Routes in Git
SES
or SMTP
Two doors, one road
Send a specific e-mail now (password reset, OTP)
Direct API callNotify after a business fact, without coupling
EventsSend only if the database transaction commits
Events + outboxSeveral e-mails for one fact (customer, admin, accounting)
One route per e-mailA hexagonal Go monolith. The HTTP API, the CloudEvents sink and the NATS JetStream consumer all hand over to the same submit service, so validation, idempotency, statuses, retries and metrics are identical whichever door the mail came through.
Beside the road · Templates
In S3, MinIO, RustFS or a local folder, cached by the workers. A file dropped in the bucket is live within a minute, without a redeploy.
Beside the road · Admin port :9090
/metrics for Prometheus, /livez and /readyz for the probes. JSON logs on stdout for Loki, with addresses masked.
At-least-once delivery on Redis Streams. A transient failure is retried with exponential backoff and jitter. A permanent one, or eight failed attempts, goes to a dead-letter queue that is never emptied silently, and can be replayed once you've fixed the cause.
Time is compressed. Defaults: first retry after 10 s, doubled each time with jitter, capped at 30 min, 8 attempts before the DLQ.
Tracking · e-mail 0192f1b4
Accepted and queued in Redis Streams
Status
queued
Attempts · 0 of 8 before the DLQ
Provider · AWS SES
Jobs left pending by a dead Pod are claimed back by the others.
Idempotency-Key on the API, route + source + id on events: resends and redeliveries are absorbed.
A distributed GCRA rate limiter on Redis keeps you under your SES quota, however many replicas run.
When e-mails leave slower than events arrive, the backlog stays in NATS instead of piling up in Redis.
Routes are written in CEL, the expression language of Kubernetes: no loops, no I/O, a bounded cost. Two commands work offline, with no Redis and no broker, so a broken route fails the pull request, not production.
+ route · order-receipt
send order-receipt to the customer
Checks · offline, no Redis, no broker
kourrier routes check
2 routes · syntax, CEL types, tenants and senders
kourrier routes test
order-paid.json → 1 e-mail: order-receipt to jane@example.com
CEL already validates CRDs and admission policies. No loops, no I/O.
Each evaluation is capped, so a route can't slow the dispatcher down.
A missing field rejects the route instead of sending a half-empty e-mail.
Hardened by default, observable out of the box. Templates change in the bucket without a redeploy; routes change through a ConfigMap and a rolling update that stops by itself if the new file is invalid.
Deployment
Non-root, read-only rootfs, seccomp, probes, graceful stop
Secret
Tenants and key fingerprints; a change restarts the Pods
ConfigMap
Routes; an invalid file stops the rollout by itself
HPA · PDB
Scales with load, survives node drains
NetworkPolicy · Ingress
Only the traffic it needs
ServiceMonitor
Prometheus scrapes :9090/metrics
PrometheusRule
Seven alerts, each with its runbook
Grafana dashboard
A labelled ConfigMap, picked up by Grafana
Outside the Pod
NATS JetStream
events in
Redis ≥ 7
queue, statuses, limits
S3 · MinIO · RustFS
templates
AWS SES · SMTP
e-mail out
Sent (24h)
18,432
Failed (24h)
3
Success ratio (1h)
99.98%
Delivery latency p95
412 ms
Queue backlog
0
Dead-letter queue
0
E-mails sent / s by tenant
Seven alerts, with their runbook
~40 MB
static binary
~53 MB
distroless, non-root image
The default driver prints e-mails instead of sending them.
Per-tenant keys; only their SHA-256 fingerprints are stored.
A tenant can only send from the addresses it was given.
HTML escaped automatically, addresses masked in the JSON logs.
Locally, Kourrier prints e-mails in the console instead of sending them. Edit a template, send again, see the result straight away.
make up
Redis and Kourrier in Docker, API on :8180
make send
sends the demo “welcome” e-mail
make logs
shows the rendered e-mail
To Jane <jane@example.com>
Welcome to Kourrier, Jane!
Hi Jane, your account is ready. You can sign in right away.
Need the real drivers? make up-full loads templates from S3 (RustFS) and sends over SMTP to Mailpit, still without leaving your machine.
Fourteen architecture decision records explain what was chosen, what was ruled out, and why. They're in French, in the repository.
To: your platform team
Kourrier is on GitHub, with its Helm chart, its Grafana dashboard and its documentation.