Files
pl_mail/compose.yaml
T
Paul LütznerandClaude Opus 5 4d103cfbdf Write the handbook, and make it fail the build when it goes stale
README says what plMail is and gets you running; CONTRIBUTING says how to
change it. Nothing covered the part in between — using it, operating it,
and registering the Google and Microsoft applications it talks to. That
is docs/: 34 pages across features, installation, providers and
internals, mirrored to the GitHub wiki.

The wiki is a mirror and never a source. bin/mirror-wiki.php flattens the
tree into the wiki's flat namespace, rewrites every relative link,
generates the sidebar from the index so the two cannot disagree, and
stamps each page with a footer saying where it came from. A browser edit
survives until the next push to main. That cost is deliberate:
documentation not versioned with the code drifts from it, and the drift
is invisible until somebody follows an instruction that stopped being
true.

Which is the whole problem, so the checkable half is checked.
DocumentationCoverageTest fails the build when a command is missing from
the reference or documented and gone, when a variable in .env is absent
from the configuration page, when a relative link dangles, when a page is
unreachable from the index or promised and missing, or when a page has no
"Things that bite" section. It cannot check whether a paragraph is TRUE,
and says so — this is the floor, so review attention goes to the part a
machine cannot have an opinion about. CONTRIBUTING has claimed since it
was written that the command table "is part of the definition of done";
nothing enforced it until now, and it was wrong in three places.

Writing it found things. They are fixed here rather than documented
around, because a handbook describing a bug accurately is still a
handbook nobody can follow.

  compose.yaml            mounted no volume for var/attachments, var/raw
                          or var/uploads. Workers write those during sync
                          and the web container serves them back, so each
                          kept its own copy in its own layer — downloads
                          404, and everything died on a recreate. The
                          override and the TrueNAS file both mount them;
                          the file the README tells an operator to run did
                          not. The same omission cost real data once
                          before. See the CHANGELOG for moving what is
                          currently stranded.

  app:calendar:materialise  new, nightly. Occurrences are drawn when an
                          event is saved and nothing moved that window
                          again, so a weekly standup ran out of dates in
                          two years and its reminders stopped with them —
                          silently. The query it runs had existed since
                          the materialiser did, documented as exactly this
                          sweep, and had never been called.

  Microsoft wizard        asked for three of the nine permissions plMail
                          requests, and a token already issued is not
                          upgraded when the rest are added later.

  GmailWatchService       told you to grant Pub/Sub Publisher to
                          gmail-api@system.gserviceaccount.com. The
                          account is gmail-api-push@; a topic granted
                          exactly what the docblock asked for never
                          delivered a notification.

  IcsExporter             said it wrote no VALARM. It has written them
                          since alerts landed.

  CLIENT_DEVELOPMENT.md   said there was no JMAP calendar API and that
                          SearchSnippet/get was absent. Both shipped.

  CONTRIBUTING            said only app:calendar:push registers a push
                          channel; subscribing has done it since the
                          subscribe flow learned to.

  Three docblocks         named getters removed long ago, or described a
                          method two below the one they sat on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:13:25 +02:00

351 lines
14 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 shell or a .env beside this file and yours is used instead — nothing is
# ever generated over the top of a value you supplied.
#
# 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.
x-app-env: &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
# 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: