The relay is a stateless emersion/go-smtp server. One Deployment+Service runs per Relay CR.
It is configured entirely from a mounted file + mounted Secrets and needs no Kubernetes API
access.
For each inbound SMTP session from the Postfix ingress:
MAIL FROM→ sender filter (allowed sender domains).RCPT TO→ route match (the relay only accepts recipients it claims).DATA→- enforce
maxMessageBytes(reject 552 if exceeded). - parse MIME.
- run filter scoring (reject 5xx if below
minScore). - compute the idempotency key once.
- fan out to all destinations (per-destination transform + deliver).
- return
250if allrequireddestinations succeed,4xxif anyrequireddestination fails (Postfix retries). Best-effort (required: false) failures are logged + metered.
- enforce
Declarative inbound validation from spec.filters:
- Hard rules (reject before forwarding):
maxMessageBytes,allowedSenderDomains,requireDKIM(a cryptographically valid DKIM signature whosed=matches). - Heuristic score, the sum of matched
scoreSignals. Accept whenscore >= minScore. Signals are named, reusable checks:fromDomain, theFrom:header domain is allowed.messageIdDomain, theMessage-IDdomain is allowed.dkimDomain, a cryptographically valid DKIM signature has an allowedd=domain.authResults, an alias ofdkimDomainfor the same cryptographic check.bodyLinkDomain, the body contains a link to an allowed domain.
DKIM signatures are verified cryptographically. Public keys are resolved from DNS at evaluation time.
Each destination transforms independently:
- MIME to canonical envelope (the fixed schema below).
- Optional Jsonnet (
google/go-jsonnet) per destination, remapping the canonical envelope into the consumer's own schema (Ory-Kratos-style mapping). Referenced viajsonnetConfigMapRef. - Payload format, either
json(canonical envelope, default) orraw(message/rfc822).
What payloadFormat: json emits and what a Jsonnet transform receives as input. Fixed,
documented, versioned schema:
{
"version": "v1",
"idempotencyKey": "<message-id-or-sha256>",
"envelope": { "mailFrom": "...", "rcptTo": ["..."] },
"headers": { "Subject": "...", "From": "...", "...": "..." },
"from": "...",
"to": ["..."],
"subject": "...",
"text": "...",
"html": "...",
"attachments": [
{ "filename": "...", "contentType": "...", "bytesBase64": "..." }
],
"raw": "<optional full RFC822, base64>"
}Textual MIME bodies are decoded from supported declared charsets into UTF-8 for the canonical
text and html fields. Raw delivery preserves the original RFC822 message.
- HTTP, a POST (or configured method) with a timeout, the
Idempotency-Keyheader, and secret-basedAuthorization. - SMTP, a forward to a downstream host/port with optional STARTTLS/auth. (Pointing this at another relay's Service is how manual relay→relay chaining is done.)
The pipeline is at-least-once (Postfix queue + retry). Fan-out is not atomic: if one of several destinations fails and triggers a retry, destinations that already succeeded receive the message again. Mitigations (standard for any queue-backed email system):
- Every delivery carries an idempotency key (
Idempotency-KeyHTTP header and/or in the JSON envelope) so downstreams dedup. required: falsemarks best-effort destinations whose failure does not trigger a retry.
This contract (at-least-once, idempotency-key-deduped, required gates retry) is part of the
public API.
The relay's mounted config is a versioned YAML document rendered from the Relay spec. The schema
and its version live in internal/relay/config.go, which reuses the
api/v1alpha1 structs as the single source of truth, so the config stays debuggable with
kubectl get cm -o yaml. Postfix map files use Postfix's own native format, not YAML.
slog (with …Context variants) + per-destination success/failure/score metrics. The relay
serves /livez /readyz /healthz via kula-app/go-health
and /metrics via promhttp on a small admin HTTP server (no Kubernetes API access). Destination
reachability is a /healthz-only (informational) check, not a readiness gate. Postfix queues
and retries on failure, so a flaky downstream must not drain the relay. Full surface, the iris_relay_*
metric catalogue, and Sentry capture rules are in observability.md.