Files
pl_mail/compose.yaml
T
Paul LütznerandClaude Opus 5 ab9ecbc9e0 Hand a whole-view bulk action to a worker, which it never was
RunBulkStatusHandler exists to take a mark-read or archive over a whole view
out of the request — its docblock quotes the thirty-second timeout that
produced it, and BulkStatusController answers with an indicator so nobody
waits. Neither was true: the message was never listed in messenger.yaml's
routing map, and Messenger handles an unrouted message inline in whichever
process dispatched it. Every whole-view action ran inside the web request,
under the limit it was written to escape, at request concurrency — which is
where the production deadlock came from.

It gets a queue of its own rather than joining maintenance, because somebody
is watching an indicator for it and queueing it behind an embedding backfill
would be slower than the inline version it replaces. That means one new
container, worker-bulk, in every compose file and in the handbook.

The deadlock itself is retried now instead of failing the job, three times per
chunk, and a collision that outlasts those hands the job back to the queue
rather than ending it. The failure path no longer writes through an
EntityManager Doctrine has closed, which is what it was doing for every
failure that came out of a flush.

And the list catches up: nothing ever refreshed it on the strength of the job,
only on the toolbar's own "the write landed" event, which is now the instant
the work was QUEUED rather than finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:18:57 +02:00

392 lines
16 KiB
YAML

x-app-image: &app-image ghcr.io/karatektus/pl_mail:latest
# Every secret below is blank by default and generated on first start by the
# secrets-init service, into the shared app_secrets volume. Set any of them in
# your environment and yours is used instead — nothing is ever generated over
# the top of a value you supplied.
#
# "In your environment" and not "in a .env beside this file", because the .env
# beside this file is Symfony's — committed, full of development defaults, and
# read by compose for ${VAR} interpolation whether anyone meant it to be or not.
# A default written here only applies when that file is silent about the
# variable, so anything it does set is what a cloned repo actually runs with.
# ComposeEnvironmentTest holds every ${VAR} below to that: interpolate only what
# .env leaves alone, or leaves at exactly the value written here.
#
# APP_ENV is hardcoded for that reason. It was `${APP_ENV:-prod}` and .env says
# `APP_ENV=dev`, so every `git clone && docker compose up -d` — the install the
# README documents — booted the prod image in dev mode, where bundles.php loads
# DebugBundle, which `composer install --no-dev` never put in the image. Six
# services crash-looped on a missing class, and the entrypoint reported it as
# "the database is not up or not reachable". Running this stack in dev is what
# compose.override.yaml.dist is for; it sets APP_ENV per service and merges over
# this one.
#
# DATABASE_URL is deliberately absent: the app entrypoint assembles it from the
# generated POSTGRES_PASSWORD. Set it explicitly to point at a database of your
# own, and the entrypoint leaves it alone. It is the one exemption from the rule
# above — .env sets a credential-less DSN on purpose (a blank one has no driver
# and breaks the image's cache warmup), and the entrypoint tests for the missing
# password rather than for emptiness.
x-app-env: &app-env
APP_ENV: prod
APP_SECRET: ${APP_SECRET:-}
APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:-}
DATABASE_URL: ${DATABASE_URL:-}
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_VERSION: ${POSTGRES_VERSION:-18}
MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-doctrine://default?auto_setup=0}
MERCURE_URL: ${MERCURE_URL:-http://mercure/.well-known/mercure}
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL:-https://localhost/.well-known/mercure}
# The state that must outlive a container and be shared between them.
#
# Every app service mounts all four. app_secrets because the secrets minted on
# first run live there and a service that cannot see them generates its own and
# stops being able to read what the others wrote; the other three because the
# WORKERS write attachments, raw sources and staged uploads while the web
# container is what serves them back. Unmounted, each container keeps its own
# copy in its own layer: downloads 404 because the file is in a process that is
# not answering the request, and everything written vanishes on the next
# `docker compose up` that recreates a container.
#
# This has happened before. compose.override.yaml.dist was missing exactly these
# three mounts once and it cost real data — CONTRIBUTING.md records it — and
# they were then added to the override and to truenas.compose.yaml but never to
# the file the README actually tells an operator to run.
x-app-storage: &app-storage
- app_secrets:/app/var/secrets
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
services:
# Mints APP_SECRET, APP_ENCRYPTION_KEY, POSTGRES_PASSWORD and
# MERCURE_JWT_SECRET into app_secrets, then exits. Runs before everything
# else because Postgres and Mercure read their secrets when their containers
# are created — they cannot wait for the app to hand them one.
#
# Idempotent: on every later start it finds the file and does nothing.
secrets-init:
image: *app-image
restart: "no"
entrypoint: ["generate-secrets"]
environment:
APP_SECRET: ${APP_SECRET:-}
APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:-}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET:-}
volumes:
- app_secrets:/app/var/secrets
php:
image: *app-image
restart: unless-stopped
environment:
<<: *app-env
SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
MAILER_DSN: ${MAILER_DSN:-null://null}
APP_CONTAINER_NAME: web
volumes:
- caddy_data:/data
- caddy_config:/config
# The x-app-storage list, spelled out: this service adds Caddy's own two,
# and YAML merges mappings but not sequences, so there is no way to say
# "that anchor, plus these". Keep the four in step with it.
- app_secrets:/app/var/secrets
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
ports:
- target: 80
published: ${HTTP_PORT:-80}
protocol: tcp
- target: 443
published: ${HTTPS_PORT:-443}
protocol: tcp
- target: 443
published: ${HTTP3_PORT:-443}
protocol: udp
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
imap-supervisor:
image: *app-image
restart: unless-stopped
command: php bin/console app:imap:supervise
stop_grace_period: 30s
environment:
<<: *app-env
APP_CONTAINER_NAME: imap-supervisor
volumes: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# ── Workers, one per queue ────────────────────────────────────────────────
# Three processes rather than three transports on one, because a worker
# already inside a long handler cannot pick up anything else however the
# queues are prioritised. That was the whole problem: pressing Send behind a
# Gmail batch waited for the batch.
#
# Anything leaving plMail, and the only queue somebody is watching. Kept on
# its own process so a send is never behind a sync.
worker-export:
image: *app-image
restart: unless-stopped
command: php bin/console messenger:consume export --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-export
volumes: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Mail arriving and the work that immediately follows it.
worker-ingest:
image: *app-image
restart: unless-stopped
command: php bin/console messenger:consume ingest --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-ingest
volumes: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Bulk and deliberate: backfills, rule runs over existing mail, admin sweeps.
#
# Also drains `async`, the pre-split queue, so envelopes written before the
# upgrade still have a consumer. Nothing routes there any more — drop it from
# this command and from messenger.yaml once a release has passed.
worker-maintenance:
image: *app-image
restart: unless-stopped
command: php bin/console messenger:consume maintenance async --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-maintenance
volumes: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# A whole-view mark-read or archive. On its own process for the reason the
# send queue is: somebody pressed a button and is watching an indicator, and
# this must not wait behind a backfill on `maintenance`. Drop this service and
# the indicator fills to nothing — the job is queued and never handled.
worker-bulk:
image: *app-image
restart: unless-stopped
command: php bin/console messenger:consume bulk --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-bulk
volumes: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Fires the recurring jobs in App\Infrastructure\Scheduler\MaintenanceSchedule.
# Separate from messenger-worker so a long prune cannot stall the async queue.
scheduler:
image: *app-image
restart: unless-stopped
command: php bin/console messenger:consume scheduler_default --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: scheduler
volumes: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
database:
image: postgres:${POSTGRES_VERSION:-18}-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_USER: ${POSTGRES_USER:-app}
# The official image supports _FILE for any of its variables, which is the
# only way to hand it a password that did not exist when compose parsed
# this file.
POSTGRES_PASSWORD_FILE: /run/plmail/postgres_password
depends_on:
secrets-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- database_data:/var/lib/postgresql
- app_secrets:/run/plmail:ro
command:
- "postgres"
- "-c"
- "shared_preload_libraries=pg_stat_statements"
- "-c"
- "pg_stat_statements.track=top"
mercure:
image: dunglas/mercure
restart: unless-stopped
environment:
SERVER_NAME: ':80'
# Left empty on purpose: the entrypoint below fills them from the
# generated file. The hub image has no _FILE support, so sourcing it in a
# shell before exec'ing Caddy is the way in. Setting MERCURE_JWT_SECRET
# yourself still wins — the generator never overwrites it, so both sides
# read the same value.
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET:-}
entrypoint:
- /bin/sh
- -c
- |
if [ -z "$$MERCURE_JWT_SECRET" ] && [ -f /run/plmail/generated.env ]; then
MERCURE_JWT_SECRET="$$(sed -n 's/^MERCURE_JWT_SECRET=//p' /run/plmail/generated.env)"
fi
export MERCURE_PUBLISHER_JWT_KEY="$$MERCURE_JWT_SECRET"
export MERCURE_SUBSCRIBER_JWT_KEY="$$MERCURE_JWT_SECRET"
exec "$$@"
- --
# The image's default Caddyfile, NOT dev.Caddyfile. The dev one turns on
# `anonymous`, which accepts any subscriber without a JWT — and since
# frankenphp/Caddyfile proxies /.well-known/mercure* straight through, that
# let anyone reaching this instance subscribe to mail/user/<any id> and
# watch that account's sync activity. It also enables `demo`, `subscriptions`
# and `cors_origins *`. The default config requires the subscriber JWT the
# app now mints, and needs no CORS because the browser reaches the hub
# same-origin through our own Caddy.
command: /usr/bin/caddy run --config /etc/caddy/Caddyfile
depends_on:
secrets-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/healthz"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- mercure_data:/data
- mercure_config:/config
- app_secrets:/run/plmail:ro
# Push for Android, without Google.
#
# Optional and off by default: `docker compose --profile push up -d`. Nobody
# who does not want push pays for a container they never use.
#
# This needs no plMail code at all. The app already speaks Web Push — RFC 8030
# transport, RFC 8291 payload encryption, RFC 8292 VAPID signing — and ntfy is
# a push service that speaks exactly that. What it adds is the missing half on
# Android: RFC 8030 requires a *push service* that owns the endpoint URL and
# holds the connection to the device, browsers ship one and native apps do
# not. A UnifiedPush distributor on the phone talks to this.
#
# The alternative for an Android user is Firebase, which means Google sees
# that a message arrived and when. Running this instead keeps that on your own
# hardware, which is the reason to self-host mail in the first place.
ntfy:
image: binwiederhier/ntfy
profiles: [push]
restart: unless-stopped
command: serve
environment:
# Derived from SERVER_NAME, so push needs no configuration of its own:
# the host set at first boot is already the one phones can reach, which is
# the only thing this has to be. Set NTFY_BASE_URL explicitly to override
# it — a separate hostname, or TLS terminated in front (see below).
#
# It is baked into every endpoint handed out, so changing it later
# invalidates every existing subscription and every device must
# re-register. Worth getting right before handing the address to phones.
#
# ntfy cannot be hosted under a sub-path — it refuses a base-url with one
# at startup — so it cannot be folded behind the app's own Caddy at
# /push the way the Mercure hub is at /.well-known/mercure. Hence a port
# of its own.
NTFY_BASE_URL: ${NTFY_BASE_URL:-http://${SERVER_NAME:-localhost}:${NTFY_PORT:-8090}}
NTFY_LISTEN_HTTP: ':80'
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
# Open by default, and that is a considered choice rather than laziness.
# UnifiedPush topics are random and unguessable, and the payload is
# encrypted to the device's own key before it ever reaches here — so this
# service cannot read your mail, and someone who guessed a topic could
# only send noise the app fails to decrypt and discards.
#
# The default URL above is http, which is right for the LAN and Tailscale
# deployments this is usually reached over, and not for the open
# internet: the endpoint URL is itself the secret, so anyone able to watch
# the traffic could learn a topic. Facing the internet, put TLS in front
# and set NTFY_BASE_URL to the https address.
#
# To lock it down anyway, set this to deny-all and create a user:
# docker compose exec ntfy ntfy user add --role=admin plmail
# then give the phone that login in the ntfy app. It is the right move if
# this is exposed to the internet rather than reached over a VPN.
NTFY_AUTH_DEFAULT_ACCESS: ${NTFY_AUTH_DEFAULT_ACCESS:-read-write}
# A personal server has a handful of devices; the defaults are sized for a
# public instance and would let one client exhaust it.
NTFY_VISITOR_REQUEST_LIMIT_BURST: '60'
NTFY_VISITOR_REQUEST_LIMIT_REPLENISH: '5s'
ports:
- "${NTFY_PORT:-8090}:80"
volumes:
- ntfy_data:/var/lib/ntfy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/v1/health"]
timeout: 5s
retries: 5
start_period: 20s
volumes:
app_secrets:
app_attachments:
app_raw:
app_uploads:
caddy_data:
caddy_config:
database_data:
mercure_data:
mercure_config:
ntfy_data: