Files
jbpenrath bc966355b5 (global) clean thread snippet and display message snippet
Use the new preview_text method of jmap_email to generate
clean snippet for thread (denormalized at update_stats)
and message (at serialization). Now when a mesage is folded, we
display this snippet. In the thread list we also display this snippet above
the subject.
2026-08-13 12:08:40 +02:00

645 lines
20 KiB
YAML

name: st-messages
# Raise the open-file limit for the Django dev server + import workers. The
# Docker default (1024) is too low: the dev ``runserver`` keeps inbound HTTP
# keep-alive connections open with no idle timeout, so a long UI session polling
# the API (plus large imports opening S3 connections) exhausts FDs and wedges
# the backend with "Too many open files".
x-nofile-ulimits: &nofile-ulimits
nofile:
soft: 262144
hard: 262144
# Shared config for the Celery workers. worker-dev (full stack) and
# worker-dev-light (light stack) differ only in their profiles / depends_on /
# environment; everything else lives here. Note that ``environment`` is a list,
# so the ``<<`` merge does NOT combine it — each service repeats the full list.
x-worker-base: &worker-base
build:
context: src/backend
target: runtime-dev
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
ulimits: *nofile-ulimits
command: ["python", "worker.py", "--loglevel=DEBUG"]
env_file:
- deploy/env/backend.defaults
- deploy/env/backend.local
volumes:
- ./src/backend:/app
- ./data/static:/data/static
mem_limit: 2G
services:
postgresql:
image: postgres:16.6@sha256:557fea37a744d5f4c8faab304b0a90858b53ab119735a88c131fd19dab802f36
ports:
- "8912:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- deploy/env/postgresql.defaults
- deploy/env/postgresql.local
redis:
image: redis:5
ports:
- "8913:6379"
opensearch:
image: opensearchproject/opensearch:2.19.2@sha256:69588c664014fa3d3260ed5dd337f8538fec94790dad00bfbe92301eaaaac240
environment:
- discovery.type=single-node
- bootstrap.memory_lock=true
- "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
- "DISABLE_INSTALL_DEMO_CONFIG=true"
- "DISABLE_SECURITY_PLUGIN=true"
# - http.cors.enabled=true
# - "http.cors.allow-origin=/.*/"
ports:
- "8914:9200" # REST API
- "8915:9600" # Performance Analyzer
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9200"]
interval: 1s
timeout: 5s
retries: 60
ulimits:
memlock:
soft: -1 # Set memlock to unlimited (no soft or hard limit)
hard: -1
nofile:
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536
hard: 65536
mailcatcher:
image: maildev/maildev:2.2.1@sha256:180ef51f65eefebb0e7122d8308813c1fd7bff164bc440ce5a3c2feee167a810
ports:
- "8904:1080"
- "8917:1025"
objectstorage:
# user: ${DOCKER_USER:-1000}
image: rustfs/rustfs:1.0.0-alpha.83@sha256:1cfa82fb394a7c6a4ffc24f5333b52afee608d258503f94b37fcce5cc965c896
environment:
- RUSTFS_ACCESS_KEY=st-messages
- RUSTFS_SECRET_KEY=password
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ADDRESS=0.0.0.0:9000
- RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
- RUSTFS_CORS_ALLOWED_ORIGINS=*
- RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=*
ports:
- "8906:9000"
- "8907:9001"
healthcheck:
test:
[
"CMD",
"sh",
"-c",
"curl -f http://127.0.0.1:9000/health && curl -f http://127.0.0.1:9001/rustfs/console/health",
]
interval: 1s
timeout: 5s
retries: 60
start_period: 0s
volumes:
- objectstorage-data:/data
keycloak:
image: quay.io/keycloak/keycloak:26.7.1@sha256:f1f1f01e472c8a78df40d8f2a49a925274eda4d3d80d5f6edbb5c880ee3c01c6
volumes:
- ./src/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro
- ./src/keycloak/themes/dsfr-2.3.4.jar:/opt/keycloak/providers/keycloak-theme.jar:ro
- ./src/keycloak/bulk-role-membership/bulk-role-membership.jar:/opt/keycloak/providers/bulk-role-membership.jar:ro
environment:
- HOST=http://localhost:8902
- ADMIN_HOST=http://localhost:8902
command:
- start-dev
- --features=preview
- --import-realm
- --proxy-headers=xforwarded
- --http-enabled=true
- --hostname=$${HOST}
- --hostname-admin=$${ADMIN_HOST}
- --http-port=8802
# Expose the management health endpoints (:9000/health/*) so the
# healthcheck below can tell when Keycloak is actually serving.
- --health-enabled=true
env_file:
- deploy/env/keycloak.defaults
- deploy/env/keycloak.local
ports:
- "8902:8802"
# Without a healthcheck, `--wait` / `depends_on: service_started` consider
# Keycloak ready the instant the container process launches — but the JVM is
# still booting + importing the realm for several seconds, so :8902 500s.
# The image ships no curl/wget, so probe the management readiness endpoint
# (:9000/health/ready, 200 only once the realm import + DB are up) with
# bash's /dev/tcp.
healthcheck:
test:
- CMD
- bash
- -c
- "exec 3<>/dev/tcp/localhost/9000; printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3; head -n1 <&3 | grep -q ' 200'"
interval: 3s
timeout: 5s
retries: 40
# During start-up, probe every second (instead of `interval`) so Keycloak
# is detected healthy within ~1s of actually serving. start_period is set
# to comfortably cover a cold JVM boot + realm import so those early
# failures stay in the grace window.
start_period: 25s
start_interval: 1s
depends_on:
- postgresql
backend-base:
build:
context: src/backend
target: runtime-dev
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
volumes:
- ./src/backend:/app
- ./data/static:/data/static
# Dev-only override: live-mount the jmap-email working tree over the
# package installed from PyPI so local source edits propagate without a
# rebuild. The wheel installed at
# ``/venv/lib/${PYTHON_VERSION}/site-packages/jmap_email`` is overlaid
# with the working-tree source. Override ``PYTHON_VERSION`` in the
# environment when the backend's Python floor moves. Comment this mount
# out to run exactly what CI/prod install from PyPI.
- ./src/jmap-email/jmap_email:/venv/lib/${PYTHON_VERSION:-python3.14}/site-packages/jmap_email
ulimits: *nofile-ulimits
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request as u; u.urlopen('http://localhost:8000/__heartbeat__/', timeout=1)"]
interval: 3s
retries: 3
start_period: 10s
backend-dev:
extends: backend-base
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
env_file:
- deploy/env/backend.defaults
- deploy/env/backend.local
ports:
- "8901:8000"
depends_on:
postgresql:
condition: service_healthy
# restart: true
objectstorage:
condition: service_healthy
redis:
condition: service_started
opensearch:
condition: service_healthy
keycloak:
condition: service_healthy
mailcatcher:
condition: service_started
# Lean backend for the default `make start`: same Development config (Redis
# cache + Celery broker, real worker) but WITHOUT OpenSearch, object storage
# or the MTAs — so a plain dev loop boots a handful of containers. Under the
# `light` profile so a bare `docker compose up` (make start-full) skips it;
# `make start` names it explicitly. `backend-dev` is left untouched because
# `make test-back` (docker compose run backend-dev) relies on its OpenSearch /
# object-storage deps to provision those for the search/storage test suites.
backend-dev-light:
extends: backend-base
profiles:
- light
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
# No OpenSearch container in this stack: skip the per-delivery reindex
# enqueue so the worker isn't logging connection errors (search is a
# start-full feature).
- OPENSEARCH_INDEX_THREADS=False
env_file:
- deploy/env/backend.defaults
- deploy/env/backend.local
ports:
- "8901:8000"
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_started
keycloak:
condition: service_healthy
backend-db:
extends: backend-base
profiles:
- tools
environment:
- DJANGO_CONFIGURATION=DevelopmentMinimal
env_file:
- deploy/env/backend.defaults
- deploy/env/backend.local
ports:
- "8901:8000"
depends_on:
postgresql:
condition: service_healthy
# restart: true
backend-uv:
profiles:
- tools
volumes:
- ./src/backend:/app
build:
context: src/backend
target: uv
pull_policy: build
worker-dev:
<<: *worker-base
environment:
- DJANGO_CONFIGURATION=Development
volumes:
- ./src/backend:/app
- ./data/static:/data/static
# Same dev-only jmap-email overlay as backend-base: the worker imports
# the same core code, so it must see the same library source.
- ./src/jmap-email/jmap_email:/venv/lib/${PYTHON_VERSION:-python3.14}/site-packages/jmap_email
depends_on:
- backend-dev
# Worker for the light stack (see backend-dev-light). Needs only the
# broker (Redis) + DB — not the web backend, not OpenSearch. Defined inline
# rather than via ``extends: worker-dev`` because ``extends`` drags in that
# service's ``depends_on: [backend-dev]``, which would pull the full backend
# (and its OpenSearch / object-storage deps) back into the light stack.
worker-dev-light:
<<: *worker-base
profiles:
- light
environment:
- DJANGO_CONFIGURATION=Development
- OPENSEARCH_INDEX_THREADS=False
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_started
worker-ui:
build:
context: src/backend
target: runtime-dev
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
depends_on:
- redis
environment:
- FLOWER_UNAUTHENTICATED_API=true
- DJANGO_CONFIGURATION=Development
env_file:
- deploy/env/backend.defaults
- deploy/env/backend.local
volumes:
- ./src/backend:/app
# Same dev-only jmap-email overlay as backend-base: flower imports the
# same core code, so it must see the same library source.
- ./src/jmap-email/jmap_email:/venv/lib/${PYTHON_VERSION:-python3.14}/site-packages/jmap_email
ports:
- "8903:8803"
command: celery -A messages.celery_app flower --port=8803
frontend-base:
user: "${DOCKER_USER:-1000}"
build:
context: ./src/frontend
dockerfile: Dockerfile
# Lean image with no node_modules baked in — deps live in the
# frontend-node-modules volume, installed by `make install-frozen-front`.
target: frontend-dev-base
args:
DOCKER_USER: ${DOCKER_USER:-1000}
frontend-dev:
extends: frontend-base
env_file:
- deploy/env/frontend.defaults
- deploy/env/frontend.local
command: ["npm", "run", "dev"]
volumes:
- ./src/frontend/:/home/frontend/
# Keep node_modules on a fast Docker volume instead of the (slow,
# virtualized) host bind mount above. Writing ~thousands of files to a
# bind mount is what made installs take minutes; a named volume lives on
# the container filesystem, so `npm ci` runs in ~20s. Populated by
# `make install-frozen-front`.
- frontend-node-modules:/home/frontend/node_modules
ports:
- "8900:3000"
frontend-tools:
extends: frontend-base
profiles:
- frontend-tools
volumes:
- ./src/backend/core/api/openapi.json:/home/backend/core/api/openapi.json
- ./src/frontend/:/home/frontend/
# Share the same fast node_modules volume as frontend-dev so that
# `make install-frozen-front` (which runs here) installs into the volume
# that frontend-dev consumes.
- frontend-node-modules:/home/frontend/node_modules
frontend-tools-amd64:
extends: frontend-tools
platform: linux/amd64
# Mobile (Capacitor) web build: like frontend-tools but with the env_file, so
# the NEXT_PUBLIC_* vars Vite inlines at build time are present (frontend-tools
# has none). The native projects (android/, ios/) are written back to the host
# through the mount; the native compile (gradle/xcode) stays a host step.
frontend-mobile:
extends: frontend-base
profiles:
- frontend-tools
# Run in the root group and register the (arbitrary) host uid in /etc/passwd
# before exec'ing the command, so the Capacitor CLI's os.userInfo() call does
# not throw ENOENT under musl. See the chmod in src/frontend/Dockerfile.
# Use DOCKER_UID (uid only) and not DOCKER_USER (uid:gid): appending ":0" to
# the latter yields a malformed "uid:gid:0" spec that drops the root group,
# leaving /etc/passwd read-only and breaking the registration above.
user: "${DOCKER_UID:-1000}:0"
entrypoint:
- /bin/sh
- -c
- 'grep -q ":x:$$(id -u):" /etc/passwd || echo "builder:x:$$(id -u):0::/home/frontend:/bin/sh" >> /etc/passwd; exec "$$@"'
- --
# The Capacitor CLI writes its config under $HOME/.config. HOME is unset for
# the arbitrary uid, so it falls back to "/" and fails with EACCES on
# mkdir /.config. Point it at a writable, non-mounted path (keeps the CLI's
# throwaway config out of the bind-mounted repo).
environment:
HOME: /tmp
env_file:
- deploy/env/frontend.defaults
- deploy/env/frontend.local
volumes:
- ./src/frontend/:/home/frontend/
# Share the same fast node_modules volume as frontend-dev so that
# `make install-frozen-front` (which runs here) installs into the volume
# that frontend-dev consumes.
- frontend-node-modules:/home/frontend/node_modules
crowdin:
image: crowdin/cli:4.11.0@sha256:9b32eafcfd8ba4b5183bd601bfeb8a65b0d338f2d2fc36df155f4845ee244eff
volumes:
- ".:/app"
env_file:
- deploy/env/crowdin.defaults
- deploy/env/crowdin.local
user: "${DOCKER_USER:-1000}"
working_dir: /app
mta-in:
build:
context: src/mta-in
target: runtime-prod
env_file:
- deploy/env/mta-in.defaults
- deploy/env/mta-in.local
ports:
- "8910:25"
depends_on:
- backend-dev
mta-in-test:
profiles:
- tools
build:
context: src/mta-in
target: runtime-dev
env_file:
- deploy/env/mta-in.defaults
- deploy/env/mta-in.local
environment:
- EXEC_CMD=true
- MDA_API_BASE_URL=http://localhost:8000/api/mail/
- MTA_HOST=localhost
- MTA_PORT=25
- MTA_IMPL=postfix
command: pytest -vvs tests/
volumes:
- ./src/mta-in:/app
mta-in-uv:
profiles:
- tools
volumes:
- ./src/mta-in:/app
build:
context: src/mta-in
target: uv
pull_policy: build
# ---- Pure-Python (aiosmtpd) inbound MTA --------------------------------
# Runs side-by-side with the Postfix-based `mta-in` service on a different
# host port (8920 vs 8910). Both implementations share the same MDA
# contract, env vars, and test suite. Toggle which one is the public-facing
# MTA at the edge by switching the upstream pool.
mta-in-py:
build:
context: src/mta-in
dockerfile: Dockerfile.pymta
target: runtime-distroless-prod
args:
DOCKER_USER: ${DOCKER_USER:-65532}
user: ${DOCKER_USER:-65532}
env_file:
- deploy/env/mta-in.defaults
- deploy/env/mta-in.local
- deploy/env/mta-in-py.defaults
- deploy/env/mta-in-py.local
ports:
- "8920:25"
- "9120:9100" # Prometheus metrics
# Defence-in-depth: pymta needs no on-disk writes at runtime. Read-only
# rootfs + dropped capabilities + no-new-privileges mirror the posture
# a production k8s pod-spec should run with.
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
depends_on:
- backend-dev
mta-in-py-test:
profiles:
- tools
build:
context: src/mta-in
dockerfile: Dockerfile.pymta
target: runtime-dev
args:
DOCKER_USER: ${DOCKER_USER:-65532}
user: ${DOCKER_USER:-65532}
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file:
- deploy/env/mta-in.defaults
- deploy/env/mta-in.local
- deploy/env/mta-in-py.defaults
- deploy/env/mta-in-py.local
environment:
- EXEC_CMD=true
- MDA_API_BASE_URL=http://localhost:8000/api/mail/
- MTA_HOST=localhost
- MTA_PORT=25
- MTA_IMPL=pymta
- MTA_METRICS_URL=http://localhost:9100/metrics
command: pytest -vvs tests/
volumes:
- ./src/mta-in:/app
mta-out:
build:
context: src/mta-out
target: runtime-prod
env_file:
- deploy/env/mta-out.defaults
- deploy/env/mta-out.local
ports:
- "8911:587"
depends_on:
mailcatcher:
condition: service_started
mta-out-test:
profiles:
- tools
build:
context: src/mta-out
target: runtime-dev
env_file:
- deploy/env/mta-out.defaults
- deploy/env/mta-out.local
environment:
- EXEC_CMD=true
- MTA_OUT_SMTP_HOST=localhost:587
- MTA_OUT_SMTP_USERNAME=user
- MTA_OUT_SMTP_PASSWORD=pass
- SMTP_RELAY_HOST=localhost:2525
command: pytest -vvs tests/
volumes:
- ./src/mta-out:/app
mta-out-uv:
profiles:
- tools
volumes:
- ./src/mta-out:/app
build:
context: src/mta-out
target: uv
pull_policy: build
socks-proxy:
build:
context: src/socks-proxy
target: runtime
env_file:
- deploy/env/socks-proxy.defaults
- deploy/env/socks-proxy.local
ports:
- "8916:1080"
socks-proxy-test:
profiles:
- tools
build:
context: src/socks-proxy/tests
environment:
- SOCKS_PROXY1=user1:pwd1@socks-proxy:1080
- SOCKS_PROXY2=user2:pwd2@socks-proxy:1080
depends_on:
socks-proxy:
condition: service_started
mpa:
build:
context: src/mpa/rspamd
environment:
- RSPAMD_password=password
- PORT=8010
ports:
- "8918:8010"
depends_on:
redis:
condition: service_started
mpa-test:
profiles:
- tools
build:
context: src/mpa/tests
environment:
- RSPAMD_URL=http://mpa:8010/_api
- RSPAMD_AUTH=Bearer password
command: pytest -vvs tests/
volumes:
- ./src/mpa/tests:/app/tests
depends_on:
mpa:
condition: service_started
redis:
condition: service_started
# Self-contained jmap-email package tests. Zero infrastructure
# dependencies (no DB, no opensearch, no redis) — the library has
# no runtime deps. Source is mounted for instant feedback during
# development.
jmap-email-test:
profiles:
- tools
build:
context: src/jmap-email
command: pytest -q tests/
volumes:
- ./src/jmap-email/jmap_email:/app/jmap_email
- ./src/jmap-email/tests:/app/tests
- ./src/jmap-email/pyproject.toml:/app/pyproject.toml
# Hypothesis' example database. Without it the container starts with
# no memory of past failures, so Phase.reuse has nothing to replay
# and an intermittent find stays intermittent.
#
# A named volume rather than a bind mount: a bind mount to a path
# that does not exist yet — and it never does on a fresh clone,
# since .hypothesis is gitignored — is created by Docker as *root*,
# which then blocks `git clean -fdx` and any pytest run against a
# local venv. This is a cache, so nothing needs to read it from the
# host.
- jmap-email-hypothesis:/app/.hypothesis
volumes:
jmap-email-hypothesis:
objectstorage-data:
frontend-node-modules: