smtp2graph delivery monitoring¶
smtp2graph is the cluster's SMTP-to-Microsoft-Graph relay, in the infrastructure namespace
(kubernetes/apps/infrastructure/smtp2graph). This page covers why its monitoring measures
delivery outcome rather than reachability, and the sidecar and canary design behind that.
Why delivery, not reachability¶
Between 2026-08-10 and 2026-08-19 the relay accepted mail and then failed every Graph send with
ErrorSendAsDenied: 353 messages lost, 2,824 errors, zero delivered. The SMTP listener stayed
healthy the whole time, so a liveness probe, a TCP check or a Gatus probe would all have stayed
green. Nothing noticed for nine days. Root cause was fixed separately, outside this repository;
the metrics sidecar and prometheusrule.yaml (PR #4402) exist so a failure is caught within
minutes whenever mail is flowing. During a quiet period the 6-hourly canary is the only signal,
and SMTP2GraphCanaryFailing does not fire until it has missed for about 14 hours.
smtp2graph v1.1.5 exposes no metrics of its own: it listens on :25 and nothing else, and its
config schema has no metrics or health options. The sidecar in helmrelease.yaml
(exporter/exporter.py) is the only source of delivery telemetry, built from two signals:
- Queue depth. It reads the relay's own on-disk queue directories (
queue/,temp/,failed/) and reports counts and the oldest message's age. - An end-to-end canary. Every 6 hours it sends a real message to
CANARY_TOand confirms it leaves the queue without landing infailed/, which exercises the actual Graph call rather than just the SMTP listener. A mailbox rule on the[canary] smtp2graphsubject keeps the traffic out of the way. ShorteningCANARY_INTERVAL_SECONDSbuys a tighter detection window at the cost of more mail.
The exporter is a real file, not inline YAML, so it is unit-testable outside the cluster. All three paths (delivered, rejected by Graph, relay down) were exercised against a stub SMTP server before merge.
Queue stall vs. stranded message¶
SMTP2GraphQueueStalled fires on any backlog older than 30 minutes and is the only alert covering
a message that smtp2graph will never retry and never move to failed/ (three Graph error codes it
treats as permanent). See KB-035
for the symptom, cause and fix; do not duplicate that runbook here.
PodMonitor, not ServiceMonitor¶
The metrics sidecar is scraped by podmonitor.yaml rather than through its own Service. This was a
live regression, not foresight: PR #4402 first gave the sidecar its own Service, and app-template
5.x suffixes every Service on a controller the moment there is more than one, so smtp2graph
became smtp2graph-app. That broke smtp2graph.infrastructure.svc.cluster.local, the address
every in-cluster mail sender (pocket-id, tandoor, epicgames, scanopy) uses, and closed the
LoadBalancer VIP the NMC fleet sends through. primary: true does not opt out of the suffixing,
confirmed against the chart schema and by rendering it. PR #4407 fixed it by dropping the second
Service: the sidecar declares a named container port and PodMonitor scrapes the pod directly.
TLS: STARTTLS offered, not required¶
The target state is a relay that offers STARTTLS on port 25 with a Let's Encrypt certificate for
smtp.${SECRET_DOMAIN} (certificate.yaml), the LoadBalancer's external-dns name. The certificate
is publicly trusted, so clients verify it with their stock roots and no private CA is handed out.
That only works if the client connects by that name: a client pointed at
smtp2graph.infrastructure.svc.cluster.local gets a name mismatch, and a client that upgrades
opportunistically (nodemailer) then fails to send.
| Sender | Target TLS | How |
|---|---|---|
Alertmanager (email receiver) |
Required | drop requireTLS: false (default is true) |
| pocket-id | Required | SMTP_TLS: starttls |
| tandoor | Required | EMAIL_USE_TLS: "1" |
| scanopy | Required | lettre requires it on any port but 465; it also logs in as user scanopy |
| epicgames | Opportunistic | nodemailer upgrades whenever STARTTLS is offered |
wazuh (wazuh-maild) |
None | maild cannot do TLS |
| UPS and PDU network cards | None | configured without SSL |
| delivery canary | None | loopback inside the pod |
TLS stays optional (secure: false) because of the last three rows. Login stays optional too
(requireAuth unset); the one user, scanopy, exists because Scanopy's mailer always sends AUTH.
Its password is SCANOPY_SMTP_PASSWORD in the scanopy 1Password item, and its own allowedFrom
limits it to scanopy@. With TLS on, smtp2graph accepts a login only after STARTTLS.
Each sender reconciles in its own Flux Kustomization, so the switch is staged to avoid a window where a client demands TLS the relay does not offer, or upgrades against a name the certificate does not cover:
- Issue the certificate, move every sender to
smtp.${SECRET_DOMAIN}with its TLS setting unchanged, and put the queue on a PVC (2026-10-14). Scanopy cannot send until step 2. - Mount the certificate and set
secure: false,tlsCertPathandtlsKeyPathin the relay config (2026-10-14). Opportunistic clients upgrade from here on. - Require TLS on the senders marked Required (2026-10-14).
The queue (/data: queue/, temp/, failed/) is on a 1Gi ceph-block PVC with a Recreate
strategy. Reloader restarts the pod whenever the certificate Secret changes, and the
letsencrypt-production issuer uses the shortlived profile (6-day certificates), so that is a
restart every few days; on an emptyDir each one would erase queued and failed mail.
Wazuh sends as noreply@ itself. Until 2026-10-14 it sent as wazuh@, which is neither an alias
on the shared mailbox nor on allowedFrom, so every alert email was refused at MAIL FROM with a
550 that wazuh-maild never logged.
References¶
kubernetes/apps/infrastructure/smtp2graph/app/helmrelease.yaml, the metrics sidecar and canary configuration.kubernetes/apps/infrastructure/smtp2graph/app/prometheusrule.yaml, the delivery-outcome alerts.kubernetes/apps/infrastructure/smtp2graph/app/exporter/exporter.py, the exporter and canary.- KB-035, the stranded-message runbook.