From 90c740ffa88dbda3086e501db348ab37976ee290 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:49:51 +0000 Subject: [PATCH 1/2] feat(web1): add mailflow webmail stack, drop tmail webmail Replace the stopped tmail-web "webmail" stack on web1 with MailFlow 3.8.1 (unified IMAP/SMTP webmail): nginx frontend, Node backend, PostgreSQL 18 and Valkey, adapted from upstream docker-compose.ghcr.yml. - Frontend published HTTP-only on 127.0.0.1:3008; TLS stays with the host reverse proxy, upstream's Caddy profile is dropped. - Frontend and backend: cap_drop ALL, no-new-privileges, read_only with tmpfs. nginx needs CHOWN/SETUID/SETGID (temp dirs, worker drop) plus NET_BIND_SERVICE for :80/:443. - Postgres/Valkey are exempt per the data-store rule; images match the digests already pinned for twenty. - Migration 0004 composes down the old webmail project and removes /opt/containers/webmail, which the copy task would otherwise leave. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011mzrFnReayRM6LnqhZks6R --- CLAUDE.md | 2 +- .../20261004_0004_remove_webmail_stack.yml | 22 +++ .../roles/system/containers/defaults/main.yml | 6 +- .../roles/system/containers/tasks/main.yml | 7 - web1/containers/mailflow/.env.example | 37 +++++ web1/containers/mailflow/docker-compose.yml | 126 ++++++++++++++++++ web1/containers/webmail/docker-compose.yml | 24 ---- 7 files changed, 189 insertions(+), 35 deletions(-) create mode 100644 web1/ansible/migrations/20261004_0004_remove_webmail_stack.yml create mode 100644 web1/containers/mailflow/.env.example create mode 100644 web1/containers/mailflow/docker-compose.yml delete mode 100644 web1/containers/webmail/docker-compose.yml diff --git a/CLAUDE.md b/CLAUDE.md index a0b63d0f..b7a62e22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,7 @@ tmpfs: - /run # nginx (PID file) ``` -Applied to: cloudflared, webmail, n8n runner. +Applied to: cloudflared, mailflow frontend + backend, n8n runner. `calcom` is **not** read-only — its entrypoint installs NPM packages and writes a build cache on startup, which breaks under `read_only: true`. Keep `cap_drop: [ALL]` + `no-new-privileges:true`, omit `read_only`/`tmpfs`. diff --git a/web1/ansible/migrations/20261004_0004_remove_webmail_stack.yml b/web1/ansible/migrations/20261004_0004_remove_webmail_stack.yml new file mode 100644 index 00000000..61de3471 --- /dev/null +++ b/web1/ansible/migrations/20261004_0004_remove_webmail_stack.yml @@ -0,0 +1,22 @@ +--- +# Migration: 20261004_0004_remove_webmail_stack +# Description: Remove the retired tmail-web "webmail" stack (replaced by mailflow) +# Problem: dropping a stack from docker_stacks and containers/ leaves its +# (stopped) container and /opt/containers/webmail behind on the host +# Solution: compose down the old project, then delete its directory + +- name: Check for webmail compose project + ansible.builtin.stat: + path: /opt/containers/webmail/docker-compose.yml + register: webmail_compose + +- name: Remove webmail containers + community.docker.docker_compose_v2: + project_src: /opt/containers/webmail + state: absent + when: webmail_compose.stat.exists + +- name: Remove webmail project directory + ansible.builtin.file: + path: /opt/containers/webmail + state: absent diff --git a/web1/ansible/roles/system/containers/defaults/main.yml b/web1/ansible/roles/system/containers/defaults/main.yml index f009d0b1..b0da8b52 100644 --- a/web1/ansible/roles/system/containers/defaults/main.yml +++ b/web1/ansible/roles/system/containers/defaults/main.yml @@ -1,8 +1,8 @@ --- docker_stacks: - webmail: - env_file: false - state: stopped + mailflow: + env_file: true + state: present roundcube: env_file: true state: present diff --git a/web1/ansible/roles/system/containers/tasks/main.yml b/web1/ansible/roles/system/containers/tasks/main.yml index 7bc9dfa9..02cdf453 100644 --- a/web1/ansible/roles/system/containers/tasks/main.yml +++ b/web1/ansible/roles/system/containers/tasks/main.yml @@ -36,13 +36,6 @@ scope: "global" state: present -- name: Ensure webmail env.file exists - ansible.builtin.copy: - content: "" - dest: /opt/containers/webmail/env.file - force: false - mode: "0644" - - name: Ensure roundcube .env exists ansible.builtin.copy: content: "" diff --git a/web1/containers/mailflow/.env.example b/web1/containers/mailflow/.env.example new file mode 100644 index 00000000..38063977 --- /dev/null +++ b/web1/containers/mailflow/.env.example @@ -0,0 +1,37 @@ +# ── MailFlow backend ─────────────────────────────────────── +# Also read by compose for interpolation: APP_URL feeds FRONTEND_URL and +# DB_PASSWORD feeds the postgres container's POSTGRES_PASSWORD. + +# Public URL users see in the browser (invite emails, CORS) +APP_URL=https://mail.example.com + +# Host reverse proxy + MailFlow's own nginx. Safe because the frontend is only +# published on 127.0.0.1. The proxy must send X-Forwarded-Proto: https. +TRUST_PROXY_HOPS=2 + +# Secrets +# openssl rand -hex 32 +SESSION_SECRET= +# openssl rand -hex 16 +DB_PASSWORD= +# openssl rand -hex 32 — encrypts stored IMAP/SMTP credentials at rest. +# Losing or changing it means every account must be re-authenticated. +ENCRYPTION_KEY= + +# Image versions are pinned in docker-compose.yml and bumped by Renovate +UPDATE_CHECK_DISABLED=true + +# ── Web push (optional) ──────────────────────────────────── +# npx web-push generate-vapid-keys +# VAPID_PUBLIC_KEY= +# VAPID_PRIVATE_KEY= +# VAPID_SUBJECT=mailto:admin@example.com + +# ── Google OAuth for Gmail / Workspace (optional) ────────── +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# GOOGLE_REDIRECT_URI=https://mail.example.com/oauth/google/callback + +# ── IMAP tuning (optional) ───────────────────────────────── +# Max persistent IDLE connections per mail server; empty = unlimited +# IMAP_MAX_PERSISTENT_PER_HOST= diff --git a/web1/containers/mailflow/docker-compose.yml b/web1/containers/mailflow/docker-compose.yml new file mode 100644 index 00000000..bdcf1c67 --- /dev/null +++ b/web1/containers/mailflow/docker-compose.yml @@ -0,0 +1,126 @@ +--- +# MailFlow — unified IMAP/SMTP webmail (https://github.com/maathimself/mailflow) +# Adapted from upstream docker-compose.ghcr.yml: HTTP only on localhost (TLS is +# terminated by the host reverse proxy), bundled Caddy profile dropped. +services: + frontend: + image: ghcr.io/maathimself/mailflow-frontend:3.8.1@sha256:8f4b3e6cc7ccf045669edd4f2887bcd6de970377b6c95d48f7b1e8430563be96 # yamllint disable-line rule:line-length + container_name: mailflow-frontend + restart: unless-stopped + cap_drop: + - ALL + cap_add: + # nginx master binds :80/:443, creates its temp dirs and drops workers + # to the nginx user + - NET_BIND_SERVICE + - CHOWN + - SETUID + - SETGID + security_opt: + - no-new-privileges:true + # Upstream supports read-only roots: the entrypoint's config rewrites are + # best-effort and the baked-in resolver (127.0.0.11) is Docker's. + read_only: true + tmpfs: + - /tmp + - /run + - /var/cache/nginx + # Self-signed cert for the unused internal :443 listener, regenerated + # on each start + - /etc/nginx/ssl + ports: + - "127.0.0.1:3008:80" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO/dev/null http://127.0.0.1:80/ || exit 1"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 20s + networks: + - mailflow + - app-infra + + backend: + image: ghcr.io/maathimself/mailflow-backend:3.8.1@sha256:c98193a87a232661895cf5300e4f886733d34410b68c408a347c6ebadc838e98 # yamllint disable-line rule:line-length + container_name: mailflow-backend + restart: unless-stopped + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp + env_file: + - .env + environment: + # Stack wiring only; everything else lives in .env + NODE_ENV: production + PORT: 3000 + FRONTEND_URL: ${APP_URL} + DB_HOST: postgres + DB_NAME: mailflow + DB_USER: mailflow + REDIS_URL: redis://redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s + networks: + - mailflow + + postgres: + image: postgres:18.6-alpine3.23@sha256:885cf05d376c7cf27afef02073e6bdac3841252537f16e244fd1c1e6a7c99fb1 + container_name: mailflow-postgres + restart: unless-stopped + environment: + POSTGRES_DB: mailflow + POSTGRES_USER: mailflow + POSTGRES_PASSWORD: ${DB_PASSWORD} + # PG18: version-specific PGDATA; volume mounts at /var/lib/postgresql + PGDATA: /var/lib/postgresql/18/docker + volumes: + - postgres_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mailflow -d mailflow"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + networks: + - mailflow + + redis: + image: valkey/valkey:9.1-alpine3.23@sha256:c9b77919daeba2c02ad954d0c844cc4e7142069d177b89c5fd771f405daf9e02 # yamllint disable-line rule:line-length + container_name: mailflow-redis + restart: unless-stopped + command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD-SHELL", "valkey-cli ping | grep PONG"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s + networks: + - mailflow + +volumes: + postgres_data: + redis_data: + +networks: + mailflow: + app-infra: + external: true diff --git a/web1/containers/webmail/docker-compose.yml b/web1/containers/webmail/docker-compose.yml deleted file mode 100644 index dd4fca89..00000000 --- a/web1/containers/webmail/docker-compose.yml +++ /dev/null @@ -1,24 +0,0 @@ ---- -services: - webmail: - image: ghcr.io/linagora/tmail-web:release - container_name: webmail - restart: unless-stopped - cap_drop: - - ALL - security_opt: - - no-new-privileges:true - read_only: true - tmpfs: - - /tmp - - /run - ports: - - "127.0.0.1:3001:80" - volumes: - - ./env.file:/usr/share/nginx/html/assets/env.file - networks: - - app-infra - -networks: - app-infra: - external: true From e3665b62565afbd0dc8a57c454f4eb5ad31dba43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:58:03 +0000 Subject: [PATCH 2/2] refactor(web1): move mailflow env wiring into .env files Drop the inline environment blocks: backend wiring now lives in .env and the postgres container reads its own .env.postgres (twenty's pattern), both with committed .example files. The pg_isready healthcheck reads POSTGRES_USER/POSTGRES_DB from the container env instead of hardcoding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011mzrFnReayRM6LnqhZks6R --- web1/containers/mailflow/.env.example | 18 ++++++++++++++---- .../containers/mailflow/.env.postgres.example | 7 +++++++ web1/containers/mailflow/docker-compose.yml | 19 +++---------------- 3 files changed, 24 insertions(+), 20 deletions(-) create mode 100644 web1/containers/mailflow/.env.postgres.example diff --git a/web1/containers/mailflow/.env.example b/web1/containers/mailflow/.env.example index 38063977..c8be3bbf 100644 --- a/web1/containers/mailflow/.env.example +++ b/web1/containers/mailflow/.env.example @@ -1,9 +1,19 @@ # ── MailFlow backend ─────────────────────────────────────── -# Also read by compose for interpolation: APP_URL feeds FRONTEND_URL and -# DB_PASSWORD feeds the postgres container's POSTGRES_PASSWORD. +# Postgres credentials live in .env.postgres (postgres container). +NODE_ENV=production +PORT=3000 -# Public URL users see in the browser (invite emails, CORS) +# Public URL users see in the browser (invite emails, CORS). +# FRONTEND_URL must be the same value. APP_URL=https://mail.example.com +FRONTEND_URL=https://mail.example.com + +# Bundled services — DB_NAME/DB_USER/DB_PASSWORD must match .env.postgres +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=mailflow +DB_USER=mailflow +REDIS_URL=redis://redis:6379 # Host reverse proxy + MailFlow's own nginx. Safe because the frontend is only # published on 127.0.0.1. The proxy must send X-Forwarded-Proto: https. @@ -12,7 +22,7 @@ TRUST_PROXY_HOPS=2 # Secrets # openssl rand -hex 32 SESSION_SECRET= -# openssl rand -hex 16 +# openssl rand -hex 16 — same value as POSTGRES_PASSWORD in .env.postgres DB_PASSWORD= # openssl rand -hex 32 — encrypts stored IMAP/SMTP credentials at rest. # Losing or changing it means every account must be re-authenticated. diff --git a/web1/containers/mailflow/.env.postgres.example b/web1/containers/mailflow/.env.postgres.example new file mode 100644 index 00000000..160595a6 --- /dev/null +++ b/web1/containers/mailflow/.env.postgres.example @@ -0,0 +1,7 @@ +# PostgreSQL — consumed only by the postgres container. +# Keep these in sync with DB_NAME / DB_USER / DB_PASSWORD in .env. +POSTGRES_DB=mailflow +POSTGRES_USER=mailflow +POSTGRES_PASSWORD= +# PG18: version-specific PGDATA; the volume mounts at /var/lib/postgresql +PGDATA=/var/lib/postgresql/18/docker diff --git a/web1/containers/mailflow/docker-compose.yml b/web1/containers/mailflow/docker-compose.yml index bdcf1c67..6ea9bd27 100644 --- a/web1/containers/mailflow/docker-compose.yml +++ b/web1/containers/mailflow/docker-compose.yml @@ -56,15 +56,6 @@ services: - /tmp env_file: - .env - environment: - # Stack wiring only; everything else lives in .env - NODE_ENV: production - PORT: 3000 - FRONTEND_URL: ${APP_URL} - DB_HOST: postgres - DB_NAME: mailflow - DB_USER: mailflow - REDIS_URL: redis://redis:6379 depends_on: postgres: condition: service_healthy @@ -83,16 +74,12 @@ services: image: postgres:18.6-alpine3.23@sha256:885cf05d376c7cf27afef02073e6bdac3841252537f16e244fd1c1e6a7c99fb1 container_name: mailflow-postgres restart: unless-stopped - environment: - POSTGRES_DB: mailflow - POSTGRES_USER: mailflow - POSTGRES_PASSWORD: ${DB_PASSWORD} - # PG18: version-specific PGDATA; volume mounts at /var/lib/postgresql - PGDATA: /var/lib/postgresql/18/docker + env_file: + - .env.postgres volumes: - postgres_data:/var/lib/postgresql healthcheck: - test: ["CMD-SHELL", "pg_isready -U mailflow -d mailflow"] + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5