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
    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
    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:
