Mail Sink

Neither event-dev nor event-stage can send email to the outside world. Every Java service in those namespaces relays through an in-cluster GreenMail server that has no route off the cluster, and a SnappyMail webmail client renders what GreenMail captured.

Why a sink rather than a recipient override

event-stage is refreshed from production nightly by the event-clone-prod-to-stage CronJob, so its database holds real customer email addresses. A recipient override would redirect mail; a sink makes escape impossible, because there is no path to an external relay at all.

The sink also exposes an HTTP API. That turns "did the message go out, and to whom" from a log inspection into an assertion a test can make, which is what allows recipient-selection logic to be exercised against production-shaped data.

Before this was in place, stage pointed config.mail.host at myriadevents-co-za.mail.protection.outlook.com — a live Microsoft 365 relay — with working credentials in the manifest. The only thing preventing a live send was an application feature flag.

Deployments

Application Namespace Path Webmail

greenmail-dev

event-dev

event-membership/greenmail-dev

snappymail-dev

event-dev

event-membership/snappymail-dev

https://event-mail-dev.idealogic.co.za

greenmail-stage

event-stage

event-membership/greenmail-stage

snappymail-stage

event-stage

event-membership/snappymail-stage

https://event-mail-stage.idealogic.co.za

The stage manifests are copies of the dev ones. Only the ArgoCD destination namespace, the GreenMail service hostname in the SnappyMail configmap, and the ingress hostname differ. All four use automated sync with prune and self-heal.

GreenMail

Image

greenmail/standalone:latest

Service

greenmail.<namespace>.svc.cluster.local (ClusterIP)

SMTP

25

POP3

110

IMAP

143

API

8080

Authentication

Disabled (-Dgreenmail.auth.disabled)

Probes

/api/service/readiness

Storage

In-memory — messages do not survive a pod restart

Accounts are created implicitly. Any address that receives a message becomes a mailbox, and any passphrase authenticates it.

Asserting delivery through the API

# Every mailbox that has received something
curl -s http://greenmail:8080/api/user

# The messages for one recipient: subject, recipient, full MIME
curl -s http://greenmail:8080/api/user/[email protected]/messages

There is no /api/mail endpoint — it returns 404. Messages are reachable only per recipient, through /api/user/{email}/messages.

SnappyMail

Image

djmaze/snappymail:latest

Service

snappymail (ClusterIP, port 8888)

Storage

256Mi Longhorn PVC (snappymail-data)

TLS

cert-manager, letsencrypt-prod

Runtime user

uid 82

Sign in with any address, on any domain, and any passphrase. Every mailbox GreenMail has captured can be opened, and each one can send.

Configuration is generated at container start by init-config.sh, delivered through the snappymail-config ConfigMap and invoked from the deployment’s postStart hook. The script owns the domain directory and rebuilds it on every start, so a domain added through the admin panel does not survive a restart.

Any domain

The script writes a single domains/default.ini pointing IMAP and SMTP at the in-cluster GreenMail service. SnappyMail stores the * wildcard domain under the name default, so every address whose domain has no file of its own resolves to it. event-stage holds real customer addresses on arbitrary domains, which a fixed domain list cannot cover.

Two things SnappyMail seeds on first start would defeat this, and the script removes both:

domains/default.json

Points at localhost:143. SnappyMail reads a .json ahead of an .ini of the same name, so while it exists every unlisted domain fails with Can’t connect to host "tcp://localhost:143".

domains/disabled

Refuses gmail.com, hotmail.com, outlook.com, yahoo.com and qq.com before any lookup — where most customer mail lands. The script empties it. The file must still exist: SnappyMail re-seeds both files only while it is absent.

System folders

GreenMail creates a mailbox holding only INBOX, loses every folder when it restarts, and advertises no SPECIAL-USE. SnappyMail refuses to send until Sent, Drafts, Spam, Trash and Archive are mapped, so a fresh mailbox stops at Select system folders with nothing to select.

The greenmail-folders plugin, installed and enabled by the script, handles this on every login: it creates Sent, Drafts, Junk, Trash and Archive where they are missing and maps them for the account. A message sent from SnappyMail is then kept in the sender’s Sent.

After GreenMail restarts, sign out and back in. The folders are recreated at login, and a session that began before the restart has nowhere to save sent mail. The restart has emptied every mailbox in any case.

Mail the EMS services send is not copied to the sender’s Sent. They submit over plain SMTP, which has no concept of a sent folder, and GreenMail delivers only to the envelope recipients. To see what a service sent, open the recipient’s mailbox.

init-config.sh must end by chowning its output back to uid 82:

chown -R 82:82 "$DATA_DIR"

The postStart hook runs as root, so every directory the script creates lands as root:root 0755. SnappyMail’s PHP runs as uid 82 and cannot then create .sessions/ inside them, and the first login fails with Failed to create directory ….

The fix-permissions initContainer looks like it covers this but cannot — it runs to completion before the main container starts, so it never sees what postStart goes on to create. The chown at the end of the script is the only point that runs after the tree exists.

Service wiring

Every Java service in these namespaces points at the sink with authentication and TLS off:

config:
  mail:
    host: greenmail.event-stage.svc.cluster.local
    port: 25
    username: ""
    password: ""
    protocol: smtp
    tls: false
    properties.mail.smtp:
      auth: false
      starttls.enable: false

Set in event-admin-service-stage.yml, registration-portal-stage.yml and membership-ui-stage.yml, and in the corresponding -dev manifests. admin-portal and ems-mcp-server send no mail and configure none.

Changing config.mail. rewrites the ConfigMap but does *not restart the pods — the chart puts no ConfigMap checksum on the pod template, and Spring reads its configuration once at startup. Until the affected Deployments are restarted, they keep relaying through whatever host they started with.

admin-service is the exception only when a change also alters the pod spec, which forces a rollout as a side effect.

kubectl -n event-stage rollout restart \
  deployment/stage-event-admin-service \
  deployment/stage-registration-portal \
  deployment/stage-membership-ui

Health reporting

The mail health indicator is not available. The service charts hardcode management.health.mail.enabled: false into the rendered application.yaml, and no manifest setting can override it:

  • config.extraProperties merges at the document root, so a second management: key collides and Spring Boot rejects the duplicate.

  • extraEnv loses too. These services load the ConfigMap through Spring Cloud Kubernetes in bootstrap mode, where overrideSystemProperties defaults to true and the bootstrap composite is inserted ahead of systemEnvironment — so the ConfigMap outranks container environment variables.

Until the chart templates the value, reachability is established through the GreenMail API rather than /management/health.

Verifying the sink

# GreenMail is up
kubectl -n event-stage run probe --rm -i --restart=Never \
  --image=curlimages/curl -- \
  curl -s http://greenmail:8080/api/service/readiness

# No service still references an external relay
kubectl -n event-stage get cm -o yaml | grep -c mail.protection.outlook.com   # expect 0

Then send a message and confirm it appears both in the API and in the webmail client.