{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $release := include "stalwart.fullname" . -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $valkeyHost := include "stalwart.valkeyHost" . -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- /*
"Published" rather than "has an Ingress": the two modes are alternatives, and
the paragraphs below that asked ingress.enabled were really asking whether the
WebUI and JMAP are reachable from outside at all (Gitea #114).
*/ -}}
{{- $published := or .Values.ingress.enabled $httpRoute.enabled -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
{{- $ingressGroup := "" -}}
{{- if $published }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}}
{{- $mtaStsHosts := fromYamlArray (include "stalwart.mtaSts.hosts" .) -}}
{{- $mtaStsPath := include "stalwart.mtaSts.path" . -}}
Stalwart {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.

THIS CHART CONFIGURES ALMOST NOTHING, AND THAT IS NOT A GAP
  Since v0.16 Stalwart keeps its whole configuration inside the data store and
  manages it over JMAP. The only file on disk is config.json, which names the
  data store and nothing else. So this release has given you the workloads, the
  Services, the NetworkPolicies, the Ingress and a certificate — but no
  domains, no listeners, no blob store, no in-memory store and no cluster
  roles. Those are the steps below, and without them this is not yet a mail
  server.

NODE GROUPS IN THIS RELEASE
{{- range $group, $spec := $nodes }}
{{- $replicas := include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas) }}
  {{ printf "%-10s" $group }} replicas {{ $replicas }}, reachable on: {{ $spec.exposedPorts | join ", " }}
{{- end }}
{{- if .Values.scaleDown }}

  scaleDown: true — every group is at replicas 0 and nothing is running. The
  Services, Ingress, Secrets and the Certificate stay in place, so setting it
  back to false brings the release up without re-issuing the certificate.
{{- end }}
{{- if .Values.bootstrap.enabled }}

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
RECOVERY MODE IS ON — THIS RELEASE SERVES NO MAIL
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
  bootstrap.enabled is true, so every pod runs with STALWART_RECOVERY_MODE=1:
  the management HTTP endpoint on {{ (index $ports "http").containerPort }} is the ONLY thing listening and
  every mail port is closed. The Services and the Ingress look exactly as
  healthy as they do in normal operation, which is why this banner exists.

  This is correct for a first install (step 1 below). When the database is
  provisioned, set bootstrap.enabled: false and upgrade again.
{{- end }}

THE 1PASSWORD ITEMS MUST CARRY THE FIELDS THIS CHART READS
  One Secret key is created per FIELD of the item, and these templates
  reference the keys by name — an item missing a field leaves the container in
  CreateContainerConfigError. An EMPTY field fails the same way as a missing
  one: Stalwart's secret resolver treats an empty environment variable as "not
  found".
{{- if .Values.secrets.dataStoreSecretRef }}

  secrets.dataStoreSecretRef → {{ .Values.secrets.dataStoreSecretRef }}
    field:  password          (the data store password)
    The USERNAME is not in this item and cannot be: authUsername is a plain
    String on the DataStore object with no secret form. It is
    dataStore.postgres.username = "{{ .Values.dataStore.postgres.username }}" and is rendered into the
    ConfigMap.
{{- else }}

  secrets.dataStoreSecretRef is NOT SET, so no data store password reaches the
  pods and config.json references an environment variable that will not exist.
  Stalwart fails to open the store with
    Environment variable '{{ include "stalwart.env.dbPassword" . }}' not found
  Set it unless the database genuinely takes no password.
{{- end }}
{{- if .Values.secrets.recoveryAdminSecretRef }}

  secrets.recoveryAdminSecretRef → {{ .Values.secrets.recoveryAdminSecretRef }}
    field:  recovery-admin    (the COMPLETE value "admin:<password>")
    One field, not two. The upstream parser splits on the first ":" and drops
    the entry when either half is empty, so a half-filled item would silently
    mean "no recovery admin" instead of failing — and this credential logs in
    regardless of directory state.

    REMOVE IT WHEN YOU ARE DONE. It is a back door. Once a permanent
    administrator exists in the database, set secrets.recoveryAdminSecretRef
    to null and upgrade; the variable then disappears from the pods.
{{- else }}

  secrets.recoveryAdminSecretRef is NOT SET. That is the right end state once a
  permanent administrator exists — but during a first install or a recovery
  start it means Stalwart generates a temporary administrator password and
  prints it to STDOUT, i.e. into the pod log, freshly on every restart and
  visible to anyone who can read logs. Catch it with
    kubectl -n {{ .Release.Namespace }} logs sts/{{ $release }}-{{ $ingressGroup | default "<group>" }}-sfs | grep -A3 bootstrap
{{- end }}
{{- if .Values.secrets.s3SecretRef }}

  secrets.s3SecretRef → {{ .Values.secrets.s3SecretRef }}
    fields: access-key, secret-key
    Exposed to the containers as {{ include "stalwart.env.s3AccessKey" . }} and
    {{ include "stalwart.env.s3SecretKey" . }}. The chart does NOT configure the blob
    store; point the BlobStore object in the WebUI at these names:
      accessKey: {"@type":"EnvironmentVariable","variableName":"{{ include "stalwart.env.s3AccessKey" . }}"}
      secretKey: {"@type":"EnvironmentVariable","variableName":"{{ include "stalwart.env.s3SecretKey" . }}"}
{{- end }}

  CHANGING WHAT IS INSIDE AN ITEM RESTARTS NOTHING. Helm never sees a secret's
  content, so there is nothing to hash. Repointing one of the values above at a
  different vault item DOES roll the pods (checksum/secret-ref covers the
  op:// path). After rotating a password inside an item:
{{- range $group, $spec := $nodes }}
    kubectl -n {{ $.Release.Namespace }} rollout restart statefulset/{{ $release }}-{{ $group }}-sfs
{{- end }}

FIRST INSTALL — EIGHT STEPS, AND NONE OF THEM IS OPTIONAL
  1. bootstrap.enabled: true, nodes.mx.replicas: 0.
     The setup wizard everyone reaches for appears only when config.json is
     ABSENT, and this chart always renders one — so the wizard never runs here.
     Recovery mode is the documented path for a templated config.json: it
     opens the management endpoint and keeps the mail ports shut. With a second
     group running, the wizard would be unusable anyway: each request would
     land on a random pod.

  2. Open the management UI and create the permanent administrator.
     kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $release }}-{{ $ingressGroup | default "<group>" }}-http-service {{ (index $ports "http").containerPort }}:{{ (index $ports "http").containerPort }}
     then http://localhost:{{ (index $ports "http").containerPort }}/admin

  3. In the WebUI, create: the blob store (S3), the in-memory store
     ({{ if $valkeyHost }}{{ $valkeyHost }}:{{ .Values.externalServices.valkey.port }}{{ else }}no address configured — see externalServices.valkey{{ end }}),
     the coordinator (Default — it reuses the in-memory store connection), the
     search store (Default — it uses the data store, so no extra service is
     needed), the TLS object pointing at the certificate from step 5, the
     NetworkListener objects, and one ClusterRole per node group below.

     THE LISTENER PORTS MUST MATCH THIS RELEASE EXACTLY. The chart cannot
     create listener objects, so these numbers are a second copy of a decision
     made in the WebUI, and a mismatch is silent — the Service points at a port
     nothing listens on, every pod stays Ready, nothing reports it:
{{- range $portName, $port := $ports }}
       {{ printf "%-12s" $portName }} bind {{ printf "%-5s" (printf "%v" $port.containerPort) }}{{ if $port.public }}  (published on node port {{ $port.nodePort }}){{ else }}  (not published){{ end }}
{{- end }}

     AND ONE ClusterRole PER GROUP, NAMED EXACTLY:
{{- range $group, $spec := $nodes }}
       {{ $group }}
{{- end }}
     A node whose role name is not in the database does not fail. It becomes a
     FULL node — every listener and every task — while staying Running with a
     green readiness probe. The only trace is one log line,
     'Cluster role "<name>" not found in registry', and it is printed AFTER the
     listeners are already bound.

  4. Configure HTTP forwarding before the auto-ban notices the {{ if $httpRoute.enabled }}Gateway{{ else }}Ingress{{ end }}.
     Stalwart sees the {{ if $httpRoute.enabled }}Gateway's{{ else }}ingress controller's{{ end }} pod IP for every WebUI and JMAP
     request, not the client's. Sooner or later the auto-ban bans that one
     address and the WebUI and JMAP go dark for everyone at once. The PROXY
     protocol is not the fix for HTTP: enable "forwarded" handling with
     X-Forwarded-For evaluation, and discard forwarding headers the client
     brought itself. The outage arrives weeks later and looks like a server
     fault, which is why this is a step and not a tip.
     {{ if $httpRoute.enabled }}Switching to the Gateway API does not avoid this and does not
     reset it: it is one proxy address instead of another, and whether the
     Gateway sends an X-Forwarded-For at all is that implementation's business,
     not something this route can state.{{ end }}

  5. Point the TLS object at the mounted certificate.
{{- if .Values.certificate.enabled }}
       certificate: {{ .Values.certificate.mountPath }}/tls.crt
       privateKey:  {{ .Values.certificate.mountPath }}/tls.key
     cert-manager writes these into tls-{{ $release }}-mail. Watch it with
       kubectl -n {{ .Release.Namespace }} get certificate {{ $release }}-mail-certificate
     Until it is issued the mail ports serve a self-signed certificate: the
     volume is mounted optional so that a pending challenge does not hold every
     pod in ContainerCreating.
{{- else }}
     certificate.enabled is false, so this chart issues no mail certificate.
     The Ingress certificate does NOT cover the mail ports — the HA proxy
     terminates no TLS, so Stalwart has to present one itself for STARTTLS on
     25 and implicit TLS on 465/993. Provide it another way or clients will see
     a name mismatch.
{{- end }}

  6. Set send-proxy-v2 on the HA proxy. ALL OR NOTHING WITH STEP 3.
     externalTrafficPolicy is {{ .Values.service.externalTrafficPolicy }}, which it has to be — no Stalwart
     pod runs on a control-plane node, and the HA proxy delivers to the
     control-plane nodes, so Local would drop every packet. The cost is that
     kube-proxy masquerades the source address, and on a mail server a lost
     source address switches off SPF, takes DMARC with it, makes every
     blocklist report "clean", collapses per-IP rate limits onto one address,
     removes greylisting's key, and makes the auto-ban lock out every sender at
     once. The Received: chain becomes worthless — the one item there that
     destroys evidence retroactively rather than just failing.

     The PROXY v2 header restores it, for port 25 as well, because the HA proxy
     opens its own connection to the node. But a trusted network makes the
     header MANDATORY: without it a connection from there hangs until timeout,
     and with it from an untrusted network Stalwart reads "PROXY TCP4 ..." as
     an SMTP command. Test on a spare port with its own frontend, and move 25
     only once a Received: line shows the real sender address.

  7. Restart the pods — "a newly added listener binds only on the next start" —
     and set bootstrap.enabled: false in the same round.

  8. Raise nodes.mx.replicas only after the "mx" ClusterRole exists (step 3),
     and publish DNS (below).

DNS A MAIL SERVER CANNOT RUN WITHOUT
  None of this is rendered by the chart; all of it is required.
    MX          {{ .Values.ingress.domain | default "<your mail domain>" }}.            →  this deployment's public name
    SPF         TXT "v=spf1 ..." naming the HA proxy's address as a sender
    DKIM        TXT at <selector>._domainkey — the key is generated IN Stalwart
                (Domains → DKIM), so this record is published after step 3
    DMARC       TXT at _dmarc — start at p=none with rua= and tighten only once
                the reports are clean
    REVERSE DNS PTR for the HA PROXY's public address, matching its forward
                name. Not the node's and not the pod's: the HA proxy is what
                other mail servers see connecting. Many receivers reject mail
                outright when it is missing, and nothing in Kubernetes can set
                it — it belongs to whoever owns the IP range.

WHAT A GREEN POD PROVES HERE, AND WHAT IT DOES NOT
  Liveness asks /healthz/live, readiness and startup ask /healthz/ready.
  Read off the source at v0.16.24: /healthz/ready returns 200 as soon as a data
  store HANDLE exists — it does not query the database. So Ready does not mean
  the database is reachable, and it does not catch a pod that fell back to an
  ephemeral in-memory store because config.json was missing. A green pod here
  means the process is answering HTTP.

  The probe intervals are calculated, not chosen: Stalwart rate-limits
  anonymous /healthz requests to 100 per 60s PER SOURCE IP, probes arrive from
  the node IP, and so every pod on a node shares one bucket. The defaults cost
  6 requests/min/pod and leave room for about 16 pods per node. Tightening them
  produces 429s, and a 429 on liveness is a restart loop.
{{- $anySpread := false }}
{{- range $group, $spec := $nodes }}
{{- $r := int (include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas)) }}
{{- $s := fromYaml (include "stalwart.groupSettings" (dict "group" $group "given" $spec)) }}
{{- if and $s.spreadAcrossNodes (ge $r 2) }}{{ $anySpread = true }}{{ end }}
{{- end }}

DISTRIBUTION AND DISRUPTION BUDGET — WHAT EACH GROUP ACTUALLY GOT
  Both objects are conditional, so this is the list rather than something to
  infer from `kubectl get pdb` and an `-o yaml`.
{{- range $group, $spec := $nodes }}
{{- $r := int (include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas)) }}
{{- $s := fromYaml (include "stalwart.groupSettings" (dict "group" $group "given" $spec)) }}

  {{ $group }} — {{ $r }} effective replica(s)
{{- if and $s.spreadAcrossNodes (ge $r 2) }}
    SPREAD   ENFORCED. maxSkew 1 on kubernetes.io/hostname, whenUnsatisfiable
             DoNotSchedule, nodeAffinityPolicy Ignore, selecting this group's
             own pods only — a pod of "{{ $group }}" sharing a node with a pod of
             another group stays fine. This is a PROMISE, so the price is a Pending pod
             rather than a co-located one: if fewer nodes are available than
             this group has replicas, the surplus replica does not schedule and
             there is NO scheduling event to read. Check with
               kubectl -n {{ $.Release.Namespace }} get pod -l stalwart.jk-dev.de/node-group={{ $group }} -o wide
{{- else if $s.spreadAcrossNodes }}
    SPREAD   NOT RENDERED, because this group has {{ $r }} effective replica(s) and the
             rule needs two. {{ if eq $r 0 }}With no pod at all there is nothing to
             distribute{{ else }}With one pod every placement satisfies it, so the object
             would describe nothing{{ end }}. Raise nodes.{{ $group }}.replicas to 2 to get it —
             and note that doing so changes the POD TEMPLATE, so {{ if eq $r 0 }}the group
             starts with the constraint already in place{{ else }}the pod running now is
             replaced in the same rollout round, not merely joined{{ end }}.
{{- else }}
    SPREAD   OFF — nodes.{{ $group }}.spreadAcrossNodes is false. Nothing keeps two
             pods of this group off the same node, so its replicas are
             redundancy on paper only unless your own
             nodes.{{ $group }}.topologySpreadConstraints or affinity does the job.
{{- end }}
{{- if and $s.podDisruptionBudget.enabled (ge $r 2) }}
    PDB      {{ $release }}-{{ $group }}-pdb, minAvailable {{ $s.podDisruptionBudget.minAvailable | int64 }}. A node drain cannot take
             all {{ $r }} replicas of this group at once.
{{- else if $s.podDisruptionBudget.enabled }}
    PDB      NONE, because the effective replica count is {{ $r }} and a budget needs
             two. Deliberate: {{ if eq $r 0 }}with no pod at all the object would only
             describe a promise about nothing{{ else }}minAvailable 1 with a single replica
             forbids every voluntary eviction, so `kubectl drain` would block
             forever on a pod nothing may move — and the error would name the
             PDB rather than the replica count{{ end }}.
{{- else }}
    PDB      NONE — nodes.{{ $group }}.podDisruptionBudget.enabled is false. A node
             drain may take every replica of this group at once.
{{- end }}
{{- end }}
{{- if $anySpread }}

  ONE VERSION CAVEAT, AND IT FAILS SILENTLY: nodeAffinityPolicy is a beta field
  — on by default from Kubernetes 1.26, GA with the feature gate
  NodeInclusionPolicyInPodTopologySpread in 1.33. An older API server, or one
  with the gate off, PRUNES IT WITHOUT A WORD, and then a nodeSelector or
  affinity that narrows this group onto a single node no longer produces a
  Pending pod but two CO-LOCATED ones, with the constraint reported as
  satisfied. Compare the live object against what was rendered:
    kubectl -n {{ .Release.Namespace }} get sts -l stalwart.jk-dev.de/release={{ $release }} \
      -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.topologySpreadConstraints[*].nodeAffinityPolicy}{"\n"}{end}'
  An empty column where "Ignore" should be is the whole symptom.

  AND A CEILING THAT IS A RULE, NOT A NUMBER: this chart leaves
  nodeTaintsPolicy at its API default of Ignore, so every node in the cluster
  counts as a domain — control-plane nodes included, where no Stalwart pod ever
  runs. They hold the minimum at 0 permanently, so with maxSkew 1 a group can
  never place more replicas than there are SCHEDULABLE WORKERS. It moves with
  the cluster: add a worker and it rises, remove one and it falls. Past that
  point a group needs spreadAcrossNodes: false or an own constraint with
  ScheduleAnyway. This ceiling comes from nodeTaintsPolicy and existed before
  nodeAffinityPolicy was set — the two fields are not a package.
{{- end }}
{{- if .Values.ingress.enabled }}

WEB UI AND JMAP — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
  https://{{ .Values.ingress.domain }}  →  node group "{{ $ingressGroup }}"
  Certificate requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" cluster issuer:
    kubectl -n {{ .Release.Namespace }} get certificate tls-{{ $release }}-ingress

  AFTER BOOTSTRAP, PORT {{ (index $ports "http").containerPort }} IS NOT THE ADMIN PORT ANY MORE. Upstream moves
  administration to https://<hostname>/admin once setup is complete, so this
  Ingress only works while an HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the
  database (step 3). Between finishing setup and creating that listener the
  Ingress resolves to a port that answers nothing.
{{- if $mtaStsHosts }}

{{- $mtaStsGroups := fromYaml (include "stalwart.mtaSts.ingressGroups" .) }}

MTA-STS — {{ len $mtaStsHosts }} POLICY HOST(S) IN {{ len $mtaStsGroups.order }} INGRESS OBJECT(S), INGRESS MODE
  All of them →  node group "{{ $ingressGroup }}", path {{ $mtaStsPath }} only (pathType Exact)

  ONE OBJECT PER CLUSTER ISSUER, NAMED BY INDEX — so the name does not say
  which issuer it carries, and this is the mapping. Read it here rather than
  guessing from the object names:
{{- range $i, $issuer := $mtaStsGroups.order }}
{{- $grp := index $mtaStsGroups.groups $issuer }}

  {{ printf "%s-mta-sts-ingress%s" $release (ternary "" (printf "-%d" $i) (eq $i 0)) }}
      cluster issuer: {{ if $issuer }}"{{ $issuer }}" — cert-manager requests these certificates{{ else }}NONE — no annotation, so this release requests nothing; every secret below has to exist already{{ end }}
  {{- range $secret := $grp.tlsOrder }}
      secret {{ $secret }}
    {{- range $host := index $grp.tls $secret }}
        https://{{ $host }}{{ $mtaStsPath }}
    {{- end }}
  {{- end }}
{{- end }}

  Why one object per issuer rather than one for everything: the cert-manager
  issuer is an object-level ANNOTATION while the TLS secret is a per-tls-entry
  field, so a single object cannot both request a certificate and leave another
  host's secret alone. Hosts sharing a secret share one tls: entry, so one
  certificate with several names is requested once instead of once per host:
    kubectl -n {{ .Release.Namespace }} get ingress -l app={{ $release }}

  AND MIND THE ORDER OF mtaSts.hosts. The index above runs over the issuer
  GROUPS, which are derived from the list — so reordering entries can change
  how many objects there are and which index carries which issuer, renumbering
  objects that were not themselves moved. Kubernetes then deletes and recreates
  them, which takes their Certificate objects with them. No certificate is
  RE-ISSUED by that, because the Secrets survive it (cert-manager leaves them
  alone unless it runs with --enable-certificate-owner-ref, which this cluster
  does not set) — but that is a property of the CLUSTER, not of this chart. If
  that flag is ever switched on, a reorder means a fresh ACME request per
  renumbered object.

  TWO THINGS THIS RELEASE DOES NOT DO, AND BOTH ARE SILENT WHEN MISSING:

    1. IT DOES NOT CREATE THE DOMAIN. The domains live in Stalwart's data
       store (step 3), not in any chart value, so a route to a domain that is
       not in the data store answers 404 at a perfectly routed endpoint —
       every object here green.
    2. IT DOES NOT CREATE THE DNS RECORD. MTA-STS also needs a TXT record at
       _mta-sts.<domain>; without it no sending MTA ever looks for the policy.

  AND THE FAILURE IS ON THE SENDER'S SIDE, NOT HERE: an MTA that finds no
  policy — or hits a certificate that does not match — just delivers in the
  clear and logs nothing in this cluster. So verify each one from outside:
{{- range $entry := $mtaStsHosts }}
    curl https://{{ $entry.host }}{{ $mtaStsPath }}
{{- end }}

  MIND THE DEEPER NAMES: mta-sts.sub.<domain> is one label below
  *.<domain>, and a wildcard certificate matches exactly one label — so a
  parent wildcard does NOT cover it. That is what the per-entry secret is for.
{{- end }}
{{- else if $httpRoute.enabled }}

WEB UI AND JMAP — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
  https://{{ . }}
{{- end }}
      →  node group "{{ $ingressGroup }}", one route, not one per group

  This release renders ONE HTTPRoute ({{ $release }}-httproute) and nothing
  else of the HTTP routing path — no Gateway, no class, no certificate for this
  hostname, no policy object. Three things therefore belong to the cluster:

    1. The Gateway-API CRDs. Without them this install would have failed, so
       they are present — nothing to check.
    2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
         {{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
       must exist AND its listener must allow routes from this namespace
       ({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows only in
       the route's status:

         kubectl -n {{ .Release.Namespace }} get httproute {{ $release }}-httproute -o yaml

       Read status.parents[].conditions — "Accepted: True" and
       "ResolvedRefs: True" is a working attachment; an empty status means no
       controller has looked at the route at all.
    3. The TLS certificate for the HTTP hostname, on the Gateway's listener.
       ingress.clusterIssuer is not needed FOR THAT — but read the next
       paragraph before removing it.

  THE MAIL CERTIFICATE STAYS, AND MUST NOT BE CLEANED UP.
  {{ if .Values.certificate.enabled }}certificate.enabled is true, so this release still renders
  {{ $release }}-mail-certificate.{{ else }}certificate.enabled is false, so no mail certificate is rendered here.{{ end }}
  That object has nothing to do with the Ingress or the Gateway: it covers
  STARTTLS on the SMTP port and implicit TLS on the submission and IMAPS
  ports, and those connections pass through no HTTP proxy at all. So in this mode
  ingress.domain is STILL REQUIRED (the certificate's dnsNames read it) and
  ingress.clusterIssuer is still required unless certificate.clusterIssuer is
  set. Deleting the Certificate as part of a migration drops SMTP and IMAP back
  to Stalwart's self-signed certificate: clients see a name mismatch while
  every Kubernetes object in this release stays green.

  SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they were not
  routed by the Ingress. They keep arriving through the per-port NodePort
  Services. A Gateway could carry them as TLSRoute/TCPRoute listeners; this
  chart renders neither.

  AND NOW THE ONE THAT WILL BITE: CORS, AND THE WEBMAIL.

  This installation's live Ingress carries, set by the operator through
  ingress.annotations and NOT by this chart:

    nginx.ingress.kubernetes.io/enable-cors:        true
    nginx.ingress.kubernetes.io/cors-allow-origin:  https://<the webmail host>
    nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
    nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type

  That is what lets the browser-side webmail (the bulwark chart) call this
  server's JMAP endpoint at all. IN THIS MODE IT IS GONE, and the failure is
  entirely on the client side: a console error in the browser, and nothing
  whatsoever in this server's logs.

  The Gateway API does have a CORS filter — HTTPRoute filters carry a `cors`
  field in the CRDs this repository pins — so this is not a missing feature of
  the API. THIS CHART STILL DOES NOT RENDER IT, and knowingly:

    - the chart has no CORS values to render it from (the origin and the method
      list are the operator's, and the chart sets no CORS annotations in
      Ingress mode either),
    - that filter's support level is "Extended", so an implementation may
      reject the route — and rejection is whole-route, which would take the
      WebUI and JMAP down together — or, worse, accept it and not honour it,
    - and the part a plain header filter COULD do is the part that does not
      help here: the browser's PREFLIGHT has to be answered by the gateway
      rather than by Stalwart, and because the allowed headers include
      Authorization, EVERY request from the webmail is preflighted.

  So: configure CORS AT THE GATEWAY, with that implementation's own support,
  BEFORE you move this host. Then TEST IT FROM THE WEBMAIL IN A BROWSER — curl
  sends no preflight, so it will pass while the webmail fails.

  ingress.annotations is inert in this mode in general: the Gateway API has no
  annotation channel for controller behaviour.

  AFTER BOOTSTRAP, PORT {{ (index $ports "http").containerPort }} IS NOT THE ADMIN PORT ANY MORE, and the hostname
  the WebUI sends administrators to lives IN THE DATABASE, not in any value
  here. Upstream moves administration to https://<the configured server
  hostname>/admin once setup is complete, so this route only works while an
  HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the database (step 3).
{{- if $mtaStsHosts }}

MTA-STS — ONE ROUTE, {{ len $mtaStsHosts }} HOSTNAME(S), GATEWAY MODE
{{- range $entry := $mtaStsHosts }}
  https://{{ $entry.host }}{{ $mtaStsPath }}
{{- end }}
      →  node group "{{ $ingressGroup }}", path {{ $mtaStsPath }} only (type: Exact)

  ONE HTTPRoute ({{ $release }}-mta-sts-httproute) for all of them, attached to
  the same Gateway(s) as the WebUI route above. Nothing differs per host here —
  same path, same backend, no filter — so a hostname list is the whole object:
    kubectl -n {{ .Release.Namespace }} get httproute {{ $release }}-mta-sts-httproute -o yaml

  THE TLS FIELDS OF EACH ENTRY ARE INERT IN THIS MODE. tlsSecretName and
  clusterIssuer are read only on the Ingress path; here the certificate belongs
  to the Gateway's LISTENER, and this release renders none for these hostnames.
  So before switching these hosts over, check the listener really covers every
  one of them — AND MIND THE DEPTH: mta-sts.sub.<domain> is one label below
  *.<domain>, and a wildcard matches exactly one label, so a wildcard listener
  for the parent domain does NOT terminate TLS for it. The symptom is a TLS
  error at the SENDING MTA, which nothing in this cluster logs.

  TWO THINGS THIS RELEASE STILL DOES NOT DO, both silent when missing: it does
  not create the DOMAIN in Stalwart's data store (step 3) — a route to a domain
  that is not there answers 404 at a correctly routed endpoint — and it does
  not create the _mta-sts.<domain> TXT record, without which no sending MTA
  looks for the policy at all. An MTA that finds no policy delivers in the
  clear and reports nothing here, so verify each one from outside:
{{- range $entry := $mtaStsHosts }}
    curl https://{{ $entry.host }}{{ $mtaStsPath }}
{{- end }}
{{- end }}
{{- else }}

NEITHER INGRESS NOR HTTPROUTE
  ingress.enabled and httpRoute.enabled are both false, so there is no Ingress,
  no HTTPRoute and no WebUI certificate.
  ingress.domain STAYS REQUIRED even so — the mail Certificate reads it for its
  dnsNames, and without it the certificate would carry no name at all.
{{- if $mtaStsHosts }}

  AND mtaSts.hosts IS SET ({{ len $mtaStsHosts }} entr{{ if eq (len $mtaStsHosts) 1 }}y{{ else }}ies{{ end }}) BUT RENDERS NOTHING. Those routes need a
  routing mode: they follow the one that is on, and with both off there is no
  object to carry them. Nothing is wrong with the list — switch on
  ingress.enabled or httpRoute.enabled and the routes appear with it.
{{- end }}
{{- end }}
{{- if .Values.valkeySubchart.enabled }}

THE VALKEY SUBCHART IS ENABLED
  Reachable at {{ $valkeyHost }}:{{ .Values.externalServices.valkey.port }} — this repo's own valkey chart,
  pulled from the published index. Enter that address as the in-memory store in
  step 3; the coordinator's Default variant reuses the same connection.

  ITS 1PASSWORD ITEM IS A SEPARATE ONE, AND ITS FIELD IS NOT A PASSWORD:
    valkey.secrets.authSecretRef points at
      {{ .Values.valkey.secrets.authSecretRef | default "<NOT SET - the subchart's own required() will abort the render>" }}
    and that item carries ONE field, "auth-conf", whose value is the complete
    configuration line

      requirepass <password>

    not a bare password. The valkey chart mounts the file and includes it, and
    an init container refuses to start the pod unless the line is really there —
    because the alternative failure is an open server that looks healthy.

  IT IS CONFIGURED AS A CACHE, NOT A QUEUE
    persistence.enabled false + maxmemoryPolicy allkeys-lru. Everything Stalwart
    keeps here — rate-limiter counters, auth tokens, sessions, auto-ban entries,
    the coordination pub/sub — is reconstructible after a restart; nothing in it
    is the system of record.

    The cost of allkeys-lru, so it is not a surprise: under memory pressure keys
    are discarded, so the rate limiters and the auto-ban FAIL OPEN. Size memory
    for the working set rather than switching to noeviction — with noeviction
    Stalwart's writes to the store start erroring, which takes out
    authentication instead of just the limits.

  scaleDown and checksum/config stop at the subchart boundary, so scaling this
  release to zero does NOT stop the Valkey, and a new valkey chart release
  reaches you only when stalwart itself is republished.
{{- end }}
{{- if not .Values.networkPolicy.enabled }}

NETWORKPOLICY IS OFF
  networkPolicy.enabled is false, so nothing limits reachability except the
  Services. That removes the second line of defence against the fail-open in
  step 3: a group that accidentally became a full node is then reachable on
  every port it bound.
{{- else if not .Values.networkPolicy.probeSources }}

NETWORKPOLICY: THE HTTP PORT IS OPEN INSIDE THE CLUSTER
  networkPolicy.probeSources is empty, so the rule for port {{ (index $ports "http").containerPort }} renders with
  no "from" selector. Deliberate: the kubelet probes from the worker node's IP,
  which this chart cannot know, and a wrong guess blocks every probe — a pod
  that never becomes Ready reads as a broken application rather than as a
  policy. The port carries no nodePort, so this is open inside the cluster, not
  from outside. Narrow it with networkPolicy.probeSources, and then add the
  {{ if $httpRoute.enabled }}GATEWAY's namespace (not an ingress controller's — in Gateway mode the
  traffic arrives from the Gateway's data-plane pods, which usually live in a
  namespace of their own){{ else }}ingress controller's namespace{{ end }} to networkPolicy.allowedNamespaces or the
  {{ if $httpRoute.enabled }}route{{ else }}Ingress{{ end }} goes dark.
{{- end }}
