Skip to content

(Story #2554) Route QA email to a shared maildev inbox - #2555

Open
herzog0 wants to merge 4 commits into
developfrom
teo/2554-maildev-k8s
Open

(Story #2554) Route QA email to a shared maildev inbox#2555
herzog0 wants to merge 4 commits into
developfrom
teo/2554-maildev-k8s

Conversation

@herzog0

@herzog0 herzog0 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Ref #2554

Why

Non-production deployments send real email to real recipients. config/settings.py hardcoded EMAIL_BACKEND to the Mailgun Anymail backend for every environment except LOCAL_DEVELOPMENT, and both values-stage-gke.yaml and values-cppal-dev-gke.yaml supply live Mailgun credentials and a verified sender domain.

That blocks QA work which needs to sign in as existing users with real contribution history, because the sign-in verification email is delivered to that user's actual inbox. More broadly, any transactional email triggered while testing reaches real recipients, consumes email quota, and generates bounces and spam signals against the sender domain.

Local development already solves this with the maildev container in docker-compose.yml. This brings the same catch-all inbox to stage and cppal-dev, reachable in a browser so QA does not need cluster credentials.

What changed

config/settings.py - a new CATCH_ALL_EMAIL flag (default false) makes an environment send through Django's SMTP backend to EMAIL_HOST/EMAIL_PORT instead of Mailgun. Django raises ImproperlyConfigured at startup if the flag is ever enabled while X_DEPLOYMENT_ENV is production.

The flag is opt-in per values file rather than derived from the environment name. Deriving it (from X_DEPLOYMENT_ENV, or by pattern-matching DJANGO_FQDN) would silently switch environments that have no maildev pod, turning working mail into connection errors, and would re-enable real sending if a hostname were ever renamed.

kube/boost/templates/maildev.yaml (new) - everything behind a maildevInstall flag, following the existing redisInstall / celeryInstall convention so production cannot pick it up:

  • Deployment (pinned maildev/maildev:2.2.1, replicas: 1) and a ClusterIP Service exposing SMTP 1025 and HTTP 1080.
  • For Gateway environments only: an HTTPRoute publishing the inbox at /maildev/ on the environment's mainFqdn, plus a HealthCheckPolicy and a GCPBackendPolicy.

Values files - maildevInstall: true and CATCH_ALL_EMAIL: "true" in the two QA files; maildevInstall: false plus maildevImageTag as chart defaults. Production is untouched. The MAILGUN_* entries stay in the QA files but are inert, so reverting is a one-line change.

Docs - new docs/email.md (routing per environment, how to reach the inbox, how it is wired), plus CATCH_ALL_EMAIL / EMAIL_* entries in docs/env_vars.md and an index line in docs/README.md.

How the inbox is exposed

The route is added at the existing GKE Gateway, so the Google load balancer that already fronts the site gains one URL-map rule. It reuses the existing static IP and certificate: no new hostname, no DNS record, no certificate, no LoadBalancer, and no change to the app's nginx config. Traffic to /maildev/ never reaches Django, gunicorn or the app pods.

Access is gated by maildev's own HTTP basic auth (MAILDEV_WEB_USER / MAILDEV_WEB_PASS).

Three details that are load-bearing, all verified against maildev/maildev:2.2.1:

  • MAILDEV_BASE_PATHNAME=/maildev makes maildev serve itself under the prefix, so no URL rewriting is needed at the edge, and the socket.io endpoint moves under the same prefix where the PathPrefix rule already covers it.
  • The HealthCheckPolicy targets /maildev/healthz, the only path maildev exempts from basic auth. A health check against / returns 401, which the load balancer reads as an unhealthy backend and answers with 503.
  • The GCPBackendPolicy raises timeoutSec. On Google load balancers the backend timeout is the maximum lifetime of a WebSocket connection rather than an idle timeout, so the 30 second default would sever the inbox's live-update socket every 30 seconds.

Deploy notes

A maildev-auth Secret must exist in stage and in cppal-dev before deploying, or the maildev pod will not start. It is not in the repo because this repository is public. It follows the same convention as the 13 secrets this chart already consumes (pg, mailgun, django-secret-key, and so on): created out-of-band by an operator, referenced through secretKeyRef. The chart ships no kind: Secret and CI creates none.

kubectl -n stage create secret generic maildev-auth \
  --from-literal=web_user='<user>' --from-literal=web_pass='<password>'

kubectl -n cppal-dev create secret generic maildev-auth \
  --from-literal=web_user='<user>' --from-literal=web_pass='<password>'

Name, keys and type must be exactly maildev-auth / web_user + web_pass / Opaque, because the Deployment references them verbatim. A Secret with the right name but different keys fails in exactly the same way as a missing one. Do not create it in production, where nothing consumes it.

Ordering matters, and the failure mode is fail-closed rather than degraded. If the Secret is absent at deploy time the pod stays in CreateContainerConfigError, never passes readiness, and so never joins the Service endpoints, while CATCH_ALL_EMAIL=true has already switched that environment's Django to SMTP. Every outbound email in that environment then fails with a connection error instead of falling back to Mailgun. Recovery is to create the Secret and kubectl -n <ns> rollout restart deploy/maildev.

Post-deploy checks, per namespace:

kubectl -n stage rollout status deploy/maildev --timeout=180s
kubectl -n stage get endpoints maildev          # must list a pod IP on 1025 and 1080
kubectl -n stage exec deploy/boost -c wsgi -- python -c \
  "import smtplib; smtplib.SMTP('maildev',1025).noop(); print('reachable')"

The values-cppal-dev-gke.yaml change only takes effect once the cppalliance/website-v2-qa fork's cppal-dev branch picks up this commit.

Two further things to confirm on the first deploy: that the Gateway controller auto-attached a NEG to the maildev Service (otherwise add a cloud.google.com/neg annotation), and that the GCPBackendPolicy CRD is present (kubectl get crd | grep gcpbackendpolicies; HealthCheckPolicy from the same GKE bundle is already used in gateway.yaml).

Standardizing this Secret (a chart-templated Secret fed from a CI secret, or External Secrets / GCP Secret Manager) is deliberately out of scope. Doing it for the 14th secret while the other 13 stay manual trades one inconsistency for a worse one; it is worth a separate ticket covering all of them at once.

Verification

helm template across all four values files:

  • production: no maildev objects at all, Mailgun backend unchanged.
  • stage and cppal-dev: Deployment, Service, HTTPRoute, HealthCheckPolicy and GCPBackendPolicy all render; HTTPRoute resolves to www-boost-stage / www-boost-dev on the correct hostname; CATCH_ALL_EMAIL, EMAIL_HOST and EMAIL_PORT reach every container that renders .Values.Env - boost/wsgi, boost/nginx, celery-worker, celery-beat and the migrations Job - which covers all workloads that can send mail, Celery included.
  • Chart defaults (values.yaml): no maildev objects.
  • helm lint is clean for all three GKE values files.

Settings behavior, exercised in the project image:

Configuration Result
LOCAL_DEVELOPMENT=true SMTP backend, maildev:1025
No flag, X_DEPLOYMENT_ENV=production Mailgun backend with MAILGUN_* in ANYMAIL
Flag on, X_DEPLOYMENT_ENV=stage or dev SMTP backend, maildev:1025, empty ANYMAIL
Flag on, X_DEPLOYMENT_ENV=production ImproperlyConfigured at startup

Also deployed on a local Kubernetes cluster, applying the chart-rendered manifest rather than a hand-written copy, which exercises the parts a bare container cannot:

  • deploy/maildev reached 1/1 Ready, so /maildev/healthz genuinely answers 200 under MAILDEV_BASE_PATHNAME. That is the exact path the HealthCheckPolicy probes.
  • Inbox returns 401 unauthenticated and 200 with the Secret's credentials; /maildev/healthz returns 200 with no credentials; every other path is gated, since maildev's basic-auth middleware runs ahead of routing.
  • SMTP to the bare hostname maildev on 1025 over cluster DNS delivered, matching how the app pods resolve EMAIL_HOST.
  • End to end with Django configured exactly as the chart configures it: sendtestemail and send_mail(...) to a real-looking address were captured by the pod rather than delivered, and read back through the authenticated API.

Against the pinned image directly: basic auth, the auth-exempt health path, relative asset resolution under the prefix and a real 101 Switching Protocols upgrade at /maildev/socket.io/ were all confirmed.

pre-commit run passes on all changed files.

Not included

  • A dedicated hostname for the inbox (would need a DNS record and a Certificate Manager certmap entry).
  • Per-person authentication (SSO / oauth2-proxy) or IP allowlisting.
  • Message retention across pod restarts; maildev holds messages in memory by design.

Risks and considerations

Non-production email is now fail-closed. If the maildev pod is down, sends raise instead of being delivered, and Celery tasks that send mail will error or retry. That is the correct trade for QA, since nothing silently escapes to real users, but it is a behaviour change from "Mailgun always accepts".

The maildev-auth Secret is a manual out-of-band prerequisite that a reviewer cannot see in the diff. See Deploy notes.

Known limitation

maildev applies basic auth as Express middleware, but its socket.io channel attaches to the raw HTTP server and bypasses that middleware, emitting full message payloads. The password gate deters casual access to the UI; it is not a security boundary for message contents. The URL should not be published. Accepted for a QA inbox.

Summary by CodeRabbit

  • New Features

    • Added optional catch-all email routing for non-production environments.
    • Added Maildev deployment support for development and staging environments, including web and SMTP access.
    • Added safeguards preventing catch-all email from being enabled in production.
  • Documentation

    • Documented email routing, environment settings, Maildev usage, QA inbox access, and SMTP configuration.
    • Added a link to the email routing documentation.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Email routing now supports catch-all delivery through Maildev in local, development, and stage environments. Production rejects catch-all mode. Helm templates deploy Maildev conditionally, and documentation covers configuration and QA inbox access.

Changes

Email routing and Maildev delivery

Layer / File(s) Summary
Email routing safeguards
config/settings.py
Adds CATCH_ALL_EMAIL and DEPLOYMENT_ENVIRONMENT. Production rejects catch-all mode. Local and catch-all environments use Maildev SMTP.
Maildev deployment and environment wiring
kube/boost/templates/maildev.yaml, kube/boost/values.yaml, kube/boost/values-cppal-dev-gke.yaml, kube/boost/values-stage-gke.yaml
Adds conditional Maildev Deployment, Services, GCE Gateway resources, health checks, and backend timeout settings. Development and stage enable Maildev catch-all routing.
Email routing documentation
docs/README.md, docs/email.md, docs/env_vars.md
Documents email backends, safeguards, Maildev access, Kubernetes configuration, and environment variables.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Application
  participant MaildevSMTP
  participant QAInbox
  Application->>MaildevSMTP: Send catch-all email
  MaildevSMTP->>QAInbox: Store email for web access
  QAInbox->>MaildevSMTP: Request /maildev inbox
Loading

Possibly related issues

  • boostorg/website-v2#2554 — Covers the QA Maildev routing, catch-all safeguards, Helm deployment, environment values, and documentation implemented here.

Suggested reviewers: sdarwin

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the primary change: routing QA email to a shared Maildev inbox.
Description check ✅ Passed The description thoroughly covers context, changes, deployment risks, verification, limitations, and operational prerequisites.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch teo/2554-maildev-k8s

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@herzog0
herzog0 force-pushed the teo/2554-maildev-k8s branch from ba2d090 to df43eae Compare August 4, 2026 18:46
@herzog0
herzog0 force-pushed the teo/2554-maildev-k8s branch from 980345f to c4ee485 Compare August 4, 2026 19:26
@herzog0
herzog0 marked this pull request as ready for review August 5, 2026 14:32
@herzog0
herzog0 requested a review from sdarwin August 5, 2026 14:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@kube/boost/templates/maildev.yaml`:
- Around line 104-112: Update the Maildev Gateway route in the manifest around
the maildev backend reference so the externally published /maildev path is
protected by Gateway-level basic authentication that applies to HTTP and
WebSocket upgrades; alternatively remove the external route entirely. If
retaining the route with authentication, update the Maildev documentation to
describe the protected Socket.IO endpoint and ensure its documented behavior
matches the deployed configuration.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43f23ddf-19e3-4f8a-be59-77c4e8d951dd

📥 Commits

Reviewing files that changed from the base of the PR and between 285679d and c4ee485.

📒 Files selected for processing (8)
  • config/settings.py
  • docs/README.md
  • docs/email.md
  • docs/env_vars.md
  • kube/boost/templates/maildev.yaml
  • kube/boost/values-cppal-dev-gke.yaml
  • kube/boost/values-stage-gke.yaml
  • kube/boost/values.yaml

Comment on lines +104 to +112
# More specific than the site's "/" rule, so it wins by path length. Also
# covers /maildev/socket.io/... which is how the inbox live-updates.
- matches:
- path:
type: PathPrefix
value: /maildev
backendRefs:
- name: maildev
port: 1080

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)(maildev\.yaml|gateway\.yaml|docs/email\.md|.*maildev.*|.*socket.*|.*auth.*yaml|.*tls.*)$' || true

echo "== maildev_template =="
cat -n kube/boost/templates/maildev.yaml

echo "== gateway_template =="
cat -n kube/boost/templates/gateway.yaml

echo "== email doc excerpt =="
sed -n '1,90p' docs/email.md | cat -n

echo "== search auth policies / Gateway auth / maildev socket =="
rg -n "authentication|auth|socket\.io|MAILDEV_WEB_USER|MAILDEV_WEB_PASS|PathPrefix|maildev|www-boost|TLS|tls|AuthorizationPolicy|RequestAuthentication|OAuth2|client-certificate|basic" kube/boost docs || true

Repository: boostorg/website-v2

Length of output: 29440


🌐 Web query:

Maildev Socket.IO bypass Express basic auth docs email.md line 45

💡 Result:

There is no evidence of a documented vulnerability in MailDev specifically titled "Maildev Socket.IO bypass Express basic auth" at a file path named "email.md" line 45. It is likely that this query conflates several distinct technical concepts: 1. MailDev: A development tool for testing emails, which uses Express for its web interface and Socket.IO for real-time updates [1][2]. It supports HTTP basic authentication via the --web-user and --web-pass flags [1][2]. 2. Socket.IO Authentication: Socket.IO connections operate independently of standard HTTP request/response cycles [3]. Historically, developers have encountered issues where Socket.IO connections were established outside of the authentication protections applied to Express routes [4][3]. This is a known architectural characteristic of Socket.IO rather than a MailDev-specific exploit [4][3]. 3. email.md: This is a separate, unrelated library (unmta/emailmd) used for rendering Markdown into email-safe HTML, which maintains its own documentation [5][6]. It does not appear to have any security-related documentation involving MailDev, Socket.IO, or basic authentication bypasses [5][6]. If you are experiencing authentication bypass issues in an application using MailDev or Socket.IO, ensure you are utilizing the latest versions of these dependencies to mitigate known vulnerabilities (such as historical CORS misconfigurations) [7][8][9][10] and follow official Socket.IO documentation regarding the use of io.engine.use to apply middlewares to WebSocket upgrade requests [4][3].

Citations:


Authorization Bypass (CWE-306): Missing Authentication for Critical Function

Reachability: External

Guard the Maildev live-update path at the Gateway.

The PathPrefix /maildev route publishes https://<mainFqdn>/maildev/socket.io/... on the public host. docs/email.md says maildev expresses basic auth as Express middleware, but its raw HTTP/server-level Socket.IO feed bypasses that middleware and can expose inbox messages and links. Add Gateway-level auth that also supports WebSocket upgrades, or stop publishing Maildev externally; update docs/email.md if the Socket.IO endpoint is protected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@kube/boost/templates/maildev.yaml` around lines 104 - 112, Update the Maildev
Gateway route in the manifest around the maildev backend reference so the
externally published /maildev path is protected by Gateway-level basic
authentication that applies to HTTP and WebSocket upgrades; alternatively remove
the external route entirely. If retaining the route with authentication, update
the Maildev documentation to describe the protected Socket.IO endpoint and
ensure its documented behavior matches the deployed configuration.

@herzog0 herzog0 changed the title (Story #2554) [WIP] Route QA email to a shared maildev inbox (Story #2554) Route QA email to a shared maildev inbox Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Spike + Task] Route all outbound email in QA environments to a shared MailDev inbox with a password-protected web UI

1 participant