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..c8be3bbf --- /dev/null +++ b/web1/containers/mailflow/.env.example @@ -0,0 +1,47 @@ +# ── MailFlow backend ─────────────────────────────────────── +# Postgres credentials live in .env.postgres (postgres container). +NODE_ENV=production +PORT=3000 + +# 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. +TRUST_PROXY_HOPS=2 + +# Secrets +# openssl rand -hex 32 +SESSION_SECRET= +# 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. +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/.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 new file mode 100644 index 00000000..6ea9bd27 --- /dev/null +++ b/web1/containers/mailflow/docker-compose.yml @@ -0,0 +1,113 @@ +--- +# 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 + 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 + env_file: + - .env.postgres + volumes: + - postgres_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + 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